Compare commits
28
Commits
ce1f1aaca0
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c1a3ee62e | ||
|
|
8dcd1f2482 | ||
|
|
602bd43fce | ||
|
|
a52588cd0f | ||
|
|
4fdc62754c | ||
|
|
9f5e8702a2 | ||
|
|
2d0d5c1592 | ||
|
|
7b2d2db322 | ||
|
|
c084c57e2c | ||
|
|
c2c0d498ee | ||
|
|
e54edbc037 | ||
|
|
9e4f50c955 | ||
|
|
a74c24b6fe | ||
|
|
7c299f1cfd | ||
|
|
925c8f9729 | ||
|
|
832ebf2665 | ||
|
|
000cd2022d | ||
|
|
ac7fca8b40 | ||
|
|
f639229d09 | ||
|
|
4423024974 | ||
|
|
3babc5567f | ||
|
|
3ddd0b6d80 | ||
|
|
80d44ee687 | ||
|
|
ee7fcab71c | ||
|
|
8c9324350b | ||
|
|
c0eae92b10 | ||
|
|
8686a2e1d6 | ||
|
|
829be90d19 |
@@ -97,6 +97,7 @@
|
||||
- `test` - 测试相关
|
||||
- `chore` - 构建/工具/配置
|
||||
- 描述使用祈使句、现在时态、句尾无句号
|
||||
- **Body 规则**:不列举 commit 中每一个文件的变更,body 只列出变更概要列表即可
|
||||
- **Scope 推导规则**:
|
||||
- 从被提交的文件路径中推导出一个最相关的 scope
|
||||
- 一个 commit 只写一个主要 scope,不要罗列多个
|
||||
@@ -109,6 +110,18 @@
|
||||
- `docs: 更新 API 文档`(全局文档更新)
|
||||
- `chore(deps): 升级 tokio 到 1.40`(依赖更新)
|
||||
|
||||
### Commit 提交流程
|
||||
|
||||
在生成 commit message 后,**必须先展示给用户确认**,不可直接执行 `git commit`。
|
||||
|
||||
流程:
|
||||
1. Agent 根据 diff 生成符合规范的 commit message
|
||||
2. 展示生成的 message,等待用户明确回应
|
||||
3. 用户有三种选择:
|
||||
- **确认** → 执行 `git commit`
|
||||
- **重新生成** → 根据用户反馈修改 message 后重新展示
|
||||
- **取消** → 放弃本次 commit 操作
|
||||
|
||||
---
|
||||
|
||||
### Rust
|
||||
|
||||
@@ -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
|
||||
@@ -23,3 +23,4 @@ time = { version = "0.3", features = ["serde"] }
|
||||
|
||||
[dev-dependencies]
|
||||
dotenvy = "0.15.7"
|
||||
wiremock = "0.6"
|
||||
|
||||
@@ -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是一个用于提供构建智能体的底层工具箱,其中提供的功能主要包括基础的大模型调用的完整周期,提示词的组合与优化,记忆检索与管理、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,515 @@
|
||||
# LLM Provider 重构改进方案(最终确认)
|
||||
|
||||
> 本文档记录 2026-06-25 设计评审后确认的方案决策,是对 9 系文档(`9-llm-provider-unified-interface.md` 及 `9a`-`9g` 子文档)中已有设计的**精炼与修订**。
|
||||
>
|
||||
> **阅读前提**:本文档假设读者已熟悉现有 9 系文档中的背景、架构总览和类型体系概念。
|
||||
>
|
||||
> **与 9 系的关系**:
|
||||
> - 9 系文档中的 `ContentBlock`、`MessageRequest`、`MessageResponse`、`StopReason`、`ThinkingConfig`、`ToolDefinition`、`PartialUsage` 等核心类型定义**继续有效**,本文档不再重复
|
||||
> - `ProviderCapabilities`、`LlmProvider trait` 签名、`PartialMessageResponse` 汇聚算法等 design intent **继续有效**(trait 签名由 9c 定义,切换时点由本文档 §4 Phase 0 执行)
|
||||
> - 本文档仅记录**本次确认的修订内容和执行计划**
|
||||
|
||||
---
|
||||
|
||||
## 修订记录
|
||||
|
||||
| 日期 | 版本 | 修订摘要 |
|
||||
|------|------|---------|
|
||||
| 2026-06-25 | v1 | 初版,记录 5 项设计决策 |
|
||||
| 2026-06-26 | v2 | 初审修订:修正 §1 表描述(Assistant → UserImage);消除 §2.3 MessageComplete 冗余字段;明确 Phase 0 add-only 策略;补充 9e 依赖审查说明;补充 HTTP mock 策略;修正 §5 兼容性验证标准;增加 §7 开放事项 |
|
||||
| 2026-06-26 | v3 | 复审修订:Phase 0 改为"add + trait 签名切换"消除结构性缺口;补充 OpenAI Response API 范围和 OpenAI-compatible 复用策略说明;调整 Phase 2 范围(聚焦逻辑简化) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 修订摘要
|
||||
|
||||
| 设计维度 | 9 系文档 | 本次修订 | 修订原因 |
|
||||
|----------|---------|---------|---------|
|
||||
| Message 模型 | 结构化层次(`System/User/Assistant/Tool`,每项含 `content: Vec<ContentBlock>`) | **扁平大枚举**(User 拆出 `UserImage` 独立变体,Assistant 保持整体,ToolUse 仍在 content 中) | 编译器能检查约束,`UserImage` 消费方 match 可直接区分文本和图片输入,无需检查 Vec 内容 |
|
||||
| StreamEvent 终端事件 | `MessageComplete { stop_reason, thinking_signature }` | **精简为 `MessageComplete { full_response: MessageResponse }`**,移除冗余顶层字段 | 消除冗余和消费方疑惑,唯一信源 |
|
||||
| Provider 发现 | 未明确 | **Enum-based**(`ProviderType` enum + exhaustive match),不做动态注册 | 当前协议数量可控,编译期安全,无运行时查表开销 |
|
||||
| 项目阶段 | 9 系是"推演中" | **可直接执行**,无历史包袱,一步到位 | 项目尚未 release,没有 breaking change 顾虑 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 本次修订的 5 项设计决策
|
||||
|
||||
### 2.1 Decision-01:Message 采用扁平大枚举
|
||||
|
||||
#### 定义
|
||||
|
||||
```rust
|
||||
/// 跨 Provider 统一的消息类型(扁平大枚举)。
|
||||
///
|
||||
/// 设计原则:每个变体直接承载完整语义,
|
||||
/// 消费方 match 即可获得所有信息,无需在嵌套的 Vec 中搜索。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
/// 系统提示(User & Assistant 之外的引导指令)
|
||||
System {
|
||||
content: Vec<ContentBlock>,
|
||||
},
|
||||
/// 用户输入
|
||||
User {
|
||||
content: Vec<ContentBlock>,
|
||||
},
|
||||
/// 用户的图片输入(快捷构造,免去构造 ContentBlock 的 boilerplate)
|
||||
UserImage {
|
||||
data: String,
|
||||
mime_type: String,
|
||||
detail: ImageDetail,
|
||||
},
|
||||
/// Assistant 回复内容块(可能包含 text、thinking、tool_use 等多种 block 的混合)
|
||||
///
|
||||
/// 注意:Assistant 的一次回复可以同时包含文本、思考过程、工具调用。
|
||||
/// 扁平大枚举并未将 ToolUse 提升为独立变体,而是保留在 content 中,
|
||||
/// 因为在一次 Assistant turn 中 text 和 tool_use 的**顺序关系**是有意义的。
|
||||
/// (例如:先输出推理过程,再调用工具)
|
||||
Assistant {
|
||||
content: Vec<ContentBlock>,
|
||||
},
|
||||
/// 工具调用结果
|
||||
ToolResult {
|
||||
tool_call_id: String,
|
||||
content: Vec<ContentBlock>,
|
||||
is_error: bool,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### 与 9 系结构化层次的差异
|
||||
|
||||
| 维度 | 9 系(结构化层次) | 本次(扁平大枚举) |
|
||||
|------|-------------------|-------------------|
|
||||
| Assistant 消息结构 | `Assistant { content: Vec<ContentBlock> }`,ToolUse 在 content 中 | 同上,保持 ToolUse 在 content 中 |
|
||||
| "独立 Assistant 消息"的含义 | 一次 LLM 响应 = 一个 `Assistant { content: [...] }` | 同上 |
|
||||
| Thinking / ToolCall 作为独立变体 | ❌ 无独立变体 | **Thinking、ToolCall 不作为独立 Message 变体**,仍在 `Assistant.content` 中 |
|
||||
| UserImage 独立变体 | `User { content: [Image{...}] }` | `UserImage { data, mime, detail }` |
|
||||
| 为什么不把 ToolUse 提到 Message 层 | — | 因为 text ↔ tool_use 的**交错顺序**是 Assistant 响应的语义组成部分,拆散后会丢失顺序信息 |
|
||||
| 实际的参与方差异 | `User` + `UserImage` 合并为同一变体 | `User` 和 `UserImage` **拆开**,方便消费方 match(无需检查 Vec 内容来区分文字和图片) |
|
||||
|
||||
> **与 9b 文档的关系**:9b 的 `Message::System`、`Message::User`、`Message::Assistant`、`Message::Tool` 四个变体分类保留,
|
||||
> 但 `User` 的图片输入场景通过新增 `UserImage` 变体提供便捷路径,减少 boilerplate。
|
||||
> `Message::Assistant` 的 `content: Vec<ContentBlock>` 保持不变——ToolUse 仍在 content 中。
|
||||
|
||||
#### 便捷构造函数
|
||||
|
||||
```rust
|
||||
impl Message {
|
||||
pub fn user_text(text: impl Into<String>) -> Self;
|
||||
pub fn user_image(data: impl Into<String>, mime_type: impl Into<String>, detail: ImageDetail) -> Self;
|
||||
pub fn assistant(text: impl Into<String>) -> Self;
|
||||
pub fn system(text: impl Into<String>) -> Self;
|
||||
pub fn tool_result(tool_call_id: impl Into<String>, text: impl Into<String>, is_error: bool) -> Self;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Decision-02:LlmProvider 感知消息类型
|
||||
|
||||
沿用 9c 文档中的 trait 设计,无修订。
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait LlmProvider: Send + Sync {
|
||||
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError>;
|
||||
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
request: MessageRequest,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>;
|
||||
|
||||
fn capabilities(&self) -> ProviderCapabilities;
|
||||
}
|
||||
```
|
||||
|
||||
每个 Provider 实现内部自行处理 `MessageRequest` ↔ 原生协议格式的映射。无外部转换层。
|
||||
|
||||
### 2.3 Decision-03:StreamEvent 高精度 + 终端事件携带完整响应
|
||||
|
||||
沿用 9c 文档中定义的 `StreamEvent`,但**在终端事件中增加完整响应快照**。
|
||||
|
||||
#### 修订后的 MessageComplete 事件
|
||||
|
||||
```rust
|
||||
pub enum StreamEvent {
|
||||
// ── Meta ──
|
||||
MessageStart { id: String, model: String },
|
||||
|
||||
// ── Content Block 边界 ──
|
||||
ContentBlockStart { index: u32, block_type: ContentBlockType },
|
||||
ContentBlockEnd { index: u32 },
|
||||
|
||||
// ── 块内增量 ──
|
||||
TextDelta { text: String },
|
||||
ThinkingDelta { text: String },
|
||||
RefusalDelta { text: String },
|
||||
ToolCallArgumentsDelta { index: u32, arguments: String },
|
||||
ToolCallEnd { index: u32 },
|
||||
|
||||
// ── 汇总 ──
|
||||
CostUpdate { usage: PartialUsage },
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 修订:MessageComplete 携带完整响应快照(移除冗余的 stop_reason / thinking_signature)
|
||||
// ═══════════════════════════════════════════════════════
|
||||
/// 消息完成 —— 唯一可靠的完整响应来源。
|
||||
///
|
||||
/// `full_response` 携带完整的 MessageResponse(含已拼接完毕的 content / usage / stop_reason),
|
||||
/// 消费方**无需自行累积 delta**,直接使用此快照继续后续流程。
|
||||
///
|
||||
/// 设计说明:
|
||||
/// - 9c 原有设计在 `MessageComplete` 中同时携带 `stop_reason` 和 `thinking_signature` 顶层字段,
|
||||
/// 但这些信息已包含在 `full_response` 中,造成冗余和消费方的疑惑(到底读顶层字段还是 full_response)。
|
||||
/// - 本次修订全部移除顶层冗余字段,`full_response` 是唯一信源。
|
||||
/// - Anthropic 的 thinking signature(message_delta 中下发,晚于 content_block_stop)由 Provider
|
||||
/// 的流处理循环直接调用 `PartialMessageResponse::set_thinking_signature()` 写入内部状态,
|
||||
/// 再通过 `finalize()` 回填到 Thinking block 中,最终出现在 `full_response` 的 content 里。
|
||||
/// 消费方不需要感知 signature 的存在。
|
||||
MessageComplete {
|
||||
/// 完整的响应快照。
|
||||
///
|
||||
/// 与 `PartialMessageResponse` 内部累积的状态**最终一致**,
|
||||
/// 提供此快照是为了让消费方(如 LlmCycle)在流结束后可以直接拿到
|
||||
/// 完整的 MessageResponse,无需自己实现汇聚算法。
|
||||
full_response: MessageResponse,
|
||||
},
|
||||
|
||||
// ── 错误 ──
|
||||
Error { message: String },
|
||||
}
|
||||
```
|
||||
|
||||
#### 设计理由
|
||||
|
||||
1. **简化消费方**:`LlmCycle::submit_stream()` 目前需要在 `while let` 循环中逐个处理 delta 并维护一个会话状态来判断"响应是否完整"。有了 `full_response`,`LlmCycle` 或 `AgentSession` 只需要监听 `MessageComplete` 事件,拿到快照后直接继续 tool 循环或返回给调用方。
|
||||
2. **与 PartialMessageResponse 保持一致**:`PartialMessageResponse::finalize()` 产生的 `MessageResponse` 就是 `full_response` 的值。Provider 内部的汇聚逻辑不变,只是在发出 `MessageComplete` 时多传一个已完成构建的最终结果。
|
||||
3. **零额外开销**:`MessageResponse` 在 Provider 内部已经构造好了(作为汇聚算法的最终产物),只是多 clone/arc 一次给事件携带。
|
||||
4. **消除冗余**:9c 原有设计同时保留了顶层 `stop_reason`、`thinking_signature` 和 `full_response` 中的相同信息,造成消费方疑惑。本次修订只保留 `full_response` 为唯一信源。
|
||||
|
||||
#### 对 PartialMessageResponse 的影响
|
||||
|
||||
```rust
|
||||
// finalize 在原有逻辑末尾增加一步:
|
||||
// 将 finalize 的结果提前缓存,由 MessageComplete 事件携带
|
||||
impl PartialMessageResponse {
|
||||
pub fn finalize(mut self) -> Result<MessageResponse, LlmError> {
|
||||
// ... 原有代码(按 index 升序遍历 blocks) ...
|
||||
let response = MessageResponse { ... };
|
||||
|
||||
// 新增:self. 中缓存 finalize 结果
|
||||
// (实际由 Provider 的流处理循环在发出 MessageComplete 前调用
|
||||
// finalize 并填充到事件中)
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Provider 的流处理循环 - `thinking_signature` 不再经过事件层,由 Provider 直接写入 `PartialMessageResponse` 内部状态:
|
||||
|
||||
```rust
|
||||
// 伪代码:Provider 流处理循环(以 Anthropic 为例)
|
||||
let mut partial = PartialMessageResponse::new();
|
||||
|
||||
while let Some(event) = anthropic_stream.next().await {
|
||||
match event {
|
||||
// Anthropic 的 message_delta 携带 thinking.signature
|
||||
// → Provider 直接写入 PartialMessageResponse 内部状态
|
||||
AnthropicEvent::MessageDelta { delta, usage } => {
|
||||
if let Some(thinking) = &delta.thinking {
|
||||
if let Some(sig) = &thinking.signature {
|
||||
partial.set_thinking_signature(sig.clone());
|
||||
}
|
||||
}
|
||||
yield StreamEvent::CostUpdate { usage: map_usage(usage) };
|
||||
}
|
||||
// 其他 Anthropic 事件 → 映射为 StreamEvent 并 apply_to
|
||||
other => {
|
||||
let ir_event = map_to_ir_event(other);
|
||||
ir_event.apply_to(&mut partial);
|
||||
}
|
||||
// message_stop → 调用 finalize 并发出完成事件
|
||||
AnthropicEvent::MessageStop => {
|
||||
let full = partial.finalize()?;
|
||||
yield StreamEvent::MessageComplete { full_response: full };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **变更追溯**:9c 的原有设计中,`MessageComplete` 事件携带顶层 `stop_reason` 和 `thinking_signature` 字段,
|
||||
> 供 `apply_to()` 设置 `PartialMessageResponse` 的内部状态。本次修订移除这些冗余字段后,
|
||||
> `thinking_signature` 改为由 Provider 直接调用 `partial.set_thinking_signature()` 写入内部状态,
|
||||
> `stop_reason` 则在 `finalize()` 中统一定于 `full_response.stop_reason`。
|
||||
|
||||
### 2.4 Decision-04:LlmCycle 简化
|
||||
|
||||
沿用 9e 文档的改造方向,核心变化是内部消息类型从 `Vec<OpenaiChatMessage>` 改为 `Vec<Message>`。
|
||||
|
||||
> **⚠️ 依赖验证**:9e 文档写于结构化层次设计阶段(`Message::System` / `User` / `Assistant` / `Tool`),
|
||||
> 其中的代码片段(如 `build_request()` 中 match System 消息的分支、插入 System prompt 的判断逻辑)
|
||||
> 基于旧 Message 定义。扁平大枚举后——
|
||||
> - `User` 拆出 `UserImage` → match 分支需增加 `UserImage` 的处理
|
||||
> - `Message::Tool` 更名为 `Message::ToolResult` → 所有引用需改名
|
||||
> - 其余 match 分支(`System`、`User`、`Assistant`)的基本逻辑不变
|
||||
>
|
||||
> **实施 Phase 2 时**:从 9e 中摘取实现思路,代码手动编写,不直接复制 9e 中的代码片段。
|
||||
> 修改 9e 文档中过时的代码片段不在本方案范围内,Phase 2 实施时自然淘汰。
|
||||
|
||||
关键变化要点(9e 已有详述):
|
||||
|
||||
| 当前 | 改进后 |
|
||||
|------|--------|
|
||||
| `messages: Vec<OpenaiChatMessage>` | `messages: Vec<Message>` |
|
||||
| `build_request()` 中手动拼接 system prompt | system prompt 通过 `Message::System` 在 messages 中表达,Provider 映射层自行处理差异 |
|
||||
| `submit_stream()` 中自建 delta 聚合逻辑 | 监听 `MessageComplete.full_response`,直接拿到完整响应 |
|
||||
| tool 循环需自行解析 `ChatResponse` 中的 tool_calls | 从 `MessageResponse.message`(Assistant 变体)的 content 中提取 ContentBlock::ToolUse |
|
||||
|
||||
### 2.5 Decision-05:Provider 发现使用 Enum
|
||||
|
||||
不使用动态注册表,保留当前 `ProviderType` enum 模式,但扩展其覆盖范围。
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProviderType {
|
||||
OpenaiChat,
|
||||
OpenaiResponse,
|
||||
Anthropic,
|
||||
DeepSeek,
|
||||
Qwen,
|
||||
}
|
||||
```
|
||||
|
||||
工厂函数 `create_provider()` 做 exhaustive match:
|
||||
|
||||
```rust
|
||||
pub fn create_provider(
|
||||
provider_type: ProviderType,
|
||||
config: ProviderConfig,
|
||||
) -> Result<Box<dyn LlmProvider>, LlmError> {
|
||||
match provider_type {
|
||||
ProviderType::OpenaiChat => Ok(Box::new(providers::OpenaiChatProvider::new(...))),
|
||||
ProviderType::OpenaiResponse => Ok(Box::new(providers::OpenaiResponseProvider::new(...))),
|
||||
ProviderType::Anthropic => Ok(Box::new(providers::AnthropicProvider::new(...))),
|
||||
ProviderType::DeepSeek => Ok(Box::new(providers::DeepSeekProvider::new(
|
||||
config.base_url,
|
||||
config.api_key,
|
||||
config.model,
|
||||
))),
|
||||
ProviderType::Qwen => Ok(Box::new(providers::QwenProvider::new(...))),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
新增 Provider 时,编译器通过 exhaustiveness check 强制要求 `match` 更新。
|
||||
|
||||
> **理由**:当前目标协议数量(4-5 种)完全可控,enum 的编译期安全检查优于运行时的 `HashMap::get()`。
|
||||
> 未来如果扩展到 15+ 种以上,再改为注册表模式。
|
||||
|
||||
---
|
||||
|
||||
## 3. 对 9 系文档的更新映射
|
||||
|
||||
| 9 系文档 | 变更类型 | 操作 |
|
||||
|---------|---------|------|
|
||||
| `9b-ir-type-system.md` §3.2 Message | 修订 | `UserImage` 变体新增;其余部分继续有效 |
|
||||
| `9c-llm-provider-trait.md` §4.1 LlmProvider trait | 切换时点修订 | trait 签名切换由"推迟到 Phase 2"改为 Phase 0 内完成。trait 定义本身不变。 |
|
||||
| `9c-llm-provider-trait.md` §4.3 StreamEvent | 修订 | `MessageComplete` 增加 `full_response: MessageResponse` 字段 |
|
||||
| `9c-llm-provider-trait.md` §4.4 PartialMessageResponse | 追加 | `finalize()` 返回结果需在 Provider 发出 `MessageComplete` 前已可用 |
|
||||
| `9d-provider-implementations.md` | 继续有效 | 实现策略不变 |
|
||||
| `9e-llm-cycle-and-upstream.md` | 需重新审查 | 方向不变,但其中的 match 分支和 System prompt 插入逻辑基于旧 Message 定义。Phase 2 实施时参考思路而非照搬代码(见 §2.4 ⚠️ 依赖验证) |
|
||||
| `9f-edge-cases.md` | 继续有效 | 边界情况处理不变 |
|
||||
| `9g-risk-and-migration.md` | 继续有效 | 风险评估不变 |
|
||||
| 本文档 `10-...` | **新增** | 记录最终决策和修订 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 实施步骤
|
||||
|
||||
### Phase 0:类型层落地 + trait 签名切换
|
||||
|
||||
**目标**:新增新的类型系统 + 切换 `LlmProvider` trait 签名,使全链路使用新类型。Phase 0 结束时 `cargo test` 全部通过。
|
||||
|
||||
**原则**:
|
||||
- 新类型定义放入**新文件**(`message.rs`、`request_v2.rs`、`response_v2.rs`),不堆积到已有类型文件
|
||||
- 已有的 `request.rs`(`OpenaiChatRequest`)、`response.rs`(`OpenaiChatResponse`)**保留原样**,后续 Provider 实现可能作为内部转换目标继续引用
|
||||
- `LlmProvider` trait 签名由 `chat(ChatRequest) → ChatResponse` 切换为 `chat(MessageRequest) → MessageResponse`,**在同一个 Phase 内完成**(见下方任务 6‒8)
|
||||
- trait 签名变更导致的编译错误(`StubProvider`、`LlmCycle` 调用点)**在 Phase 0 内全部修复**,不留到 Phase 1
|
||||
- `AgentSession` 等上游中对 `LlmCycle.submit()` 返回值的引用同步适配
|
||||
|
||||
**`StreamEvent` 命名冲突处理**:
|
||||
新 `StreamEvent`(高精度版)定义在 `src/llm/types/response_v2.rs` 中。
|
||||
旧 `StreamEvent`(`src/llm/stream.rs` 中定义)的变体(`AssistantTextDelta`、`ToolExecutionStarted`、`TurnComplete` 等)与新类型冲突。
|
||||
处理方式(任务 9 执行):
|
||||
|
||||
1. `response_v2.rs` 中的新 `StreamEvent` 是唯一的 `StreamEvent` 定义
|
||||
2. `src/llm/stream.rs` 中的旧 `StreamEvent` 枚举**替换为**重新导出语句:`pub use super::types::response_v2::StreamEvent;`
|
||||
3. 旧 `StreamEvent` 的变体(`AssistantTextDelta`、`ToolExecutionStarted`、`TurnComplete`)**暂时保留**为一个独立的枚举(命名为 `LegacyStreamEvent`)放在 `src/llm/types/old_stream.rs` 新文件中,供 `stream.rs` 中的 `parse_chunk_stream()` 内部使用
|
||||
4. 这样 `stream.rs` 的辅助函数继续编译,`LlmCycle` 和 `AgentSession` 看到的是新 `StreamEvent`
|
||||
|
||||
**涉及文件**:
|
||||
|
||||
| 类型 | 文件 | 操作 |
|
||||
|------|------|------|
|
||||
| 新增 | `src/llm/types/message.rs` | 新文件 |
|
||||
| 新增 | `src/llm/types/request_v2.rs` | 新文件 |
|
||||
| 新增 | `src/llm/types/response_v2.rs` | 新文件 |
|
||||
| 新增 | `src/llm/types/old_stream.rs` | 新文件(从 `stream.rs` 迁移旧 `StreamEvent` 变体) |
|
||||
| 追加 | `src/llm/types/mod.rs` | 追加 `pub mod` 声明 |
|
||||
| 修改 | `src/llm/provider.rs` | 改 `LlmProvider` trait 签名 |
|
||||
| 修改 | `src/agent/builder.rs` | 更新 `StubProvider` 实现 |
|
||||
| 修改 | `src/llm/stream.rs` | 将旧 `StreamEvent` 枚举替换为对 `response_v2::StreamEvent` 的重新导出 |
|
||||
| 修改 | `src/llm/provider/openai.rs` | 修改:添加临时桥接实现(`MessageRequest → ChatRequest` 转换 + `ChatResponse → MessageResponse` 转换),Phase 1 重写时移除 |
|
||||
| 修改 | `src/llm/hooks.rs` | 更新 `HookContext.request` 类型为 `&'a MessageRequest` |
|
||||
| 修改 | `src/llm/cycle.rs` | 更新调用点(`build_request`、`submit`、`submit_stream`、`submit_messages`、`submit_request` 的类型引用和返回值) |
|
||||
| 修改 | `src/llm/cycle/retry.rs` | 如有对新 `LlmError` 类型的引用,同步适配 |
|
||||
| 修改 | `src/agent/error.rs`、`src/agent/runtime.rs`、`src/agent/session.rs` 等 | 如有对 `LlmCycle` 返回值或 `ChatResponse` 的引用,同步适配(具体文件由编译错误定位) |
|
||||
|
||||
**具体任务**:
|
||||
1. 新增 `src/llm/types/message.rs`,定义 `Message` 扁平大枚举 + `ContentBlock` + `ContentBlockType`
|
||||
2. 将 9b 中的 `ContentBlock` 变体(`Text`, `Image`, `Audio`, `File`, `ToolUse`, `ToolResult`, `Thinking`, `Extension`)及其辅助类型(`ImageSource`、`AudioSource`、`FileSource`)定义到 `message.rs` 中
|
||||
3. 新增 `src/llm/types/request_v2.rs`,定义 `MessageRequest`(从 9b 移植)+ `ExtraError` + extra 访问方法(`get_extra`、`get_extra_opt`、`get_extra_as`、`set_extra`)
|
||||
4. 新增 `src/llm/types/response_v2.rs`,定义 `MessageResponse` + `StreamEvent`(高精度版,`MessageComplete` 只含 `full_response: MessageResponse`)+ `PartialUsage` + `PartialMessageResponse` + `apply_to` + `finalize`
|
||||
5. 新类型侧单元测试:构造、序列化/反序列化(JSON roundtrip)、match 穷举性验证、`PartialMessageResponse.apply_to + finalize` 汇聚一致性测试
|
||||
6. 修改 `src/llm/provider.rs`:`LlmProvider` trait 签名改为 `chat(MessageRequest) → Result<MessageResponse, LlmError>`、`chat_stream(MessageRequest) → Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>`
|
||||
7. 修改 `src/agent/builder.rs`:更新 `StubProvider` 实现以匹配新 trait 签名
|
||||
8. 修改 `src/llm/cycle.rs`:
|
||||
- `build_request()`:将已有的 `Vec<OpenaiChatMessage>` 转换为 `Vec<Message>`(通过 `chat_message → message` 映射函数),构造 `MessageRequest`
|
||||
- `submit()` / `submit_messages()`:返回 `Result<MessageResponse, LlmError>`
|
||||
- `submit_stream()`:返回 `Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>, LlmError>`
|
||||
- 流处理循环:由消费 `OpenaiChatChunk` 改为消费 `StreamEvent`。流结束处的 `full_response` 暂不使用(Phase 2 才启用简化逻辑),先提取 `stop_reason` 和 `message` 构建传统返回
|
||||
9. 新增文件 `src/llm/types/old_stream.rs`(从 `src/llm/stream.rs` 迁移旧 `StreamEvent` 定义),同时将 `src/llm/stream.rs` 中的旧 `StreamEvent` 枚举替换为对 `response_v2.rs` 中新 `StreamEvent` 的重新导出(`pub use super::types::response_v2::StreamEvent;`),确保 `stream.rs` 的 `parse_chunk_stream()` 和 `ChunkToEventStream` 继续编译通过
|
||||
10. 修改 `src/llm/provider/openai.rs`:添加 `LlmProvider` trait 临时桥接实现——
|
||||
- `chat()`:`MessageRequest → ChatRequest`(利用现有 `OpenaiChatMessage` 转换)→ 调用已有 `chat_inner()` → `ChatResponse → MessageResponse`(使用 `finalize()` 算法或直接映射)
|
||||
- `chat_stream()`:`MessageRequest → ChatRequest` → 调用已有 `chat_stream_inner()` → 将 `OpenaiChatChunk` 流映射为 `StreamEvent` 流(利用已有的 `parse_chunk_stream`)
|
||||
- 桥接实现标记 `// ponytail: Phase 0 临时桥接,Phase 1 重写时移除`
|
||||
11. 测试适配(编译驱动,涉及文件不限于以下列表,由编译器报错定位):
|
||||
- `src/llm/cycle.rs` 测试模块(`MockProvider`、`assistant_text_response()`、`assistant_tool_call_response()`、各测试用例中的断言类型)
|
||||
- `src/agent/session.rs` 测试模块(`MockProvider`、响应构造 helper 等)
|
||||
- `src/agent/builder.rs` 测试模块(`StubProvider` 已单独由任务 7 处理)
|
||||
- `src/agent/session_memory.rs`、`src/agent/runtime.rs` 等
|
||||
12. 编译驱动适配:对上游(`agent/session.rs`、`agent/runtime.rs`、`agent/error.rs` 等)中引用旧类型的地方,逐一按编译错误修复
|
||||
|
||||
**验证**:`cargo test` 全部通过。`git diff` 确认新增和修改文件范围符合预期。确认 `OpenaiProvider` 的临时桥接代码带有 `// ponytail: Phase 0 临时桥接` 注释,Phase 1 移除时易于定位。
|
||||
|
||||
### Phase 1:Provider 适配
|
||||
|
||||
> **前置条件**:Phase 0 已完成,`LlmProvider` trait 签名已切换为 `chat(MessageRequest) → MessageResponse`。本 Phase 直接实现新 Provider,无需再处理 trait 兼容性。
|
||||
|
||||
**目标**:重写 `OpenaiProvider`(使用新类型),新增 `AnthropicProvider`。DeepSeek/Qwen 作为 OpenAI-compatible 协议实现一并纳入。
|
||||
|
||||
**涉及文件**:
|
||||
- `src/llm/provider.rs` — 修改 `create_provider` 工厂函数,匹配新的 `ProviderType` enum
|
||||
- `src/llm/provider/registry.rs` — 适配新 `LlmProvider` trait(改动极小,只是类型变化)
|
||||
- `src/llm/provider/openai.rs` — 重写:内部实现 `MessageRequest ↔ OpenaiChatRequest` 转换
|
||||
- `src/llm/provider/anthropic.rs` — 新文件:`MessageRequest ↔ Anthropic Messages API` 映射
|
||||
- `src/llm/provider/deepseek.rs` — 新文件(与 `OpenaiChatProvider` 共享 `/chat/completions` 协议)
|
||||
- `src/llm/provider/qwen.rs` — 新文件(同上)
|
||||
|
||||
**具体任务**:
|
||||
1. `OpenaiProvider` 内部 `chat()`:`MessageRequest` → `OpenaiChatRequest`(serde 序列化)→ HTTP POST → 解析 `OpenaiChatResponse` → `MessageResponse`(通过 `finalize()` 算法或直接映射)
|
||||
2. `OpenaiProvider` 内部 `chat_stream()`:同样的转换路径,但响应解析改为 SSE 流式 → 逐 chunk 输出 `StreamEvent`
|
||||
3. `AnthropicProvider`:实现 Anthropic Messages API 的请求/响应映射,包括:
|
||||
- Messages API 请求体构建(`system` 参数 + `messages[]` + `tools` 等)
|
||||
- SSE 流解析(`message_start`, `content_block_start`, `content_block_delta`, `content_block_stop`, `message_delta`, `message_stop`, `ping`)
|
||||
- 将 Anthropic SSE 事件映射为 IR `StreamEvent`
|
||||
4. `DeepSeekProvider` / `QwenProvider`(OpenAI-compatible):
|
||||
- 共享 `OpenaiChatProvider` 的 `/chat/completions` 协议
|
||||
- **代码复用策略实施时决定**(推荐:`OpenaiChatProvider` 参数化为 `GenericOpenaiProvider { base_url, api_key, model, provider_name }`,DeepSeek/Qwen 共用同一实现,仅配置不同;备选:trait 组合提取 HTTP 请求逻辑为可复用组件)
|
||||
- 差异化处理:`max_tokens` 字段名(部分兼容端点使用 `max_tokens` 而非 `max_completion_tokens`)、错误格式(非标准 error body 解析)
|
||||
5. `ProviderRegistry` 的 `register_with_config()` 和 `create_provider()` 适配新 enum
|
||||
6. **OpenAI Response API(`ProviderType::OpenaiResponse`)实现范围说明**:本 Phase 的 `OpenaiResponseProvider` 只覆盖核心对话能力(models response 创建、流式)、工具调用。内置工具(`web_search`、`file_search`)、`previous_response_id` 续写、`store` 等 Response API 独有特性通过 `MessageRequest.extra` 传递(参考 9b 的 extra key 约定表),内置工具的完整支持延后。如果资源有限,`OpenaiResponseProvider` 可延迟到 Phase 2 之后开发,不影响其他 Provider。
|
||||
|
||||
**验证**:
|
||||
- 每个 Provider 的 `chat()` 和 `chat_stream()` 基本路径集成测试(mock HTTP 层)
|
||||
- 消息类型双向映射测试(`Message → OpenaiChatRequest`, `OpenaiChatResponse → MessageResponse`)
|
||||
- 错误路径测试(HTTP 400/401/429/500 → `LlmError` 映射)
|
||||
|
||||
**HTTP mock 策略**:
|
||||
- 推荐使用 [`wiremock`](https://crates.io/crates/wiremock) crate(项目尚无 HTTP mock 依赖)
|
||||
- 每个 Provider 的测试模块中,用 `MockServer` 启动 mock 服务端,返回预定义请求/流式响应
|
||||
- `OpenaiProvider` 的 mock 端点为 `/chat/completions`(SSE 流或 JSON 响应)
|
||||
- `AnthropicProvider` 的 mock 端点为 `/v1/messages`(SSE 事件序列)
|
||||
- 测试不依赖真实网络,`base_url` 指向 `mock_server.uri()`
|
||||
|
||||
### Phase 2:LlmCycle 简化(逻辑重构)
|
||||
|
||||
> **说明**:Phase 0 已完成 `LlmCycle` 的"类型迁移"(trait 签名、`build_request` 转换层、返回值类型)。Phase 2 聚焦**逻辑简化**——去掉 Phase 0 遗留的临时转换层,利用新类型的表达能力重写 LlmCycle 核心逻辑。
|
||||
|
||||
**目标**:
|
||||
- 将 `LlmCycle` 内部消息存储从 `Vec<OpenaiChatMessage>` 切换为 `Vec<Message>`,**移除 Phase 0 引入的 `OpenaiChatMessage → Message` 转换层**
|
||||
- 流处理循环重构:利用 `MessageComplete.full_response` 直接拿到完整响应,去掉手动 delta 累积
|
||||
- 工具循环清洗:从 `MessageResponse.message` 的 content 中直接提取 `ContentBlock::ToolUse`
|
||||
- `compact.rs` 适配新 `Message` 类型
|
||||
|
||||
**涉及文件**:
|
||||
- `src/llm/cycle.rs` — 主要修改
|
||||
- `src/llm/cycle/usage.rs` — 保持兼容(`Usage` 类型不变)
|
||||
- `src/llm/cycle/retry.rs` — 保持兼容
|
||||
- `src/llm/compact.rs` — 适配 `Message` 类型
|
||||
|
||||
**具体任务**:
|
||||
1. `self.messages` 从 `Vec<OpenaiChatMessage>` 改为 `Vec<Message>`,移除 `build_request()` 中的类型转换步骤
|
||||
2. `build_request()` 直接构建 `MessageRequest`(`messages` 直接传入 `self.messages`),不再手动插入 system prompt(从 messages 中取 `Message::System`)
|
||||
3. `submit()` / `submit_messages()`:已返回 `MessageResponse`,无需改签名。检查调用方是否直接解构 `MessageResponse` 是正确的
|
||||
4. `submit_stream()`:流处理循环中锚定 `MessageComplete.full_response`,拿到完整的 `MessageResponse` 后直接继续 tool 循环或结束。去掉中间状态的维护
|
||||
5. tool 循环:从 `MessageResponse.message` 的 `Assistant { content }` 中提取 `ContentBlock::ToolUse` 变体
|
||||
6. `compact.rs` 适配:`microcompact()` / `should_compact()` 的操作对象从 `OpenaiChatMessage` 改为 `Message`,按 text block 长度计算 token 数
|
||||
7. 清理 Phase 0 引入的临时转换函数(`chat_message_to_message`、`message_to_chat_message` 等),确认不再被引用后删除
|
||||
|
||||
**验证**:
|
||||
- `LlmCycle` 集成测试全部通过
|
||||
- 多轮对话 + 工具调用的端到端流程正常
|
||||
- `git diff` 确认 Phase 0 引入的临时转换函数已被删除
|
||||
|
||||
---
|
||||
|
||||
## 5. 验证标准
|
||||
|
||||
| 维度 | 验证方法 | 通过条件 |
|
||||
|------|---------|---------|
|
||||
| 类型正确性 | `cargo test` | 所有测试通过 |
|
||||
| JSON 双向映射 | 单元测试 | `Message → JSON → Message` 往返不变 |
|
||||
| Provider 基本路径 | 集成测试(mock HTTP) | 每个 Provider 的 chat + chat_stream 成功 |
|
||||
| Provider 错误路径 | 集成测试(mock HTTP 4xx/5xx) | 错误映射为正确的 `LlmError` 变体 |
|
||||
| StreamEvent 完整快照 | 集成测试 | `MessageComplete.full_response` 与 PartialMessageResponse 聚合结果一致 |
|
||||
| LlmCycle 多轮对话 | 集成测试(mock Provider) | 多轮对话 + 工具循环正常 |
|
||||
| compact | 集成测试 | 超过 token 阈值后消息被正确压缩 |
|
||||
| 向后兼容(已有代码) | 编译检查 | Phase 0 修改 `LlmProvider` trait + `LlmCycle` 调用点 + `StubProvider` 后,`cargo test` 全部通过。`git diff` 只涉及预期变更的文件,无意外修改 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 回滚方案
|
||||
|
||||
由于项目尚无外部消费者,回滚策略比较简单。每个 Phase 结束时打 tag 作为 checkpoint,允许跳跃回退。
|
||||
|
||||
| 阶段 | 触发条件 | 操作 |
|
||||
|------|---------|------|
|
||||
| Phase 0(类型层) | 新类型设计发现重大缺陷 | 回退 git,保留 9 系文档作为参照,重启设计评审 |
|
||||
| Phase 0 完成时 | 类型定义通过评审和测试 | 打 tag `types-v2-prototype` |
|
||||
| Phase 1(Provider 适配) | 某个 Provider 实现不合理 | 将该 Provider 回退为 `unimplemented!()`(当前状态),不影响其他 Provider |
|
||||
| Phase 1 完成时 | Provider 测试全部通过 | 打 tag `providers-v2-prototype` |
|
||||
| Phase 2(LlmCycle 简化) | 循环逻辑或 compact 出现问题 | 保留旧 `LlmCycle` 实现(不改文件名),通过 feature flag 切换 |
|
||||
| **跨阶段回退** | Phase 2 发现 Phase 0 类型设计有误 | 回退至 Phase 0 checkpoint(`types-v2-prototype`),在不动已有文件的前提下直接原地修改新类型文件重新迭代,不需要整个回退到 Phase 0 之前 |
|
||||
|
||||
**风险储备**:
|
||||
- 如果 `OpenaiProvider` 的重写复杂度过高,可以保留旧的 `OpenaiProvider` 不变,在旁边新增一个 `OpenaiProviderV2` 并行开发
|
||||
- `ChatRequest` / `ChatResponse` / `Message` / `ContentBlock` / `ToolDefinition` / `StopReason` 等类型别名和旧类型结构体的弃用路径:
|
||||
- **Phase 0 完成时**:旧别名**保留**(作为编译桥接),新类型通过不同路径(`request_v2::MessageRequest`、`response_v2::MessageResponse`)访问,两者同时存在于类型模块中
|
||||
- **Phase 1 完成时**:Provider 实现切换到新类型,旧 `OpenaiProvider` 的临时桥接代码被 Phase 1 的真实实现替换。旧别名仍由 `src/llm/types/mod.rs` 导出,不影响其他模块
|
||||
- **Phase 2 完成时**:`LlmCycle` 内部消息存储从 `Vec<OpenaiChatMessage>` 切换到 `Vec<Message>`,所有 `ChatRequest`/`ChatResponse` 引用被替换。此时对 `ChatRequest`、`ChatResponse`、`Message`、`ContentBlock`、`ToolDefinition`、`StopReason` 等旧别名和 `ChatResponse` 结构体加 `#[deprecated]` 标记
|
||||
- **下一个版本(v0.2.0 或 v1.0.0)**:运行 `cargo check` 确认无外部引用后,删除所有 deprecated 别名和 `ChatResponse` 结构体
|
||||
|
||||
---
|
||||
|
||||
## 7. 开放事项
|
||||
|
||||
以下事项已在 9 系文档中充分讨论,本次无修订,但列出以供跟踪:
|
||||
|
||||
- [ ] `ContentBlock::Extension` 作为逃生舱的具体使用场景(OpenAI Response 内置工具、未知 block 类型)
|
||||
- [ ] Anthropic 的 `/v1/messages` 流式 SSE 解析状态机细节(9d Provider 实现文档)
|
||||
- [ ] `MessageRequest.extra` 中每个 Provider 实际需要的 key 清单(9b 已有草案,Phase 1 实现时细化和验证)
|
||||
- [ ] Thinking signature 的端到端测试(9f 已有处理策略,Phase 2 时分配合并完成)
|
||||
- [ ] cost 计算逻辑适配新类型(当前 `CostTracker` 在 `Usage` 上工作,类型不变、无需修改,但在集成测试中验证)
|
||||
- [ ] `Message::ToolResult` 命名 — 9b 中叫 `Tool`(对应 OpenAI 的 `tool` role),本设计改为 `ToolResult`。Anthropic 没有独立的 `tool` role(tool_result 是 content block),实施时需验证此命名与所有 Provider 映射的一致性
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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` 测试计数和状态与代码一致
|
||||
@@ -0,0 +1,434 @@
|
||||
# 示例程序新增方案
|
||||
|
||||
> 作者:Proposal Agent
|
||||
> 日期:2026-06-11
|
||||
> 对应版本:agcore v0.1
|
||||
|
||||
## 背景与目标
|
||||
|
||||
### 问题
|
||||
|
||||
当前 `examples/` 目录下只有一个 `simple_visit.rs`,仅演示了 `LlmCycle::submit()` 的基本 LLM 调用(Phase 0),且依赖真实 API key 才能运行。v0.1 已实现的全部 7 个 Phase 的能力(Phase 0~4c)缺乏可运行、可独立验证的示例展示。
|
||||
|
||||
### 目标
|
||||
|
||||
1. **覆盖全 Phase** — 每个 Phase 核心能力至少有一个示例
|
||||
2. **可离线运行** — 优先选用 MockProvider 和本地逻辑,不强制依赖 API key
|
||||
3. **真实使用模式** — 示例反映库的预期使用方式(Builder 模式、trait 实现、? 错误传播)
|
||||
4. **验收辅助** — 示例跑通 = 对应模块公共 API 可用且装配正确
|
||||
|
||||
### 非目标
|
||||
|
||||
- 不替代单元测试的边界覆盖(内联测试仍负责边界条件)
|
||||
- 不引入第三方依赖(示例只使用 `agcore` 公开 API)
|
||||
- 不追求 UI 或交互式输入
|
||||
|
||||
---
|
||||
|
||||
## 当前状态分析
|
||||
|
||||
```text
|
||||
examples/
|
||||
└── simple_visit.rs # 仅 Phase 0 基础调用,需 API key
|
||||
```
|
||||
|
||||
### 现有示例覆盖缺口
|
||||
|
||||
| Phase | 模块 | 示例覆盖 | 缺口 |
|
||||
|-------|------|---------|------|
|
||||
| Phase 0 | LLM 调用周期 | `simple_visit.rs` | 流式事件、重试逻辑、Auto-compaction 未演示 |
|
||||
| Phase 1 | 提示词工程 | ❌ | 模板变量插值、消息组合、条件渲染 |
|
||||
| Phase 2 | 工具系统 | ❌ | 自定义工具注册、并行调用、权限检查 |
|
||||
| Phase 3 | 记忆系统 | ❌ | 对话记忆滑动窗口、知识页面存储、关键词检索 |
|
||||
| Phase 4a | 核心胶水层 | ❌ | Agent/AgentSession/RuntimeBundle/AgentBuilder 装配 |
|
||||
| Phase 4b | 任务执行 | ❌ | PlanParser/Step 状态机/TaskAgent |
|
||||
| Phase 4c | 会话级记忆 | ❌ | SessionMemory set/get/snapshot |
|
||||
|
||||
---
|
||||
|
||||
## 设计方案
|
||||
|
||||
### 总体架构
|
||||
|
||||
新增示例按三层优先级组织,每个示例为一个独立 `.rs` 文件,统一放在 `examples/` 目录下。
|
||||
|
||||
```
|
||||
examples/
|
||||
├── simple_visit.rs # [已有] 基本 LLM 调用(Phase 0)
|
||||
├── prompt_composer.rs # [新增] 提示词组合(Phase 1)🥇
|
||||
├── custom_tool.rs # [新增] 自定义工具(Phase 2)🥇
|
||||
├── agent_session_demo.rs # [新增] Agent 会话(Phase 4a+4c)🥇
|
||||
├── task_agent_demo.rs # [新增] 任务规划(Phase 4b)🥇
|
||||
├── conversation_memory_demo.rs # [新增] 对话记忆(Phase 3)🥈
|
||||
├── knowledge_search_demo.rs # [新增] 知识检索(Phase 3)🥈
|
||||
├── streaming_events_demo.rs # [新增] 流式事件(Phase 0)🥈
|
||||
└── full_integration.rs # [新增] 全栈集成(Phase 全栈)🥉
|
||||
```
|
||||
|
||||
### 详细设计
|
||||
|
||||
#### 🥇 示例:`prompt_composer.rs`(Phase 1)
|
||||
|
||||
**设计思路**:纯本地运行,不依赖任何外部服务。通过构造模板、组合消息来验证 Prompt Engineering 模块的公共 API。
|
||||
|
||||
**流程**:
|
||||
```
|
||||
TemplateContext 构造 → PromptTemplate 填充变量 → PromptComposer 构建消息链 → 断言验证
|
||||
```
|
||||
|
||||
**关键代码片段示意**:
|
||||
```rust
|
||||
// 1. 构造模板
|
||||
let mut registry = PromptTemplateRegistry::new();
|
||||
registry.register(PromptTemplate::new("weather", "今日{location}天气:{condition},温度{temperature}"));
|
||||
|
||||
// 2. 填充变量
|
||||
let template = registry.get("weather").unwrap();
|
||||
let rendered = template.render(&TemplateContext::from([
|
||||
("location", "北京"),
|
||||
("condition", "晴"),
|
||||
("temperature", "25°C"),
|
||||
])?;
|
||||
|
||||
// 3. 组合消息
|
||||
let composer = PromptComposer::new()
|
||||
.system("你是一个天气助手")
|
||||
.user(rendered)
|
||||
.assistant(/* 可选历史 */);
|
||||
|
||||
let messages = composer.compose();
|
||||
assert_eq!(messages.len(), 2);
|
||||
```
|
||||
|
||||
**验证点**:
|
||||
- `TemplateContext` 变量插值正确
|
||||
- `PromptComposer` 消息顺序正确
|
||||
- `PromptError` 在缺失变量时正确返回
|
||||
|
||||
**新增代码量**:约 60 行
|
||||
|
||||
---
|
||||
|
||||
#### 🥇 示例:`custom_tool.rs`(Phase 2)
|
||||
|
||||
**设计思路**:实现一个模拟工具(如 `WeatherTool`),注册到 `ToolRegistry`,演示单次调用、并行调用、权限检查。
|
||||
|
||||
**流程**:
|
||||
```
|
||||
实现 BaseTool → 注册到 ToolRegistry → invoke 单次 → invoke_all 并行 → PermissionChecker 白名单过滤
|
||||
```
|
||||
|
||||
**关键代码片段示意**:
|
||||
```rust
|
||||
// 1. 实现工具
|
||||
struct WeatherTool;
|
||||
#[async_trait]
|
||||
impl BaseTool for WeatherTool {
|
||||
fn name(&self) -> &str { "get_weather" }
|
||||
fn parameters(&self) -> Value { json!({"type":"object","properties":{"city":{"type":"string"}}}) }
|
||||
async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<Value, ToolError> {
|
||||
Ok(json!({"city": args["city"], "temperature": 22, "condition": "晴"}))
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 注册 + 调用
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(WeatherTool.into())?;
|
||||
let result = registry.invoke("get_weather", json!({"city": "北京"})).await?;
|
||||
|
||||
// 3. 并行调用
|
||||
let results = registry.invoke_all(vec![...], 30).await;
|
||||
|
||||
// 4. 权限检查
|
||||
let checker = PermissionChecker::new(PermissionConfig::white_list(vec!["get_weather"]));
|
||||
assert!(checker.check("get_weather").is_ok());
|
||||
assert!(checker.check("delete_file").is_err());
|
||||
```
|
||||
|
||||
**验证点**:
|
||||
- 工具注册/查找/调用完整链路
|
||||
- 并行调用结果数正确
|
||||
- 权限白名单/黑名单行为
|
||||
- `ToolError::NotFound` 未注册工具
|
||||
|
||||
**新增代码量**:约 80 行
|
||||
|
||||
---
|
||||
|
||||
#### 🥇 示例:`agent_session_demo.rs`(Phase 4a + 4c)
|
||||
|
||||
**设计思路**:使用 `MockProvider` 模拟 LLM 响应,完整演示 `Agent → AgentBuilder → RuntimeBundle → AgentSession` 的装配流程及 `SessionMemory` 的读写。
|
||||
|
||||
**流程**:
|
||||
```
|
||||
实现 Agent → AgentBuilder 构造 RuntimeBundle → AgentSession::new → submit_turn → session_data 读写 → snapshot 输出
|
||||
```
|
||||
|
||||
**关键代码片段示意**:
|
||||
```rust
|
||||
// 1. 定义 Agent
|
||||
struct CalculatorAgent;
|
||||
impl Agent for CalculatorAgent {
|
||||
fn name(&self) -> &str { "calculator" }
|
||||
fn system_prompt(&self) -> Option<&str> { Some("你是计算器助手") }
|
||||
}
|
||||
|
||||
// 2. 装配 RuntimeBundle
|
||||
let bundle = AgentBuilder::new()
|
||||
.provider(Arc::new(mock_provider))
|
||||
.tool_registry(Arc::new(tool_registry))
|
||||
.hook_executor(Arc::new(hook_executor))
|
||||
.build()?;
|
||||
|
||||
// 3. 创建会话
|
||||
let mut session = AgentSession::new(Arc::new(CalculatorAgent), "session-1", Arc::new(bundle));
|
||||
|
||||
// 4. 提交对话
|
||||
let response = session.submit_turn("1+1=?").await?;
|
||||
|
||||
// 5. SessionMemory 读写
|
||||
session.set_session_data("last_result", "2").await?;
|
||||
let result = session.get_session_data("last_result").await?;
|
||||
println!("{}", session.session_memory().snapshot().await?);
|
||||
```
|
||||
|
||||
**验证点**:
|
||||
- `AgentBuilder::build()` 必填字段校验
|
||||
- `submit_turn` 流程完整(hook 触发、cost 累计、turn_index 递增)
|
||||
- `SessionMemory` set/get/snapshot 正确
|
||||
- 多个 session 间数据隔离
|
||||
|
||||
**新增代码量**:约 100 行
|
||||
|
||||
---
|
||||
|
||||
#### 🥇 示例:`task_agent_demo.rs`(Phase 4b)
|
||||
|
||||
**设计思路**:使用 `JsonPlanParser` 从预定义 JSON 解析 Plan,驱动 Step 状态机转换,观察状态单向流转。
|
||||
|
||||
**流程**:
|
||||
```
|
||||
构造 JSON 输入 → JsonPlanParser::parse → Plan 数据结构 → 模拟 execute_plan → Step 状态变迁 → Hook 事件
|
||||
```
|
||||
|
||||
**关键代码片段示意**:
|
||||
```rust
|
||||
// 1. 解析 Plan
|
||||
let parser = JsonPlanParser;
|
||||
let input = r#"{"steps": [{"description": "查天气"}, {"description": "算结果"}]}"#;
|
||||
let mut plan = parser.parse(input, "完成今日任务").await?;
|
||||
|
||||
// 2. 模拟 step 执行
|
||||
assert!(plan.steps[0].status.is_pending());
|
||||
step.status = StepStatus::Running;
|
||||
step.status = StepStatus::Completed(response);
|
||||
assert!(step.status.is_terminal());
|
||||
|
||||
// 3. 失败路径
|
||||
step.status = StepStatus::Failed(AgentError::Other("API 不可用".into()));
|
||||
assert!(step.status.is_terminal());
|
||||
```
|
||||
|
||||
**验证点**:
|
||||
- 合法 JSON 解析正确
|
||||
- 非法 JSON / 空步骤 / 缺字段返回 `AgentError::PlanParse`
|
||||
- 状态机单向转换(Pending → Running → Completed/Failed/Skipped)
|
||||
- `is_terminal()` / `is_pending()` 语义正确
|
||||
|
||||
**新增代码量**:约 70 行
|
||||
|
||||
---
|
||||
|
||||
#### 🥈 示例:`conversation_memory_demo.rs`(Phase 3)
|
||||
|
||||
**设计思路**:演示 `ConversationMemory` 的多轮消息写入、滑动窗口淘汰、冷热分离存储。
|
||||
|
||||
**流程**:
|
||||
```
|
||||
ConversationMemory::new → add_message × N → 触发窗口淘汰 → get_history 验证 → MemoryStore 持久化读取
|
||||
```
|
||||
|
||||
**关键代码片段示意**:
|
||||
```rust
|
||||
let config = ConversationMemoryConfig {
|
||||
strategy: MemoryStrategy::SlidingWindow,
|
||||
max_turns: 5,
|
||||
..Default::default()
|
||||
};
|
||||
let mut memory = ConversationMemory::new(store, "session-1", config);
|
||||
|
||||
// 写入 10 条消息
|
||||
for i in 0..10 {
|
||||
memory.add_message(OpenaiChatMessage::user_text(format!("消息 {i}"))).await?;
|
||||
}
|
||||
|
||||
// 验证窗口大小为 5
|
||||
let history = memory.get_history().await?;
|
||||
assert_eq!(history.len(), 5);
|
||||
assert!(history[0].content().contains("消息 5"));
|
||||
```
|
||||
|
||||
**验证点**:
|
||||
- 滑动窗口淘汰旧消息
|
||||
- Full 策略保留全部消息
|
||||
- 冷存储 `MemoryStore` 写入/读取正确
|
||||
- `CompactConfig` 触发自动压缩
|
||||
|
||||
**新增代码量**:约 70 行
|
||||
|
||||
---
|
||||
|
||||
#### 🥈 示例:`knowledge_search_demo.rs`(Phase 3)
|
||||
|
||||
**设计思路**:演示 `KnowledgeStore` 页面存储 + `MemoryRetriever` 关键词检索与 Dice 系数评分。
|
||||
|
||||
**流程**:
|
||||
```
|
||||
KnowledgeStore 创建页面 → MemoryRetriever::search → 评分排序结果输出 → 阈值过滤观察
|
||||
```
|
||||
|
||||
**关键代码片段示意**:
|
||||
```rust
|
||||
let store = KnowledgeStore::new(memory_store);
|
||||
store.save_page("Rust 入门", "Rust 是一门系统编程语言...", vec!["rust", "编程"]).await?;
|
||||
store.save_page("Python 简介", "Python 是动态类型语言...", vec!["python", "动态"]).await?;
|
||||
|
||||
let retriever = MemoryRetriever::new(store, RetrieverConfig::default());
|
||||
let result = retriever.search("Rust 语言").await?;
|
||||
|
||||
for item in &result.items {
|
||||
println!(" 页面: {} (评分: {:.2})", item.page.title, item.score);
|
||||
assert!(item.score >= 0.0 && item.score <= 1.0);
|
||||
}
|
||||
```
|
||||
|
||||
**验证点**:
|
||||
- `KnowledgeStore` 页面存/取/搜索正确
|
||||
- `TextOverlap` Dice 系数在 [0.0, 1.0] 范围内
|
||||
- 停用词过滤正常
|
||||
- 低于 `min_score` 的结果被过滤
|
||||
|
||||
**新增代码量**:约 60 行
|
||||
|
||||
---
|
||||
|
||||
#### 🥈 示例:`streaming_events_demo.rs`(Phase 0 — 流式接口)
|
||||
|
||||
**设计思路**:调用 `LlmCycle::submit_stream()` 获取事件流,展示了语义事件的消费模式。可选使用 API key 或 MockProvider。
|
||||
|
||||
**流程**:
|
||||
```
|
||||
LlmCycle::submit_stream → 事件循环 match StreamEvent → 输出类型/内容 → TurnComplete 收尾
|
||||
```
|
||||
|
||||
**关键代码片段示意**:
|
||||
```rust
|
||||
let mut cycle = LlmCycle::new(provider, config);
|
||||
let mut stream = cycle.submit_stream("讲个笑话".into(), vec![]).await?;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
while let Some(event) = stream.next().await {
|
||||
match event {
|
||||
StreamEvent::AssistantTextDelta { text } => print!("{text}"),
|
||||
StreamEvent::TurnComplete { reason } => println!("\n\n完成,原因: {reason:?}"),
|
||||
StreamEvent::Error { message } => eprintln!("错误: {message}"),
|
||||
_ => {} // 其他事件
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**验证点**:
|
||||
- 流式链路完整(请求 → 事件 → 完成)
|
||||
- 事件枚举覆盖所有变体
|
||||
- 错误事件正确处理
|
||||
|
||||
**新增代码量**:约 80 行
|
||||
|
||||
---
|
||||
|
||||
#### 🥉 示例:`full_integration.rs`(Phase 全栈集成)
|
||||
|
||||
**设计思路**:端到端演示,将 v0.1 所有模块装配为一个可运行的智能体。需真实 API key。
|
||||
|
||||
**流程**:
|
||||
```
|
||||
创建 Agent(带 system prompt + 2 个工具)→ 注入 MemoryStore/Retriever → AgentSession → 多轮对话 → 知识检索 → SessionMemory 桥接 → 输出运行摘要
|
||||
```
|
||||
|
||||
**验证点**:
|
||||
- Phase 4 "胶水层"真正将 Phase 0~3 粘合
|
||||
- `submit_turn` 内部调用工具
|
||||
- `ConversationMemory` 回写
|
||||
- 全链路无类型/装配错误
|
||||
|
||||
**新增代码量**:约 150 行
|
||||
|
||||
---
|
||||
|
||||
## 实现计划
|
||||
|
||||
### 阶段一:第一梯队(优先级 🥇)
|
||||
|
||||
| 示例 | 预计代码量 | 可并行实施 |
|
||||
|------|-----------|-----------|
|
||||
| `prompt_composer.rs` | ~60 行 | ✅ 与 2/3 并行 |
|
||||
| `custom_tool.rs` | ~80 行 | ✅ 与 1/3 并行 |
|
||||
| `agent_session_demo.rs` | ~100 行 | ✅ 与 1/2 并行 |
|
||||
| `task_agent_demo.rs` | ~70 行 | ✅ 与 1/2/3 并行 |
|
||||
|
||||
**验证标准**:`cargo run --example <name>` 全部成功退出(code 0)。
|
||||
|
||||
### 阶段二:第二梯队(优先级 🥈)
|
||||
|
||||
| 示例 | 预计代码量 | 前置依赖 |
|
||||
|------|-----------|---------|
|
||||
| `conversation_memory_demo.rs` | ~70 行 | 无 |
|
||||
| `knowledge_search_demo.rs` | ~60 行 | 无 |
|
||||
| `streaming_events_demo.rs` | ~80 行 | 无 |
|
||||
|
||||
### 阶段三:第三梯队(优先级 🥉)
|
||||
|
||||
| 示例 | 预计代码量 | 前置依赖 |
|
||||
|------|-----------|---------|
|
||||
| `full_integration.rs` | ~150 行 | 需 `.env` 配置 API key |
|
||||
|
||||
### 总工作量估算
|
||||
|
||||
| 合计 | 代码行数 | 文件数 |
|
||||
|------|---------|-------|
|
||||
| 第一阶段 | ~310 行 | 4 个 |
|
||||
| 第二阶段 | ~210 行 | 3 个 |
|
||||
| 第三阶段 | ~150 行 | 1 个 |
|
||||
| **总计** | **~670 行** | **8 个文件** |
|
||||
|
||||
---
|
||||
|
||||
## 风险评估
|
||||
|
||||
| 风险 | 影响 | 概率 | 缓解措施 |
|
||||
|------|------|------|---------|
|
||||
| 示例与库 API 不同步(库重构后示例过时) | 高 | 中 | 将示例加入 CI:`cargo test --examples` |
|
||||
| MockProvider 行为与真实 Provider 差异 | 低 | 低 | 示例明确标注离线/在线模式 |
|
||||
| 示例代码量膨胀超过预期 | 低 | 低 | 每个示例控制在 200 行以内,超过则拆分子函数 |
|
||||
| `full_integration.rs` 依赖 API key,CI 会跳过 | 中 | 高 | 用 `#[cfg(not(ci))]` 或 `.env` 存在性判断优雅降级 |
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. **阶段一全部完成时**:
|
||||
- `cargo run --example prompt_composer` → 成功退出
|
||||
- `cargo run --example custom_tool` → 成功退出
|
||||
- `cargo run --example agent_session_demo` → 成功退出
|
||||
- `cargo run --example task_agent_demo` → 成功退出
|
||||
|
||||
2. **阶段二全部完成时**:
|
||||
- 额外 3 个示例均可 `cargo run` 成功
|
||||
|
||||
3. **阶段三完成时**(可选):
|
||||
- `full_integration` 在有 `.env` 配置时成功运行,无配置时友好提示降级
|
||||
|
||||
4. **全局验收**:
|
||||
- `cargo build` 无新增警告
|
||||
- 所有示例输出格式清晰,有说明性 println
|
||||
- 每个示例在文件顶部有 `//!` 注释说明其演示目的
|
||||
@@ -0,0 +1,113 @@
|
||||
# LLM Provider 统一接口设计(方案 C)
|
||||
|
||||
> 本文档已被拆分为独立的子文档以便深入推演和修改。以下保留背景与架构总览作为索引。
|
||||
>
|
||||
> **拆分日期**:2026-06-15
|
||||
> **拆分方式**:原 §1-§2 保留在本文件,§3-§13 移至 `9a`-`9g` 子文档。
|
||||
> 所有"待深入推演"议题保留在对应子文档的原文位置。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 当前状态
|
||||
|
||||
`LlmProvider` trait 的请求/响应类型直接绑定到 OpenAI Chat Completion API 格式:
|
||||
|
||||
```rust
|
||||
pub type ChatRequest = OpenaiChatRequest; // 类型别名
|
||||
pub type ChatResponse = struct { message: OpenaiChatMessage, ... };
|
||||
pub type Message = OpenaiChatMessage;
|
||||
```
|
||||
|
||||
所有 "内部统一类型" 都是 OpenAI 格式的直接映射。这导致:
|
||||
|
||||
| API 类型 | 兼容性 | 代价 |
|
||||
|----------|--------|------|
|
||||
| OpenAI Chat(DeepSeek、Qwen 等) | ✅ 原生兼容 | 零 |
|
||||
| Anthropic Messages | ❌ 语义丢失 | 需逆向映射,丢失 thinking 等特性 |
|
||||
| OpenAI Response API | ❌ 范式不兼容 | ChatResponse 无法表达多类型 output |
|
||||
| 非标自定义 API | ❌ 无扩展点 | 只能走 extra_body 逃生舱 |
|
||||
|
||||
### 1.2 目标
|
||||
|
||||
设计一套**真正与 Provider 无关的内部统一类型(IR)**,使得:
|
||||
|
||||
1. 所有 Provider 对外暴露的接口完全一致(统一 trait)
|
||||
2. 每个 Provider 内部自行完成 IR ↔ 原生格式的映射
|
||||
3. 上层(LlmCycle、AgentSession)完全感知不到具体 Provider
|
||||
4. 新 Provider 只需实现一次双向映射即可接入
|
||||
5. 各 API 的独有特性(thinking、内置工具等)有表达空间
|
||||
|
||||
### 1.3 非目标
|
||||
|
||||
- 不追求覆盖所有 API 的每一个参数(90% 核心流程即可)
|
||||
- 不追求在不改上层代码的情况下切换 Provider(接口一致足以)
|
||||
- 不试图让 OpenAI Response API 的内置工具完全融入消息循环(通过逃生舱 + 可选能力 trait)
|
||||
|
||||
---
|
||||
|
||||
## 2. 架构总览
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ 上 层(AgentSession / TaskAgent) │
|
||||
│ 只与 IR 类型和 LlmProvider trait 交互 │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ LlmCycle │
|
||||
│ 循环 / 重试 / Tool 循环 / Hook / Compact / CostTracker │
|
||||
│ 内部使用 Vec<Message> + MessageRequest │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ LlmProvider trait(核心接口) │
|
||||
│ chat(MessageRequest) → Result<MessageResponse, LlmError> │
|
||||
│ chat_stream(MessageRequest) → Stream<StreamEvent> │
|
||||
│ capabilities() → ProviderCapabilities │
|
||||
├────────────────┬──────────────────────┬──────────────────────┤
|
||||
│ OpenaiProvider │ AnthropicProvider │ DeepSeekProvider ... │
|
||||
│ IR ↔ OpenAI │ IR ↔ Anthropic │ IR ↔ OpenAI 格式 │
|
||||
│ JSON │ JSON │ (兼容 Chat API) │
|
||||
└────────────────┴──────────────────────┴──────────────────────┘
|
||||
```
|
||||
|
||||
### 2.1 分层原则
|
||||
|
||||
- **IR 层**:全项目唯一的内部表示,与任何具体 API 格式无关
|
||||
- **Provider 层**:每个 Provider 实现 IR ↔ 原生格式的双向映射,复杂度隔离在此层
|
||||
- **上层**:只与 IR 和 `LlmProvider` trait 交互,不感知具体 Provider
|
||||
|
||||
---
|
||||
|
||||
## 文件索引
|
||||
|
||||
### 核心设计
|
||||
|
||||
| 文件 | 内容 | 包含待深入推演 |
|
||||
|------|------|---------------|
|
||||
| [9a-background-and-architecture.md](9a-background-and-architecture.md) | §1 背景与目标 + §2 架构总览 | — |
|
||||
| [9b-ir-type-system.md](9b-ir-type-system.md) | §3 IR 类型体系:[ContentBlock](9b-ir-type-system.md#31-contentblock--最小的内容单元)、[Message](9b-ir-type-system.md#32-message--统一消息类型)、[StopReason](9b-ir-type-system.md#33-stopreason--统一停止原因)、[MessageRequest](9b-ir-type-system.md#36-messagerequest--统一请求)、[MessageResponse](9b-ir-type-system.md#37-messageresponse--统一响应) | ToolResult 嵌套约束、extra 类型安全性 |
|
||||
| [9c-llm-provider-trait.md](9c-llm-provider-trait.md) | §4 LlmProvider Trait:[核心接口](9c-llm-provider-trait.md#41-核心接口)、[ProviderCapabilities](9c-llm-provider-trait.md#42-providercapabilities)、[StreamEvent](9c-llm-provider-trait.md#43-流式事件-streamevent)、[汇聚算法](9c-llm-provider-trait.md#44-partialmessageresponse--流式事件的汇聚算法) | — |
|
||||
| [9d-provider-implementations.md](9d-provider-implementations.md) | §5 Provider 实现:[OpenAI](9d-provider-implementations.md#51-openai-provider兼容-chat-api)、[Anthropic](9d-provider-implementations.md#52-anthropicprovidermessages-api)、[Response API](9d-provider-implementations.md#53-openai-response-api草案)、[DeepSeek/Qwen](9d-provider-implementations.md#54-deepseek--qwen-等兼容-provider-的落地策略) | OpenAI 流式转换、Anthropic 流式状态机、Response API 映射、DeepSeek/Qwen 落地 |
|
||||
| [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md) | §6-§8:LlmCycle 改造(build_request、tool 循环、submit_stream、compact)+ 上层影响 + 兼容策略 | system prompt 双重表达冲突、compact 适配 |
|
||||
| [9f-edge-cases.md](9f-edge-cases.md) | §9 边界情况:工具定义传递、Thinking 端到端、Multiple ContentBlock、多 Choice、内置工具 | — |
|
||||
|
||||
### 辅助参考
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| [9g-risk-and-migration.md](9g-risk-and-migration.md) | §10 风险评估 + §11 类型差异总结 + §12 迁移路径(4 Phase)+ §13 验收标准(A1-A10) |
|
||||
|
||||
### 待深入推演完整清单
|
||||
|
||||
| # | 议题 | 所在文件 | 优先级 |
|
||||
|---|------|---------|--------|
|
||||
| 1 | ToolResult 嵌套约束 | [9b-ir-type-system.md](9b-ir-type-system.md#31-contentblock--最小的内容单元) | ✅ 已推演(方案 C:运行时过滤) |
|
||||
| 2 | extra 的类型安全性 | [9b-ir-type-system.md](9b-ir-type-system.md#36-messagerequest--统一请求) | ✅ 已推演(方案 B:Result-based access) |
|
||||
| 3 | StreamEvent 汇聚为 MessageResponse 算法 | [9c-llm-provider-trait.md](9c-llm-provider-trait.md#44-partialmessageresponse--流式事件的汇聚算法) | ✅ 已推演(方案 B:显式边界 + BTreeMap 分桶) |
|
||||
| 4 | ToolCallStart index 归一化 | [9c-llm-provider-trait.md](9c-llm-provider-trait.md#43-流式事件-streamevent) | ✅ 已推演(自动解决,ToolCallStart 合并到 ContentBlockStart) |
|
||||
| 5 | OpenAI 流式转换实现 | [9d-provider-implementations.md](9d-provider-implementations.md#51-openai-provider兼容-chat-api) | ✅ 已推演(方案 A:SseByteStream 通用层 + OpenaiStreamToEvents 状态机,ToolCallEnd 依赖 finalize 兜底,忽略多 Choice) |
|
||||
| 6 | Anthropic 流式状态机设计 | [9d-provider-implementations.md](9d-provider-implementations.md#52-anthropicprovidermessages-api) | ✅ 已推演(轻量分发器:3 状态 + 7 种事件映射 + 零 index 映射) |
|
||||
| 7 | OpenAI Response API 完整映射表 | [9d-provider-implementations.md](9d-provider-implementations.md#53-openai-response-api草案) | 低 |
|
||||
| 8 | DeepSeek/Qwen Provider 落地策略 | [9d-provider-implementations.md](9d-provider-implementations.md#54-deepseek--qwen-等兼容-provider-的落地策略) | 低 |
|
||||
| 9 | system prompt 双重表达冲突 | [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md#62-build_request--新签名) | ✅ 已推演(方案 D:移除 system 字段,IR 只留一个入口,Provider 层负责映射) |
|
||||
| 10 | compact 在 IR 上的改法与 token 估算 | [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md#66-compact-逻辑调整) | ✅ 已推演(三个子议题各有方案决策 + 二维决策框架) |
|
||||
| 11 | Thinking signature 端到端传递 | [9f-edge-cases.md](9f-edge-cases.md#92-thinking-的端到端流程) | ✅ 已推演(方案 C:MessageComplete 兜底 + finalize 回填) |
|
||||
@@ -0,0 +1,70 @@
|
||||
# 背景与架构总览
|
||||
|
||||
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §1 背景与目标 + §2 架构总览。
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 当前状态
|
||||
|
||||
`LlmProvider` trait 的请求/响应类型直接绑定到 OpenAI Chat Completion API 格式:
|
||||
|
||||
```rust
|
||||
pub type ChatRequest = OpenaiChatRequest; // 类型别名
|
||||
pub type ChatResponse = struct { message: OpenaiChatMessage, ... };
|
||||
pub type Message = OpenaiChatMessage;
|
||||
```
|
||||
|
||||
所有 "内部统一类型" 都是 OpenAI 格式的直接映射。这导致:
|
||||
|
||||
| API 类型 | 兼容性 | 代价 |
|
||||
|----------|--------|------|
|
||||
| OpenAI Chat(DeepSeek、Qwen 等) | ✅ 原生兼容 | 零 |
|
||||
| Anthropic Messages | ❌ 语义丢失 | 需逆向映射,丢失 thinking 等特性 |
|
||||
| OpenAI Response API | ❌ 范式不兼容 | ChatResponse 无法表达多类型 output |
|
||||
| 非标自定义 API | ❌ 无扩展点 | 只能走 extra_body 逃生舱 |
|
||||
|
||||
### 1.2 目标
|
||||
|
||||
设计一套**真正与 Provider 无关的内部统一类型(IR)**,使得:
|
||||
|
||||
1. 所有 Provider 对外暴露的接口完全一致(统一 trait)
|
||||
2. 每个 Provider 内部自行完成 IR ↔ 原生格式的映射
|
||||
3. 上层(LlmCycle、AgentSession)完全感知不到具体 Provider
|
||||
4. 新 Provider 只需实现一次双向映射即可接入
|
||||
5. 各 API 的独有特性(thinking、内置工具等)有表达空间
|
||||
|
||||
### 1.3 非目标
|
||||
|
||||
- 不追求覆盖所有 API 的每一个参数(90% 核心流程即可)
|
||||
- 不追求在不改上层代码的情况下切换 Provider(接口一致足以)
|
||||
- 不试图让 OpenAI Response API 的内置工具完全融入消息循环(通过逃生舱 + 可选能力 trait)
|
||||
|
||||
---
|
||||
|
||||
## 2. 架构总览
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ 上 层(AgentSession / TaskAgent) │
|
||||
│ 只与 IR 类型和 LlmProvider trait 交互 │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ LlmCycle │
|
||||
│ 循环 / 重试 / Tool 循环 / Hook / Compact / CostTracker │
|
||||
│ 内部使用 Vec<Message> + MessageRequest │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ LlmProvider trait(核心接口) │
|
||||
│ chat(MessageRequest) → Result<MessageResponse, LlmError> │
|
||||
│ chat_stream(MessageRequest) → Stream<StreamEvent> │
|
||||
│ capabilities() → ProviderCapabilities │
|
||||
├────────────────┬──────────────────────┬──────────────────────┤
|
||||
│ OpenaiProvider │ AnthropicProvider │ DeepSeekProvider ... │
|
||||
│ IR ↔ OpenAI │ IR ↔ Anthropic │ IR ↔ OpenAI 格式 │
|
||||
│ JSON │ JSON │ (兼容 Chat API) │
|
||||
└────────────────┴──────────────────────┴──────────────────────┘
|
||||
```
|
||||
|
||||
### 2.1 分层原则
|
||||
|
||||
- **IR 层**:全项目唯一的内部表示,与任何具体 API 格式无关
|
||||
- **Provider 层**:每个 Provider 实现 IR ↔ 原生格式的双向映射,复杂度隔离在此层
|
||||
- **上层**:只与 IR 和 `LlmProvider` trait 交互,不感知具体 Provider
|
||||
@@ -0,0 +1,479 @@
|
||||
# IR 类型体系
|
||||
|
||||
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §3 IR 类型体系。
|
||||
>
|
||||
> **相关文件:**
|
||||
> - [9c-llm-provider-trait.md](9c-llm-provider-trait.md) — 使用本文定义的 IR 类型的 LlmProvider trait
|
||||
> - [9d-provider-implementations.md](9d-provider-implementations.md) — Provider 的 IR ↔ 原生格式映射
|
||||
> - [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md) — LlmCycle 改造(内部使用 `Vec<Message>` + `MessageRequest`)
|
||||
> - [9f-edge-cases.md](9f-edge-cases.md) — 边界情况(Thinking 端到端、Multiple ContentBlock 等)
|
||||
|
||||
## 3. IR 类型体系
|
||||
|
||||
### 3.1 ContentBlock —— 最小的内容单元
|
||||
|
||||
```rust
|
||||
/// 跨 Provider 统一的内容块。
|
||||
///
|
||||
/// 设计思路:
|
||||
/// - ToolUse/ToolResult 作为 content block(Anthropic 风格)
|
||||
/// - Thinking 作为一等公民
|
||||
/// - Extension 作为逃生舱
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ContentBlock {
|
||||
/// 纯文本
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
/// 图片(base64 或 URL)
|
||||
Image {
|
||||
source: ImageSource,
|
||||
},
|
||||
/// 音频输入
|
||||
Audio {
|
||||
source: AudioSource,
|
||||
},
|
||||
/// 文件上传
|
||||
File {
|
||||
source: FileSource,
|
||||
},
|
||||
/// 工具调用请求(由 Assistant 发起)
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: serde_json::Value,
|
||||
},
|
||||
/// 工具调用结果(回传)
|
||||
ToolResult {
|
||||
tool_use_id: String,
|
||||
content: Vec<ContentBlock>,
|
||||
is_error: bool,
|
||||
},
|
||||
```
|
||||
|
||||
> **✅ 推演结论(2026-06-16):采用方案 C —— 运行时过滤 + 构造时辅助 + 文档约定**
|
||||
>
|
||||
> **决策:** 保持 `content: Vec<ContentBlock>` 不变,不引入 `ToolResultContent` 类型。
|
||||
>
|
||||
> **理由:**
|
||||
> 1. ToolResult 主要由 LlmCycle 的工具循环构建(`Message::tool_result()`),而非用户手写,
|
||||
> 构造链本身已倾向于只产生 Text block。运行时过滤只是安全网。
|
||||
> 2. 引入 `ToolResultContent` 会膨胀类型体系,遍历 content 的代码需要为 ToolResult
|
||||
> 单独写一层,开发者负担大于收益。
|
||||
> 3. 未来如果 Provider 放宽约束(如 Anthropic 支持 tool_result 嵌套 tool_use),
|
||||
> 方向 B 只需要删掉过滤代码,方向 A 需要改类型定义 + 所有 match 分支,成本更高。
|
||||
>
|
||||
> **具体做法:**
|
||||
> - `ContentBlock::ToolResult.content` 保持 `Vec<ContentBlock>` 不变
|
||||
> - 新增辅助方法 `ContentBlock::is_valid_in_tool_result(&self) -> bool`,
|
||||
> 返回 `self` 是否是 ToolResult 中允许的类型(Text、Image、Audio、File、Extension)
|
||||
> - 每个 Provider 的 IR→原生格式映射层,在将 ToolResult content 转为 Provider 格式时,
|
||||
> **静默过滤 + warn log**:过滤掉 Thinking、ToolUse、ToolResult 等不允许的 block,
|
||||
> 使用 `tracing::warn!("忽略 ContentBlock::{:?} 在 ToolResult 中", block)` 记录
|
||||
> - `Message::tool_result()` 构造函数确保只产生 Text block(但不对类型做强制约束)
|
||||
> - **不返回错误**:非法嵌套不阻塞主流程,静默丢掉无关内容
|
||||
>
|
||||
> **何时实现:** Phase 4 实现 AnthropicProvider 的 ToolResult 映射时一起做。
|
||||
> **影响范围:** Provider 映射层(约 3-5 行过滤代码)+ `Message::tool_result()` 构造。
|
||||
|
||||
/// 思考内容(Anthropic thinking / OpenAI reasoning)
|
||||
Thinking {
|
||||
text: String,
|
||||
/// Anthropic 的 thinking 签名(用于验证思考未被篡改)。
|
||||
/// OpenAI 无此概念,为 None。
|
||||
signature: Option<String>,
|
||||
},
|
||||
/// 逃生舱 —— Provider 特有且无法映射的 content block
|
||||
Extension {
|
||||
kind: String,
|
||||
data: serde_json::Value,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImageSource {
|
||||
pub data: String, // base64 或 URL
|
||||
pub mime_type: String, // "image/png", "image/jpeg", "image/webp"
|
||||
pub is_url: bool, // true = URL, false = base64
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioSource {
|
||||
pub data: String, // base64
|
||||
pub format: AudioFormat, // 复用现有 AudioFormat
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileSource {
|
||||
pub data: String,
|
||||
pub filename: Option<String>,
|
||||
pub mime_type: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
#### 设计决策:为什么把 ToolUse 放在 ContentBlock 中
|
||||
|
||||
| 维度 | OpenAI 风格(独立 tool_calls 字段) | Anthropic 风格(ContentBlock 内) |
|
||||
|------|-----------------------------------|----------------------------------|
|
||||
| Assistant 消息结构 | `{ content, tool_calls }` | `{ content: [text, tool_use, ...] }` |
|
||||
| 文本与工具的顺序 | 分离,无法交错 | 按序排列,可交错 |
|
||||
| 多工具表达 | `tool_calls: [...]` 数组 | content 中多个 `tool_use` block |
|
||||
| **统一后的处理逻辑** | 需同时检查 content 和 tool_calls | **只需遍历一次 content blocks** |
|
||||
|
||||
选择 Anthropic 风格作为统一表达,因为遍历 content 即可获取所有信息,处理逻辑更简单。
|
||||
|
||||
### 3.2 Message —— 统一消息类型
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
System {
|
||||
content: Vec<ContentBlock>,
|
||||
},
|
||||
User {
|
||||
content: Vec<ContentBlock>,
|
||||
},
|
||||
Assistant {
|
||||
content: Vec<ContentBlock>,
|
||||
// 注意:ToolUse 在 content 中,不需要独立字段
|
||||
},
|
||||
Tool {
|
||||
content: Vec<ContentBlock>,
|
||||
/// 关联的 tool_call_id(OpenAI 格式需要)
|
||||
tool_call_id: String,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### 与当前 OpenaiChatMessage 的映射
|
||||
|
||||
| 当前类型 | 新 IR 类型 | 说明 |
|
||||
|---------|-----------|------|
|
||||
| `OpenaiChatMessage::Developer { content, name }` | `Message::System { content }` | 合并到 System(Anthropic 无 Developer role) |
|
||||
| `OpenaiChatMessage::System { content, name }` | `Message::System { content }` | name 暂不保留 |
|
||||
| `OpenaiChatMessage::User { content, name }` | `Message::User { content }` | `ContentField` 统一为 `Vec<ContentBlock>` |
|
||||
| `OpenaiChatMessage::Assistant { content, tool_calls, refusal, name }` | `Message::Assistant { content }` | tool_calls → ContentBlock::ToolUse;refusal → ContentBlock::Text |
|
||||
| `OpenaiChatMessage::Tool { content, tool_call_id }` | `Message::Tool { content, tool_call_id }` | 基本一致 |
|
||||
| `OpenaiChatMessage::Function { content, name }` | `Message::Tool { tool_call_id: name }` | 已废弃,映射到 Tool |
|
||||
|
||||
#### 便捷构造函数
|
||||
|
||||
```rust
|
||||
impl Message {
|
||||
pub fn user(text: impl Into<String>) -> Self;
|
||||
pub fn assistant(text: impl Into<String>) -> Self;
|
||||
pub fn system(text: impl Into<String>) -> Self;
|
||||
pub fn tool_result(tool_call_id: impl Into<String>, text: impl Into<String>) -> Self;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 StopReason —— 统一停止原因
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StopReason {
|
||||
/// 正常结束
|
||||
Stop,
|
||||
/// 达到 token 上限
|
||||
Length,
|
||||
/// 触发工具调用
|
||||
ToolUse,
|
||||
/// 被内容过滤
|
||||
ContentFilter,
|
||||
/// 达到最大 token 数
|
||||
MaxTokens,
|
||||
/// 命中停止序列
|
||||
StopSequence,
|
||||
/// 其他 / Provider 特有
|
||||
Other,
|
||||
}
|
||||
```
|
||||
|
||||
#### 跨 Provider 映射
|
||||
|
||||
| IR StopReason | OpenAI (finish_reason) | Anthropic (stop_reason) |
|
||||
|---|---|---|
|
||||
| `Stop` | `"stop"` | `"end_turn"` |
|
||||
| `Length` / `MaxTokens` | `"length"` | `"max_tokens"` |
|
||||
| `ToolUse` | `"tool_calls"` | `"tool_use"` |
|
||||
| `ContentFilter` | `"content_filter"` | — |
|
||||
| `StopSequence` | — | `"stop_sequence"` |
|
||||
| `Other` | `"function_call"` / 其他 | 其他 |
|
||||
|
||||
### 3.4 ThinkingConfig —— 思考配置
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ThinkingConfig {
|
||||
/// 思考 token 预算
|
||||
pub budget_tokens: u32,
|
||||
}
|
||||
```
|
||||
|
||||
跨 Provider 特性:Anthropic 原生支持,OpenAI 通过 `reasoning_tokens` 间接支持。
|
||||
|
||||
### 3.5 ToolDefinition —— 统一工具定义
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolDefinition {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub parameters: serde_json::Value,
|
||||
pub strict: Option<bool>,
|
||||
}
|
||||
// ToolChoice 枚举保持不变
|
||||
```
|
||||
|
||||
与当前 `OpenaiToolDefinition` 一致,不需要改动。
|
||||
|
||||
### 3.6 MessageRequest —— 统一请求
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MessageRequest {
|
||||
// ════════════════════════════════════════════
|
||||
// 核心共通参数(所有 Provider 都有的概念)
|
||||
// ════════════════════════════════════════════
|
||||
pub model: String,
|
||||
pub messages: Vec<Message>,
|
||||
// 没有 `system` 字段——系统提示统一通过 `Message::System { content }`
|
||||
// 在 `messages` 中表达。各 Provider 在 IR→原生映射层自行处理差异。
|
||||
pub tools: Vec<ToolDefinition>,
|
||||
pub tool_choice: ToolChoice,
|
||||
pub max_tokens: Option<u32>,
|
||||
pub temperature: Option<f32>,
|
||||
pub top_p: Option<f32>,
|
||||
pub stop_sequences: Vec<String>,
|
||||
pub stream: bool,
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// 跨 Provider 但非全部支持
|
||||
// ════════════════════════════════════════════
|
||||
pub thinking: Option<ThinkingConfig>,
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// 逃生舱:Provider 特有参数
|
||||
// ════════════════════════════════════════════
|
||||
/// Provider 特有的扩展参数。
|
||||
/// 约定:每个 Provider 声明自己读取哪些 key;
|
||||
/// 遇到不认识的 key 静默忽略。
|
||||
pub extra: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
```
|
||||
|
||||
> **✅ 推演结论(2026-06-16):方案 B —— Result-based access + 可选 get_extra_as 扩展**
|
||||
>
|
||||
> **决策:** 保持 `HashMap<String, serde_json::Value>` 作为存储格式,不引入 Per-Provider extra struct
|
||||
> 作为硬约束。核心改动是将 `get_extra` 返回类型从 `Option<T>` 改为 `Result<Option<T>, ExtraError>`,
|
||||
> 使类型错误可被发现和传播。
|
||||
>
|
||||
> **理由:**
|
||||
> 1. extra 的本质是逃生舱——如果给逃生舱做全类型安全,就失去了逃生舱的灵活性。
|
||||
> 方向 A(Per-Provider struct)的 N+1 膨胀和维护负担超过了收益。
|
||||
> 2. 三种 extra 参数特性不同,需要不同的处理策略:
|
||||
> - **非关键参数**(`frequency_penalty`、`seed` 等):类型错了静默降级即可
|
||||
> - **关键参数**(`response_format`、`previous_response_id` 等):类型错了必须报错
|
||||
> - **整体读取**:Provider 想一次性结构化读取时,通过 `get_extra_as` 自行定义 struct
|
||||
> 3. `get_extra_as` 提供了"struct 方案"的可选路径,但不作为硬约束,
|
||||
> 每个 Provider 在自己的模块中决定是否使用。
|
||||
>
|
||||
> **具体做法:**
|
||||
> - 新增 `ExtraError` 枚举(`TypeMismatch` / `Deserialize` 两种变体)
|
||||
> - `get_extra` 签名改为 `Result<Option<T>, ExtraError>`;key 不存在 = `Ok(None)`,类型不匹配 = `Err`
|
||||
> - 新增 `get_extra_opt`:类型不匹配时 warn log + 返回 `None`,用于非关键参数
|
||||
> - 新增 `get_extra_as`:整体反序列化 extra 到自定义 struct(Provider 内部使用)
|
||||
> - `set_extra` 保持 `impl Into<Value>` 不变,不引入 Result(调用点无错误处理负担)
|
||||
> - Provider 映射层:关键参数用 `get_extra` + `?`,非关键参数用 `get_extra_opt`
|
||||
> - **不引入运行时 key 声明验证**——Provider 支持的 keys 仅在代码注释中声明,依赖集成测试保证正确性
|
||||
>
|
||||
> **何时实现:** Phase 2 实现 `MessageRequest` 类型时一起完成
|
||||
> **影响范围:** `MessageRequest`(方法签名变更)+ 每个 Provider 的映射层(适配新签名)
|
||||
|
||||
### ExtraError —— extra 参数访问错误
|
||||
|
||||
```rust
|
||||
/// extra 参数访问错误。
|
||||
///
|
||||
/// 注意:`NotFound` 不等价于"错误"——optional 参数未设置(key 不存在)是正常状态,
|
||||
/// 返回 `Ok(None)` 而非错误。本类型只覆盖"存在但类型不对"的场景。
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ExtraError {
|
||||
/// key 存在但值的类型与要求不匹配。
|
||||
#[error("extra 字段 `{key}` 类型不匹配: {details}")]
|
||||
TypeMismatch {
|
||||
key: String,
|
||||
details: String,
|
||||
},
|
||||
/// 整体反序列化失败(用于 get_extra_as 场景)。
|
||||
#[error("extra 反序列化失败: {0}")]
|
||||
Deserialize(String),
|
||||
}
|
||||
```
|
||||
|
||||
### MessageRequest —— extra 访问方法
|
||||
|
||||
```rust
|
||||
impl MessageRequest {
|
||||
/// 核心方法:安全读取单个 extra 字段。
|
||||
///
|
||||
/// - key 不存在 → `Ok(None)`
|
||||
/// - key 存在且类型匹配 → `Ok(Some(value))`
|
||||
/// - key 存在但类型不匹配 → `Err(ExtraError::TypeMismatch)`
|
||||
///
|
||||
/// Provider 映射层对**关键参数**(如 response_format)使用此方法 + `?`。
|
||||
pub fn get_extra<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, ExtraError> {
|
||||
match self.extra.get(key) {
|
||||
None => Ok(None),
|
||||
Some(value) => serde_json::from_value(value.clone())
|
||||
.map(Some)
|
||||
.map_err(|e| ExtraError::TypeMismatch {
|
||||
key: key.to_string(),
|
||||
details: e.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 宽松读取 —— 类型不匹配时 warn log + 返回 `None`。
|
||||
///
|
||||
/// 适用于**非关键参数**(如 frequency_penalty、seed、presence_penalty)。
|
||||
/// Provider 映射层无需处理 Result,遇到类型错误静默降级。
|
||||
pub fn get_extra_opt<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
|
||||
match self.get_extra(key) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("忽略 extra 参数 `{}`: {}", key, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 整体反序列化 extra 到自定义结构体。
|
||||
///
|
||||
/// 适用于 Provider 想在映射层内一次性读取所有 extra 参数的场景。
|
||||
/// Provider 在自己的模块中定义 struct,借助 `#[serde(deny_unknown_fields)]` 获得约束:
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// // 在 openai/provider.rs
|
||||
/// #[derive(Deserialize)]
|
||||
/// #[serde(deny_unknown_fields)]
|
||||
/// struct OpenaiExtraParams {
|
||||
/// frequency_penalty: Option<f32>,
|
||||
/// seed: Option<i64>,
|
||||
/// response_format: Option<ResponseFormat>,
|
||||
/// }
|
||||
///
|
||||
/// // 映射层中:
|
||||
/// let extra: OpenaiExtraParams = request.get_extra_as()?;
|
||||
/// ```
|
||||
///
|
||||
/// **注意:** 使用 `deny_unknown_fields` 时,来自其他 Provider 的 extra key
|
||||
/// 会导致反序列化失败。不设置 `deny_unknown_fields` 则自动忽略无关 key。
|
||||
pub fn get_extra_as<T: DeserializeOwned>(&self) -> Result<T, ExtraError> {
|
||||
let obj = self
|
||||
.extra
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
serde_json::from_value(serde_json::Value::Object(obj))
|
||||
.map_err(|e| ExtraError::Deserialize(e.to_string()))
|
||||
}
|
||||
|
||||
/// 设置 extra 参数。
|
||||
///
|
||||
/// 值通过 `impl Into<serde_json::Value>` 传入,支持:
|
||||
/// - `set_extra("user", "abc")` —— `&str` → `Value::String`
|
||||
/// - `set_extra("seed", Value::from(42_i64))` —— 显式 Value 构造
|
||||
/// - `set_extra("logit_bias", json!({"2435": -100}))` —— json! 宏
|
||||
///
|
||||
/// 对复杂类型推荐使用 `json!()` 宏以保证可读性。
|
||||
/// 无返回值 —— 调用点无错误处理负担(HashMap insert 不会失败)。
|
||||
pub fn set_extra(&mut self, key: impl Into<String>, value: impl Into<serde_json::Value>) {
|
||||
self.extra.insert(key.into(), value.into());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Provider 映射层使用模式
|
||||
|
||||
```rust
|
||||
// === OpenAI provider IR→native 映射示例 ===
|
||||
|
||||
// 非关键参数 —— get_extra_opt,类型错了静默降级
|
||||
let frequency_penalty: Option<f32> = request.get_extra_opt("frequency_penalty");
|
||||
let presence_penalty: Option<f32> = request.get_extra_opt("presence_penalty");
|
||||
let seed: Option<i64> = request.get_extra_opt("seed");
|
||||
|
||||
// 关键参数 —— get_extra + ?,类型错误必须报出来
|
||||
let response_format: Option<ResponseFormat> = request.get_extra("response_format").map_err(|e| {
|
||||
LlmError::Other(format!("extra 参数读取失败: {e}"))
|
||||
})?;
|
||||
|
||||
// 或者 Provider 定义自己的 struct 一把读取
|
||||
// let extra: OpenaiExtraParams = request.get_extra_as()?;
|
||||
```
|
||||
|
||||
### 使用约定
|
||||
|
||||
- 每个 Provider 在模块顶部的注释中声明自己读取的 extra key
|
||||
- Provider 遇到不认识的 key **静默忽略**
|
||||
- **key 命名规范**:使用 `snake_case`,与 Provider 原生 API 的参数名一致
|
||||
- **key 去重规则**:extra 中出现的 key 不能与 `MessageRequest` 的命名参数字段重复
|
||||
(如 `max_tokens` 是命名参数,不能在 extra 中重复设置)
|
||||
- 非关键参数优先使用 `get_extra_opt`,关键参数使用 `get_extra` + 显式错误处理
|
||||
|
||||
### 典型 extra key 约定
|
||||
|
||||
| key | 值类型 | 使用者 | 读取方式 | 说明 |
|
||||
|-----|--------|--------|---------|------|
|
||||
| `frequency_penalty` | `f32` | OpenAI | `get_extra_opt` | 频率惩罚 |
|
||||
| `presence_penalty` | `f32` | OpenAI | `get_extra_opt` | 存在惩罚 |
|
||||
| `logit_bias` | `HashMap<String, f32>` | OpenAI | `get_extra_opt` | Token 偏置 |
|
||||
| `seed` | `i64` | OpenAI | `get_extra_opt` | 随机种子 |
|
||||
| `service_tier` | `String` | OpenAI | `get_extra_opt` | 服务等级 |
|
||||
| `user` | `String` | OpenAI | `get_extra_opt` | 最终用户标识 |
|
||||
| `response_format` | `ResponseFormat` | OpenAI | `get_extra` | **关键**:响应格式 |
|
||||
| `parallel_tool_calls` | `bool` | OpenAI | `get_extra_opt` | 是否并行工具 |
|
||||
| `built_in_tools` | `Vec<String>` | OpenAI Response | `get_extra_opt` | 内置工具列表 |
|
||||
| `previous_response_id` | `String` | OpenAI Response | `get_extra` | **关键**:前序响应 ID |
|
||||
|
||||
### 3.7 MessageResponse —— 统一响应
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MessageResponse {
|
||||
pub id: String,
|
||||
pub model: String,
|
||||
pub message: Message,
|
||||
pub usage: Usage, // 复用现有 Usage 类型
|
||||
pub stop_reason: StopReason,
|
||||
/// Provider 特有扩展数据
|
||||
pub extra: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl MessageResponse {
|
||||
/// 提取纯文本内容(拼接所有 Text block)
|
||||
pub fn text(&self) -> String;
|
||||
}
|
||||
```
|
||||
|
||||
#### Usage 的跨 Provider 兼容性
|
||||
|
||||
当前 `Usage` 类型:
|
||||
|
||||
```rust
|
||||
pub struct Usage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
pub completion_tokens_details: Option<CompletionTokensDetails>,
|
||||
pub prompt_tokens_details: Option<PromptTokensDetails>,
|
||||
}
|
||||
```
|
||||
|
||||
| Provider | prompt_tokens | completion_tokens | 其他 | 映射方式 |
|
||||
|----------|--------------|-------------------|------|---------|
|
||||
| OpenAI | `usage.prompt_tokens` | `usage.completion_tokens` | `details.*` | 直接使用 |
|
||||
| Anthropic | `usage.input_tokens` | `usage.output_tokens` | `cache_*` 映射到 `details.cached_tokens` | 映射赋值 |
|
||||
|
||||
`Usage` 类型可以直接复用,无需修改。
|
||||
@@ -0,0 +1,446 @@
|
||||
# LlmProvider Trait 设计
|
||||
|
||||
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §4 LlmProvider Trait 设计。
|
||||
>
|
||||
> **相关文件:**
|
||||
> - [9b-ir-type-system.md](9b-ir-type-system.md) — IR 类型定义(MessageRequest、MessageResponse、StreamEvent 等)
|
||||
> - [9d-provider-implementations.md](9d-provider-implementations.md) — 各 Provider 的具体实现策略
|
||||
> - [9f-edge-cases.md](9f-edge-cases.md) — Thinking signature 等边界情况(与 StreamEvent 设计关联)
|
||||
|
||||
## 4. LlmProvider Trait 设计
|
||||
|
||||
### 4.1 核心接口
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait LlmProvider: Send + Sync {
|
||||
/// 发送消息请求,获取完整响应。
|
||||
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError>;
|
||||
|
||||
/// 流式消息请求,返回语义化事件流。
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
request: MessageRequest,
|
||||
) -> Result<
|
||||
Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>,
|
||||
LlmError,
|
||||
>;
|
||||
|
||||
/// 返回 Provider 的能力描述。
|
||||
fn capabilities(&self) -> ProviderCapabilities;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 ProviderCapabilities
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderCapabilities {
|
||||
/// Provider 标识名称
|
||||
pub provider_name: &'static str,
|
||||
/// 支持的模型列表(None = 不限制)
|
||||
pub supported_models: Option<Vec<String>>,
|
||||
/// 特性标记
|
||||
pub features: ProviderFeatures,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ProviderFeatures {
|
||||
pub streaming: bool,
|
||||
pub thinking: bool,
|
||||
pub vision: bool,
|
||||
pub audio_input: bool,
|
||||
pub tool_use: bool,
|
||||
pub parallel_tool_calls: bool,
|
||||
/// true=OpenAI风格(system in messages), false=Anthropic风格(system as param)
|
||||
pub system_prompt_in_messages: bool,
|
||||
pub max_context_window: u32,
|
||||
}
|
||||
```
|
||||
|
||||
#### capabilities() 的使用场景
|
||||
|
||||
1. **LlmCycle**:根据 `features.thinking` 决定是否启用 thinking 模式
|
||||
2. **AgentBuilder**:在构建时校验模型是否支持所需特性
|
||||
3. **UI/CLI**:展示 Provider 的能力矩阵
|
||||
4. **智能路由**:根据能力自动选择最佳 Provider
|
||||
|
||||
### 4.3 流式事件 StreamEvent
|
||||
|
||||
```rust
|
||||
/// 流式事件 —— 流式响应的语义化增量构建过程。
|
||||
/// 一组 StreamEvent 最终可汇聚为一个完整的 MessageResponse。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum StreamEvent {
|
||||
// ── Meta ──
|
||||
/// 消息开始
|
||||
MessageStart { id: String, model: String },
|
||||
|
||||
// ── Content Block 边界 ──
|
||||
/// Content block 开始(index = 全局 content block 序号)
|
||||
ContentBlockStart { index: u32, block_type: ContentBlockType },
|
||||
/// Content block 结束
|
||||
ContentBlockEnd { index: u32 },
|
||||
|
||||
// ── 块内增量(无 index,隐含属于当前活跃 block)──
|
||||
/// 文本增量
|
||||
TextDelta { text: String },
|
||||
/// 思考增量(Anthropic thinking block)
|
||||
ThinkingDelta { text: String },
|
||||
/// 拒绝增量(OpenAI refusal,汇聚为 ContentBlock::Text)
|
||||
RefusalDelta { text: String },
|
||||
|
||||
// ── Tool Call 参数(index = block index)──
|
||||
/// 工具参数增量
|
||||
ToolCallArgumentsDelta { index: u32, arguments: String },
|
||||
/// 工具参数结束,可尝试解析 JSON
|
||||
ToolCallEnd { index: u32 },
|
||||
|
||||
// ── 汇总 ──
|
||||
/// Token 用量(字段级合并,见 PartialUsage)
|
||||
CostUpdate { usage: PartialUsage },
|
||||
/// 消息完成(thinking_signature 仅 Anthropic 场景,回填到最后的 Thinking block)
|
||||
MessageComplete { stop_reason: StopReason, thinking_signature: Option<String> },
|
||||
|
||||
// ── 错误 ──
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
/// ContentBlock 的类型标识,嵌入在 ContentBlockStart 事件中。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ContentBlockType {
|
||||
Text,
|
||||
Thinking,
|
||||
Refusal,
|
||||
ToolUse { id: String, name: String },
|
||||
}
|
||||
|
||||
/// CostUpdate 中携带的用量数据——只含本次更新中真正下发的字段。
|
||||
///
|
||||
/// 汇聚算法做字段级合并(覆盖各字段的 Some 值),而非全量覆盖。
|
||||
/// 解决 Provider 分多次下发 Usage 的问题:
|
||||
/// - OpenAI: 一次全量下发(所有字段都有值)
|
||||
/// - Anthropic: message_start 时下发 input_tokens,message_delta 时下发 output_tokens
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PartialUsage {
|
||||
pub prompt_tokens: Option<u32>,
|
||||
pub completion_tokens: Option<u32>,
|
||||
pub total_tokens: Option<u32>,
|
||||
pub completion_tokens_details: Option<CompletionTokensDetails>,
|
||||
pub prompt_tokens_details: Option<PromptTokensDetails>,
|
||||
}
|
||||
```
|
||||
|
||||
#### 设计决策:为什么移除 ToolCallStart
|
||||
|
||||
`ToolCallStart { index, id, name }` 的语义本质是一个 ContentBlock 的开始(类型为 ToolUse),
|
||||
没有单独存在的必要。合并到 `ContentBlockStart.block_type = ToolUse { id, name }` 后:
|
||||
- **index 语义统一**:ContentBlockStart.index 是全文档唯一的 content block 序号
|
||||
- **事件减少**:每次 tool_use block 开始少发一个事件,Anthropic 映射更自然(`content_block_start` → `ContentBlockStart`)
|
||||
- **#4议题自动解决**:index 在 IR 层面始终为全局序号,OpenAI Provider 内部的局部→全局映射完全封装在 Provider 层,
|
||||
不在 IR 中暴露差异
|
||||
|
||||
#### 设计决策:为什么 TextDelta 不带 index
|
||||
|
||||
- 单 SSE 连接内 TCP 保证字节序,TextDelta 必然属于最后一个 ContentBlockStart 开始的 block
|
||||
- 不携带 index 可减少事件体积
|
||||
- 如果未来 LlmCycle 需要跨 Provider 合并流,可向后兼容地添加 index 字段(降级策略:无 index 时默认归属当前活跃 block)
|
||||
|
||||
#### 设计决策:CostUpdate 使用 PartialUsage 做字段级合并
|
||||
|
||||
覆盖策略(取最新值)在 Anthropic 场景下出错——Anthropic 分两次下发:
|
||||
```
|
||||
CostUpdate #1: { prompt_tokens: Some(100), completion_tokens: None, total_tokens: Some(100) }
|
||||
CostUpdate #2: { prompt_tokens: None, completion_tokens: Some(50), total_tokens: Some(150) }
|
||||
```
|
||||
如果直接 `Usage` 全量覆盖,第一次的 prompt_tokens 会被第二次的 `None` 清零。
|
||||
改为 `PartialUsage`(字段级 `Option`)后,汇聚算法只更新 `Some` 的字段,避免清零。
|
||||
|
||||
#### 与当前 StreamEvent 的对比
|
||||
|
||||
| 当前 StreamEvent | 新 StreamEvent | 理由 |
|
||||
|-----------------|---------------|------|
|
||||
| `AssistantTextDelta { text }` | `TextDelta { text }` | 简洁化 |
|
||||
| — | `ThinkingDelta { text }` | Anthropic 需要 |
|
||||
| — | `RefusalDelta { text }` | OpenAI 需要 |
|
||||
| `ToolExecutionStarted { tool_name, input, tool_call_id }` | `ContentBlockStart { index, ToolUse { id, name } }` + `ToolCallArgumentsDelta { index, arguments }` | 拆分为 block 边界 + 参数增量 |
|
||||
| `ToolExecutionCompleted` | **移除** | LlmCycle 层事件,非 Provider 层 |
|
||||
| — | `ContentBlockStart` / `ContentBlockEnd` | 显式 block 边界标记 |
|
||||
| — | `BlockContentType` | ContentBlock 类型标识 |
|
||||
| `CostUpdate { usage: Usage }` | `CostUpdate { usage: PartialUsage }` | 字段级合并,兼容多 Provider |
|
||||
| `TurnComplete { reason }` | `MessageComplete { stop_reason, thinking_signature: Option<String> }` | 语义更准确 + thinking 签名回填 |
|
||||
| `Error { message }` | 保留 | ✅ |
|
||||
| — | `MessageStart { id, model }` | Anthropic 需要 |
|
||||
| — | `ToolCallEnd { index }` | 明确参数完整时间点 |
|
||||
| `ToolCallStart { index, id, name }` | **移除** | 合并到 ContentBlockStart.ToolUse |
|
||||
|
||||
---
|
||||
|
||||
### 4.4 PartialMessageResponse —— 流式事件的汇聚算法
|
||||
|
||||
> **✅ 推演结论(2026-06-17):采用方案 B(显式边界)—— ContentBlockStart/End 声明 block 边界,BTreeMap 按 index 分桶组装**
|
||||
>
|
||||
> **核心思路:** 放弃"类型切换推断 block 边界"的隐含方案。为 StreamEvent 增加 `ContentBlockStart` 和 `ContentBlockEnd`
|
||||
> 事件,使每个 block 的开始和结束都有精确的事件标记。汇聚算法由"线性累积 + 隐含 flush + 延迟排序"改为
|
||||
> **"按 index 分桶 + 排序组装"**,彻底消除对事件到达顺序的依赖。
|
||||
>
|
||||
> **决策理由:**
|
||||
> 1. 类型切换推断在 ToolCallEnd 后跟 TextDelta 的场景下导致顺序错乱(ToolUse 被延迟插入到 Text 之后)
|
||||
> 2. 显式边界将位置信息绑定到 index,不依赖事件到达时序,容错性更强
|
||||
> 3. Anthropic 的 `content_block_start/stop` 天然映射为 `ContentBlockStart/End`
|
||||
> 4. OpenAI 的无边界流式由 Provider 内部分析 delta 类型来合成边界,封装在 Provider 层
|
||||
> 5. #4(ToolCallStart index 归一化)自动解决——index 统一为全局 content block 序号
|
||||
|
||||
#### PartialMessageResponse 结构体
|
||||
|
||||
```rust
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use serde_json::Value;
|
||||
|
||||
/// 流式事件的累积状态 —— 按 index 分桶,逐个构建 ContentBlock。
|
||||
#[derive(Debug, Default)]
|
||||
pub struct PartialMessageResponse {
|
||||
// ── 来自 MessageStart ──
|
||||
pub id: Option<String>,
|
||||
pub model: Option<String>,
|
||||
|
||||
/// 按 index 分桶的 ContentBlock 构建器(BTreeMap 保证升序遍历)
|
||||
pub blocks: BTreeMap<u32, ContentBlockBuilder>,
|
||||
|
||||
/// Tool call 参数累积(按 block index 关联)
|
||||
pub tool_call_args: HashMap<u32, String>,
|
||||
|
||||
/// 已收到 ContentBlockEnd 的 block index 集合
|
||||
pub block_completion: HashSet<u32>,
|
||||
|
||||
/// 当前最后打开的 block index(供无 index 的 TextDelta 定位)
|
||||
pub last_open_index: Option<u32>,
|
||||
|
||||
/// Token 用量(字段级合并后的最终值)
|
||||
pub usage: Usage,
|
||||
|
||||
/// 结束状态
|
||||
pub stop_reason: Option<StopReason>,
|
||||
|
||||
/// MessageComplete 携带的 thinking signature(留待 finalize 回填)
|
||||
pub thinking_signature: Option<String>,
|
||||
|
||||
pub is_errored: bool,
|
||||
pub is_complete: bool,
|
||||
}
|
||||
|
||||
/// 按 index 分桶的 ContentBlock 构建器。
|
||||
#[derive(Debug)]
|
||||
pub enum ContentBlockBuilder {
|
||||
Text(String),
|
||||
Thinking { buffer: String, signature: Option<String> },
|
||||
Refusal(String),
|
||||
ToolUse { id: String, name: String },
|
||||
}
|
||||
```
|
||||
|
||||
#### apply_to 算法
|
||||
|
||||
```rust
|
||||
impl StreamEvent {
|
||||
/// 将当前事件应用到 PartialMessageResponse 上。
|
||||
/// 返回 true 表示正常处理,false 表示应停止处理后续事件。
|
||||
pub fn apply_to(&self, state: &mut PartialMessageResponse) -> bool {
|
||||
match self {
|
||||
// ──────────────── Meta ────────────────
|
||||
StreamEvent::MessageStart { id, model } => {
|
||||
state.id = Some(id.clone());
|
||||
state.model = Some(model.clone());
|
||||
true
|
||||
}
|
||||
|
||||
// ──────────────── Block 边界 ────────────────
|
||||
StreamEvent::ContentBlockStart { index, block_type } => {
|
||||
let builder = match block_type {
|
||||
ContentBlockType::Text => ContentBlockBuilder::Text(String::new()),
|
||||
ContentBlockType::Thinking => ContentBlockBuilder::Thinking {
|
||||
buffer: String::new(),
|
||||
signature: None,
|
||||
},
|
||||
ContentBlockType::Refusal => ContentBlockBuilder::Refusal(String::new()),
|
||||
ContentBlockType::ToolUse { id, name } =>
|
||||
ContentBlockBuilder::ToolUse { id: id.clone(), name: name.clone() },
|
||||
};
|
||||
state.blocks.entry(*index).or_insert(builder);
|
||||
state.last_open_index = Some(*index);
|
||||
true
|
||||
}
|
||||
|
||||
StreamEvent::ContentBlockEnd { index } => {
|
||||
state.block_completion.insert(*index);
|
||||
true
|
||||
}
|
||||
|
||||
// ──────────────── 块内增量 ────────────────
|
||||
StreamEvent::TextDelta { text } => {
|
||||
// 定位到最后打开的 block
|
||||
if let Some(idx) = state.last_open_index {
|
||||
if let Some(ContentBlockBuilder::Text(ref mut buf)) = state.blocks.get_mut(&idx) {
|
||||
buf.push_str(text);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("TextDelta 到达时无活跃 block");
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
StreamEvent::ThinkingDelta { text } => {
|
||||
if let Some(idx) = state.last_open_index {
|
||||
if let Some(ContentBlockBuilder::Thinking { ref mut buffer, .. }) =
|
||||
state.blocks.get_mut(&idx)
|
||||
{
|
||||
buffer.push_str(text);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("ThinkingDelta 到达时无活跃 block");
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
StreamEvent::RefusalDelta { text } => {
|
||||
if let Some(idx) = state.last_open_index {
|
||||
if let Some(ContentBlockBuilder::Refusal(ref mut buf)) =
|
||||
state.blocks.get_mut(&idx)
|
||||
{
|
||||
buf.push_str(text);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("RefusalDelta 到达时无活跃 block");
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// ──────────────── Tool Call 参数 ────────────────
|
||||
StreamEvent::ToolCallArgumentsDelta { index, arguments } => {
|
||||
state
|
||||
.tool_call_args
|
||||
.entry(*index)
|
||||
.or_default()
|
||||
.push_str(arguments);
|
||||
true
|
||||
}
|
||||
|
||||
StreamEvent::ToolCallEnd { index } => {
|
||||
// ToolCallEnd 只做标记,参数在 finalize 时解析
|
||||
state.block_completion.insert(*index);
|
||||
true
|
||||
}
|
||||
|
||||
// ──────────────── 汇总 ────────────────
|
||||
StreamEvent::CostUpdate { usage } => {
|
||||
Self::apply_cost_update(state, usage);
|
||||
true // 即使 is_complete 后 CostUpdate 仍然可以到达
|
||||
}
|
||||
|
||||
StreamEvent::MessageComplete {
|
||||
stop_reason,
|
||||
thinking_signature,
|
||||
} => {
|
||||
state.stop_reason = Some(*stop_reason);
|
||||
state.thinking_signature = thinking_signature.clone();
|
||||
state.is_complete = true;
|
||||
true
|
||||
}
|
||||
|
||||
// ──────────────── 错误 ────────────────
|
||||
StreamEvent::Error { message } => {
|
||||
state.is_errored = true;
|
||||
tracing::error!("流式处理中发生错误: {}", message);
|
||||
false // 终止处理
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 字段级合并 CostUpdate(仅覆盖 Some 的字段)。
|
||||
fn apply_cost_update(state: &mut PartialMessageResponse, update: &PartialUsage) {
|
||||
if let Some(v) = update.prompt_tokens {
|
||||
state.usage.prompt_tokens = v;
|
||||
}
|
||||
if let Some(v) = update.completion_tokens {
|
||||
state.usage.completion_tokens = v;
|
||||
}
|
||||
if let Some(v) = update.total_tokens {
|
||||
state.usage.total_tokens = v;
|
||||
}
|
||||
if update.completion_tokens_details.is_some() {
|
||||
state.usage.completion_tokens_details = update.completion_tokens_details;
|
||||
}
|
||||
if update.prompt_tokens_details.is_some() {
|
||||
state.usage.prompt_tokens_details = update.prompt_tokens_details;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### finalize —— 完成逻辑
|
||||
|
||||
```rust
|
||||
impl PartialMessageResponse {
|
||||
/// 将所有累积状态转化为最终的 MessageResponse。
|
||||
pub fn finalize(mut self) -> Result<MessageResponse, LlmError> {
|
||||
let id = self.id.unwrap_or_else(|| "stream-unknown".to_string());
|
||||
let model = self.model.unwrap_or_else(|| "unknown".to_string());
|
||||
let stop_reason = self.stop_reason.unwrap_or(StopReason::Other);
|
||||
|
||||
// 1. 按 index 升序遍历所有 blocks,转换为 ContentBlock
|
||||
let mut content: Vec<ContentBlock> = Vec::new();
|
||||
for (_index, builder) in std::mem::take(&mut self.blocks).into_iter() {
|
||||
let block = match builder {
|
||||
ContentBlockBuilder::Text(text) => ContentBlock::Text { text },
|
||||
ContentBlockBuilder::Thinking { buffer, mut signature } => {
|
||||
// 如果 Thinking block 的 signature 尚未填充,用 MessageComplete 的回填
|
||||
if signature.is_none() {
|
||||
signature = self.thinking_signature.clone();
|
||||
}
|
||||
ContentBlock::Thinking {
|
||||
text: buffer,
|
||||
signature,
|
||||
}
|
||||
}
|
||||
ContentBlockBuilder::Refusal(text) => ContentBlock::Text { text },
|
||||
ContentBlockBuilder::ToolUse { id, name } => {
|
||||
let arguments = self.tool_call_args.remove(&(_index as u32))
|
||||
.unwrap_or_default();
|
||||
let input: Value = serde_json::from_str(&arguments)
|
||||
.unwrap_or(Value::Null);
|
||||
ContentBlock::ToolUse { id, name, input }
|
||||
}
|
||||
};
|
||||
content.push(block);
|
||||
}
|
||||
|
||||
Ok(MessageResponse {
|
||||
id,
|
||||
model,
|
||||
message: Message::Assistant { content },
|
||||
usage: self.usage,
|
||||
stop_reason,
|
||||
extra: HashMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 检查是否已收到足以构造"有意义"响应的数据。
|
||||
pub fn is_meaningful(&self) -> bool {
|
||||
self.id.is_some() && (self.is_complete || self.is_errored)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 边界情况处理
|
||||
|
||||
| 边界场景 | 处理策略 | 理由 |
|
||||
|----------|---------|------|
|
||||
| MessageStart 前收到 TextDelta | warn log,忽略增量 | 不合法流,防御性处理 |
|
||||
| ToolCallArgumentsDelta 乱序 | HashMap key 天然容忍 | index 作为关联 key,到达顺序不影响最终累积 |
|
||||
| 多次 CostUpdate | 字段级合并(PartialUsage) | OpenAI 一次全量,Anthropic 分两次,OpenAI Response 可能多次 |
|
||||
| 同一 index 收到多次 ContentBlockStart | `entry(*index).or_insert()` 幂等 | 第二次不覆盖已有内容 |
|
||||
| MessageComplete 后 CostUpdate 才到 | 不阻断,仍然合并 | Usage 最终值可能最后才到 |
|
||||
| ContentBlockEnd 缺失 | finalize 时仍然输出内容 | 不完整的 block 也是内容 |
|
||||
| 无任何 ContentBlockStart | finalize 返回空的 Assistant 消息 | 边界场景,不阻断 |
|
||||
| Error 后收到其他事件 | Error 返回 false,上层停止调用 apply_to | 一旦出错,不再处理后续事件 |
|
||||
| thinking_signature 未填 | Thinking block 的 signature 为 None | 安全降级 |
|
||||
@@ -0,0 +1,459 @@
|
||||
# Provider 实现策略
|
||||
|
||||
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §5 Provider 实现策略。
|
||||
>
|
||||
> **相关文件:**
|
||||
> - [9b-ir-type-system.md](9b-ir-type-system.md) — IR 类型定义(ContentBlock、Message、MessageRequest/Response 等)
|
||||
> - [9c-llm-provider-trait.md](9c-llm-provider-trait.md) — LlmProvider trait 定义(chat、chat_stream 签名)
|
||||
> - [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md) — LlmCycle 改造(system prompt 冲突等与 Provider 相关)
|
||||
> - [9f-edge-cases.md](9f-edge-cases.md) — Thinking signature 端到端传递(与 Anthropic 流式强相关)
|
||||
|
||||
## 5. Provider 实现策略
|
||||
|
||||
### 5.1 OpenaiProvider(兼容 Chat API)
|
||||
|
||||
```
|
||||
MessageRequest
|
||||
│
|
||||
├── model → model
|
||||
├── messages → messages (逐条映射,见下方)
|
||||
├── system → 插入为首条 System message(如无 System message 时)
|
||||
├── tools → tools (OpenaiTool::Function)
|
||||
├── tool_choice → tool_choice
|
||||
├── max_tokens → max_tokens
|
||||
├── temperature → temperature
|
||||
├── top_p → top_p
|
||||
├── stop_sequences → stop (StopSequence::Multiple)
|
||||
├── thinking → 忽略(OpenAI 不支持)
|
||||
└── extra.* → 对应字段 / extra_body
|
||||
|
||||
Message → OpenaiChatMessage:
|
||||
System { content } → System { content: to_openai_content(content) }
|
||||
User { content } → User { content: to_openai_content(content) }
|
||||
Assistant { content } → Assistant {
|
||||
content: to_openai_content(text_blocks),
|
||||
tool_calls: extract_tool_calls(content)
|
||||
}
|
||||
Tool { content, tool_call_id } → Tool { content, tool_call_id }
|
||||
|
||||
ContentBlock → OpenaiContentPart:
|
||||
Text { text } → Text { text }
|
||||
Image { source } → Image { image_url: { url, detail } }
|
||||
Audio { source } → InputAudio { input_audio }
|
||||
File { source } → File { file }
|
||||
ToolUse { id, name, input } → 转为 OpenaiToolCall 放入 tool_calls 字段
|
||||
ToolResult { .. } → 通过 tool_call_id 关联到 Tool 消息
|
||||
Thinking { .. } → 忽略
|
||||
Extension { .. } → 忽略
|
||||
|
||||
OpenaiChatResponse → MessageResponse:
|
||||
id → id
|
||||
model → model
|
||||
usage → usage
|
||||
choices[0].finish_reason → stop_reason
|
||||
choices[0].message → Message::Assistant {
|
||||
content: [
|
||||
ContentBlock::Text { text },
|
||||
... (msg.tool_calls → ContentBlock::ToolUse)
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> **✅ 推演结论(2026-06-18):**
|
||||
>
|
||||
> ### 架构分层
|
||||
>
|
||||
> OpenAI 的流式转换拆为**两层**,字节解析层通用、事件转换层 OpenAI 特定:
|
||||
>
|
||||
> ```
|
||||
> bytes_stream()
|
||||
> │
|
||||
> ▼
|
||||
> SseByteStream [sse.rs — 通用层]
|
||||
> │ 逐行分割、按空行分帧、解析 event:/data: 行前缀、
|
||||
> │ 过滤 "[DONE]"/ping、缓冲拼接碎片、多行 data: 自动拼接
|
||||
> ▼
|
||||
> Stream<Item=Result<SseEvent, LlmError>> ← 结构化 SSE 帧
|
||||
> │ SseEvent { event_name: Option<String>, data: String }
|
||||
> │ - OpenAI: event_name = None(未命名事件)
|
||||
> │ - Anthropic: event_name = Some("message_start" | "content_block_delta" | ...)
|
||||
> │
|
||||
> ├─ OpenAI ────→ OpenaiStreamToEvents [openai/stream.rs]
|
||||
> │ 忽略 event_name,反序列化 data → OpenaiChatChunk → 状态机
|
||||
> │
|
||||
> └─ Anthropic ──→ AnthropicStreamToEvents [anthropic/stream.rs]
|
||||
> 按 event_name 分发事件类型 → 事件映射
|
||||
> ```
|
||||
>
|
||||
> **Layer 1 — `SseByteStream<S>`(新增 `src/llm/provider/sse.rs`)**
|
||||
>
|
||||
> 从当前 `SseChunkStream`(`openai.rs` 内联实现)提取字节解析逻辑为通用 SSE 解析器。
|
||||
> 产出 `SseEvent` 结构体,携带 `event_name` + `data` 两部分信息。
|
||||
> OpenAI 和 Anthropic 均直接复用此层,各自的事件转换器按需使用 `event_name`。
|
||||
>
|
||||
> **Layer 2 — `OpenaiStreamToEvents`(新增 `src/llm/provider/openai/stream.rs`)**
|
||||
>
|
||||
> 接收 `SseEvent` 流,忽略 `event_name`(OpenAI 为 `None`),
|
||||
> 将 `data` 反序列化为 `OpenaiChatChunk`(保留作为内部格式),
|
||||
> 通过状态机转换为 `StreamEvent` 事件流。
|
||||
>
|
||||
> ### 状态机设计
|
||||
>
|
||||
> ```rust
|
||||
> pub struct OpenaiStreamToEvents<S> {
|
||||
> inner: S, // Stream<Item=Result<String, LlmError>>
|
||||
> // ── 状态 ──
|
||||
> global_index: u32, // 下一个可用 content block 序号
|
||||
> current_block: Option<(u32, CurrentBlockType)>, // 当前活跃 block
|
||||
> tool_call_indices: HashMap<u32, u32>, // OpenAI tool_call.index → 全局序号
|
||||
> is_complete: bool,
|
||||
> }
|
||||
>
|
||||
> enum CurrentBlockType { Text, Refusal, ToolUse }
|
||||
> ```
|
||||
>
|
||||
> **每条 JSON 行的处理流程:**
|
||||
>
|
||||
> ```
|
||||
> 收到一行 JSON 字符串
|
||||
> ├─ 解析为 OpenaiChatChunk
|
||||
> │
|
||||
> ├─ Phase 1: 处理 delta 内容(先增量)
|
||||
> │ ├─ delta.content → ensure_block(Text) → TextDelta
|
||||
> │ ├─ delta.refusal → ensure_block(Refusal) → RefusalDelta
|
||||
> │ └─ delta.tool_calls
|
||||
> │ ├─ 新 tool call(function.name 有值)
|
||||
> │ │ → ensure_block(ToolUse, id, name) → (不产参数事件,等后续 arguments)
|
||||
> │ └─ 已有 tool call(function.arguments 有值)
|
||||
> │ → ToolCallArgumentsDelta(index, arguments)
|
||||
> │
|
||||
> └─ Phase 2: 处理汇总(后收束)
|
||||
> ├─ finish_reason 存在 → close_current_block() + MessageComplete
|
||||
> ├─ usage 存在 → CostUpdate
|
||||
> └─ 同时存在 → CostUpdate → close_block → MessageComplete
|
||||
> ```
|
||||
>
|
||||
> **核心抽象 `ensure_block`:** 当新 chunk 的 delta 类型与当前活跃 block 不同时,
|
||||
> 自动关闭当前 block(emit `ContentBlockEnd`)并开启新 block(emit `ContentBlockStart`)。
|
||||
> 同类型继续时只发增量事件,不切换。
|
||||
>
|
||||
> **状态转移表:**
|
||||
>
|
||||
> | 当前状态 | 收到 delta.content | 收到 delta.tool_calls (new) | 收到 delta.tool_calls (延续) | 收到 finish_reason |
|
||||
> |----------|-------------------|----------------------------|----------------------------|-------------------|
|
||||
> | 无活跃 block | → ContentBlockStart(Text)<br>→ TextDelta | → ContentBlockStart(ToolUse) | (不应发生) | → MessageComplete |
|
||||
> | Text 活跃中 | → TextDelta | → ContentBlockEnd<br>→ ContentBlockStart(ToolUse) | (不应发生,与 content 互斥) | → ContentBlockEnd<br>→ CostUpdate<br>→ MessageComplete |
|
||||
> | ToolUse 活跃中 | → ContentBlockEnd<br>→ ContentBlockStart(Text) | → ContentBlockEnd<br>→ ContentBlockStart(new ToolUse) | → ToolCallArgumentsDelta | → ContentBlockEnd<br>→ CostUpdate<br>→ MessageComplete |
|
||||
>
|
||||
> ### 各议题结论
|
||||
>
|
||||
> | # | 议题 | 结论 | 理由 |
|
||||
> |---|------|------|------|
|
||||
> | 1 | **SSE 字节解析复用** | 提取为通用 `SseByteStream`(`sse.rs`),支持 `event:` + `data:` 双行解析;`OpenaiStreamToEvents` / `AnthropicStreamToEvents` 分别在其上封装 | Anthropic 复用相同字节协议,通过 `SseEvent.event_name` 区分事件类型;分层测试、职责清晰 |
|
||||
> | 2 | **ContentBlockStart/End 合成** | "类型切换推断边界"策略——来什么类型就关旧开新,依赖 content/tool_calls 互斥保证 | 状态机 3 种当前类型覆盖全部场景,假设验证通过 |
|
||||
> | 3 | **Tool call 序号映射** | `HashMap<openai_index, global_block_index>`,新 tool call 出现时分配全局序号 | OpenAI index 是 tool 数组级别全局的,但与 IR content block 体系不同,需映射 |
|
||||
> | 4 | **多 Choice** | 忽略 choices[1..],不暴露 | 当前架构无多 choice 概念,80% 场景 n=1,非目标已明确 |
|
||||
> | 5 | **Usage 时机** | 先发 `CostUpdate` → 再发 `MessageComplete`,同一 chunk 内串行 | 汇聚算法兼容两者顺序,但语义上先用量后完成更合理 |
|
||||
> | 6 | **ToolCallEnd 发出** | OpenAI 层不显式发出 `ToolCallEnd`,依赖汇聚算法 `finalize()` 兜底 | `ToolCallEnd` 保留给 Anthropic(`content_block_stop` 场景);`ContentBlockEnd` 已标 block 完成,`finalize` 时 `tool_call_args` 已累积完整 |
|
||||
> | 7 | **Refusal 处理** | 检测 `delta.refusal`,emit `ContentBlockStart(Refusal)` + `RefusalDelta` + `ContentBlockEnd` | OpenAI 特有字段,IR 已有 `ContentBlockType::Refusal` |
|
||||
> | 8 | **代码消重** | 删除 `stream.rs::parse_chunk_stream`(无人调用);`cycle.rs::submit_stream` 直接消费 `Result<StreamEvent>` 流 | 转换逻辑统一到 Provider 层,LlmCycle 只负责编排和 hook |
|
||||
>
|
||||
> ### 边界情况
|
||||
>
|
||||
> | 场景 | 处理方式 |
|
||||
> |------|---------|
|
||||
> | **`[DONE]` 行** | `SseByteStream` 层过滤,不传递到事件层(当前已有逻辑) |
|
||||
> | **delta 为空 + finish_reason** | 只处理 Phase 2,关闭当前 block 后 emit MessageComplete |
|
||||
> | **同一 chunk 含 delta.content + finish_reason** | Phase 1 先处理 delta 发 TextDelta,Phase 2 关闭 block 发 Complete |
|
||||
> | **同一 chunk 含 delta.tool_calls + finish_reason** | 先处理所有 tool calls(映射 + arguments 累积),再关 block |
|
||||
> | **usage 单独 chunk 下发** | 触发 Phase 2 但 finish_reason 为 None → 只发 CostUpdate,不发 MessageComplete |
|
||||
> | **网络断开** | reqwest 返回 Err → emit StreamEvent::Error,is_complete = true |
|
||||
> | **JSON 解析失败** | emit StreamEvent::Error,终止流(防御性处理) |
|
||||
>
|
||||
> ### 文件变更清单
|
||||
>
|
||||
> | 操作 | 文件 | 说明 |
|
||||
> |------|------|------|
|
||||
> | 新增 | `src/llm/provider/sse.rs` | 通用 SSE 字节解析层,含 `SseEvent` 结构体;从当前 `openai.rs` 的 `SseChunkStream` 提取核心逻辑并增强为支持 `event:` + `data:` 双行解析 + 空行分帧 |
|
||||
> | 新增 | `src/llm/provider/openai/stream.rs` | `OpenaiStreamToEvents` 转换器 + 状态机 |
|
||||
> | 修改 | `src/llm/provider/openai.rs` | `chat_stream` 返回 `Result<StreamEvent>`,组合 `SseByteStream` + `OpenaiStreamToEvents` |
|
||||
> | 修改 | `src/llm/provider.rs` | `LlmProvider::chat_stream` 签名改为 `Result<StreamEvent>` |
|
||||
> | 修改 | `src/llm/cycle.rs` | `submit_stream` 直接消费 `StreamEvent` 流,移除内联 chunk→event 转换 |
|
||||
> | 修改 | `src/llm/stream.rs` | 删除 `parse_chunk_stream`(无人调用) |
|
||||
>
|
||||
> 优先级:高(Phase 2 OpenAI Provider 重构的核心任务)
|
||||
|
||||
### 5.2 AnthropicProvider(Messages API)
|
||||
|
||||
```
|
||||
MessageRequest → Anthropic Messages Request:
|
||||
model → "anthropic-xxx"
|
||||
messages → [只包含 User / Assistant / Tool role]
|
||||
system → system (顶层参数)
|
||||
tools → tools (Anthropic 原生格式)
|
||||
tool_choice → tool_choice
|
||||
max_tokens → max_tokens
|
||||
temperature → temperature
|
||||
top_p → top_p
|
||||
stop_sequences → stop_sequences
|
||||
thinking → thinking (原生支持)
|
||||
extra.* → 忽略或不支持
|
||||
|
||||
Message → Anthropic Message:
|
||||
System { } → 跳过(已在 system 参数中)
|
||||
User { content } → { role: "user", content: to_anthropic_content(content) }
|
||||
Assistant { content } → { role: "assistant", content: to_anthropic_content(content) }
|
||||
Tool { content, tool_call_id } → { role: "user", content: [tool_result block] }
|
||||
|
||||
ContentBlock → Anthropic Content Block:
|
||||
Text { text } → { type: "text", text }
|
||||
Image { source } → { type: "image", source: { type: "base64", ... } }
|
||||
ToolUse { id, name, input } → { type: "tool_use", id, name, input }
|
||||
ToolResult { tool_use_id, content, is_error } → { type: "tool_result", ... }
|
||||
Thinking { text, signature } → { type: "thinking", thinking: text, signature }
|
||||
Audio / File / Extension → 忽略或不支持
|
||||
|
||||
Anthropic Response → MessageResponse:
|
||||
id → id
|
||||
model → model
|
||||
content → Message::Assistant { content: map_blocks(content) }
|
||||
usage.input_tokens → usage.prompt_tokens
|
||||
usage.output_tokens → usage.completion_tokens
|
||||
stop_reason → stop_reason
|
||||
```
|
||||
|
||||
> **✅ 推演结论(2026-06-18):**
|
||||
>
|
||||
> ### 设计思路:轻量分发器
|
||||
>
|
||||
> Anthropic 的流式事件**自带语义块边界**(`content_block_start/stop` 显式声明),
|
||||
> 不像 OpenAI 需从扁平 delta 推断。因此 Anthropic 状态机采用**轻量分发器**模式——
|
||||
> 每个事件自描述,状态机仅做顺序合法性校验,不做 block 边界推断或 index 映射。
|
||||
>
|
||||
> **与 OpenAI 流式转换的核心差异:**
|
||||
>
|
||||
> | 维度 | OpenaiStreamToEvents | AnthropicStreamToEvents |
|
||||
> |------|---------------------|------------------------|
|
||||
> | 核心复杂度 | 中——需从扁平 delta 推断 block 边界 | 低——事件自带语义边界 |
|
||||
> | 状态数 | 3(无活跃、Text、ToolUse) | 3(PendingStart、Active、Terminated) |
|
||||
> | index 管理 | `HashMap<openai_idx, global_idx>` 映射 | 直接使用 Anthropic index(1:1) |
|
||||
> | Block 边界推断 | 类型切换推断 | 原生 content_block_start/stop |
|
||||
> | Thinking 处理 | 无 | 通过 message_delta.thinking.signature |
|
||||
>
|
||||
> ### 架构分层
|
||||
>
|
||||
> 沿用 OpenAI 的两层架构,`SseByteStream` 共享(已增强为支持命名事件),
|
||||
> `AnthropicStreamToEvents` 在事件层按 `event_name` 分发:
|
||||
>
|
||||
> ```
|
||||
> bytes_stream()
|
||||
> │
|
||||
> ▼
|
||||
> SseByteStream [sse.rs — 通用层(已增强)]
|
||||
> │ 逐行分割、按空行分帧、解析 event:/data: 行前缀、过滤 "[DONE]"/ping
|
||||
> ▼
|
||||
> Stream<Item=Result<SseEvent, LlmError>> ← SseEvent { event_name: Option<String>, data }
|
||||
> │
|
||||
> ▼
|
||||
> AnthropicStreamToEvents [anthropic/stream.rs — Anthropic 特定]
|
||||
> │ 按 SseEvent.event_name 分发事件类型 → 直接映射
|
||||
> ▼
|
||||
> Stream<Item=Result<StreamEvent, LlmError>> ← IR 语义事件
|
||||
> ```
|
||||
>
|
||||
> ### 结构体设计
|
||||
>
|
||||
> ```rust
|
||||
> pub struct AnthropicStreamToEvents<S> {
|
||||
> inner: S, // Stream<Item=Result<SseEvent, LlmError>>
|
||||
> state: AnthropicStreamState, // 仅做顺序校验
|
||||
> usage: PartialUsage, // 从 message_start + message_delta 累积
|
||||
> pending_thinking_signature: Option<String>, // message_delta 中到达
|
||||
> }
|
||||
>
|
||||
> /// 状态机状态 —— 仅做合法性校验,事件本身已自描述。
|
||||
> enum AnthropicStreamState {
|
||||
> PendingStart, // 等待 message_start
|
||||
> Active, // 已收到 message_start,正在接收 content block 事件
|
||||
> Terminated, // 已终结,不再处理后续事件
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> 状态足够简单的原因:Anthropic 每个事件自带完整语义——
|
||||
> - `content_block_delta` 自带 `index`,不需要追踪"当前活跃 block"
|
||||
> - `content_block_stop` 自带 `index`,不需要追踪"当前关闭哪个"
|
||||
> - 状态只拒绝非法到达顺序的事件
|
||||
>
|
||||
> ### 事件映射表(完整)
|
||||
>
|
||||
> | Anthropic SSE 事件 | 产出的 StreamEvent | 说明 |
|
||||
> |-------------------|-------------------|------|
|
||||
> | `message_start` | `MessageStart { id, model }`<br>`CostUpdate { prompt_tokens }` | 从 `message.usage.input_tokens` 提取 |
|
||||
> | `ping` | —(忽略) | Anthropic 心跳 |
|
||||
> | `content_block_start`<br>`block.type="text"` | `ContentBlockStart { index, Text }` | |
|
||||
> | `content_block_start`<br>`block.type="tool_use"` | `ContentBlockStart { index, ToolUse { id, name } }` | block 自带 id + name |
|
||||
> | `content_block_start`<br>`block.type="thinking"` | `ContentBlockStart { index, Thinking }` | |
|
||||
> | `content_block_delta`<br>`delta.type="text_delta"` | `TextDelta { text: delta.text }` | |
|
||||
> | `content_block_delta`<br>`delta.type="thinking_delta"` | `ThinkingDelta { text: delta.thinking }` | |
|
||||
> | `content_block_delta`<br>`delta.type="input_json_delta"` | `ToolCallArgumentsDelta { index, arguments: delta.partial_json }` | index 透传 |
|
||||
> | `content_block_stop` | `ContentBlockEnd { index }` | |
|
||||
> | `message_delta` | `CostUpdate { completion_tokens }`<br>`MessageComplete { stop_reason, thinking_signature }` | signature 从 `delta.thinking?.signature` 提取 |
|
||||
> | `message_stop` | —(流结束标记,不产事件) | 仅切状态到 Terminated |
|
||||
> | `error` | `Error { message: error.message }` | 切状态到 Terminated |
|
||||
>
|
||||
> ### 状态转移表
|
||||
>
|
||||
> **当前状态:`PendingStart`**
|
||||
>
|
||||
> | 输入事件 | 输出 StreamEvent | 新状态 | 备注 |
|
||||
> |---------|----------------|--------|------|
|
||||
> | `message_start` | → `MessageStart` + `CostUpdate` | `Active` | ✅ 正常流程入口 |
|
||||
> | 其他任何事件 | — ⚠ warn | 不变 | 防御性跳过 |
|
||||
> | `error` | → `Error` | `Terminated` | ❌ 错误路径 |
|
||||
>
|
||||
> **当前状态:`Active`**
|
||||
>
|
||||
> | 输入事件 | 输出 StreamEvent | 新状态 | 备注 |
|
||||
> |---------|----------------|--------|------|
|
||||
> | `ping` | —(忽略) | `Active` | ✅ 心跳 |
|
||||
> | `content_block_start` | → `ContentBlockStart` | `Active` | ✅ 新 block 开始 |
|
||||
> | `content_block_delta` | → `TextDelta` / `ThinkingDelta` / `ToolCallArgumentsDelta` | `Active` | ✅ 块内增量 |
|
||||
> | `content_block_stop` | → `ContentBlockEnd` | `Active` | ✅ block 结束 |
|
||||
> | `message_delta` | → `CostUpdate` + `MessageComplete` | `Active` | ✅ 消息完成信息 |
|
||||
> | `message_stop` | — | `Terminated` | ✅ 正常结束 |
|
||||
> | `error` | → `Error` | `Terminated` | ❌ 错误路径 |
|
||||
> | 未知 delta type | — ⚠ warn | `Active` | 防御性忽略 |
|
||||
>
|
||||
> **当前状态:`Terminated`**
|
||||
>
|
||||
> | 输入事件 | 输出 StreamEvent | 新状态 | 备注 |
|
||||
> |---------|----------------|--------|------|
|
||||
> | 任何事件 | — ⚠ warn "已完结" | `Terminated` | 防御性忽略 |
|
||||
>
|
||||
> ### 各议题结论
|
||||
>
|
||||
> | # | 议题 | 结论 | 理由 |
|
||||
> |---|------|------|------|
|
||||
> | 1 | **状态机模式** | 轻量分发器(3 状态),不做 block 推断 | Anthropic 事件自带语义边界,不需要像 OpenAI 那样推断 |
|
||||
> | 2 | **index 映射** | 无需映射,直接使用 Anthropic index(1:1) | Anthropic 的 `index` 是全局 content block 序号,与 IR 完全对齐 |
|
||||
> | 3 | **Thinking signature** | ✅ 已推演(方案 C):message_delta 提取 → MessageComplete 传递 → finalize 回填 | 已在 [9f-edge-cases.md](9f-edge-cases.md#92-thinking-的端到端流程) 中完成推演 |
|
||||
> | 4 | **SSE 字节解析复用** | 通过增强的 `SseByteStream`(支持 `event:` 行解析)与 OpenAI 共享通用层 | 同一字节协议,仅在事件解析层差异化 |
|
||||
> | 5 | **Usage 分次到达** | `message_start` 提取 `input_tokens`,`message_delta` 提取 `output_tokens`,`PartialUsage` 字段级合并 | 与 StreamEvent 汇聚算法兼容 |
|
||||
> | 6 | **错误恢复** | `error` 事件 → `StreamEvent::Error` + state=Terminated,后续事件全部忽略 | 不同于 OpenAI 的 HTTP 错误路径,但 IR 层统一为 `StreamEvent::Error` |
|
||||
> | 7 | **ContentBlockType::ToolUse 嵌入** | `content_block_start` 中的 `id` + `name` 直接填入 `ContentBlockType::ToolUse { id, name }` | 与已推演的 ContentBlockStart 设计一致 |
|
||||
> | 8 | **block 切换** | content_block_stop(index) → content_block_start(index') 自然过渡,状态机不追踪 | 事件本身已确定边界,无需状态机参与 |
|
||||
>
|
||||
> ### 与已推演设计的对齐
|
||||
>
|
||||
> **与 Thinking signature 方案的对齐(方案 C):**
|
||||
> ```
|
||||
> content_block_start { type: "thinking" }
|
||||
> → ContentBlockStart(Thinking) ← signature 未到达
|
||||
> content_block_delta { thinking_delta }
|
||||
> → ThinkingDelta(...)
|
||||
> content_block_stop
|
||||
> → ContentBlockEnd ← signature 仍未到达
|
||||
> message_delta { delta.thinking.signature = "0x..." }
|
||||
> → MessageComplete { thinking_signature: Some("0x...") }
|
||||
> → finalize() 回填到最后一个 Thinking block
|
||||
> ```
|
||||
>
|
||||
> **与 StreamEvent 汇聚算法的对齐:**
|
||||
> 本状态机产出的 StreamEvent 可直接喂入已推演的 `PartialMessageResponse::apply_to()` 算法。
|
||||
> 上述事件序列在汇聚算法中:
|
||||
> 1. `MessageStart` → state.id/model
|
||||
> 2. `ContentBlockStart/Delta/End` → `BTreeMap` 按 index 分桶组装
|
||||
> 3. `CostUpdate` → PartialUsage 字段级合并
|
||||
> 4. `MessageComplete` → stop_reason + thinking_signature + is_complete
|
||||
> 5. `finalize()` → 回填 signature → `MessageResponse`
|
||||
>
|
||||
> ### 边界情况
|
||||
>
|
||||
> | 场景 | 处理方式 |
|
||||
> |------|---------|
|
||||
> | **message_start 前收 content_block_start** | ⚠ warn 忽略,不发射事件 |
|
||||
> | **message_delta 前收 message_stop** | ⚠ warn,强制 Terminated |
|
||||
> | **content_block_stop 无对应 start** | ⚠ warn 忽略(index 无对应) |
|
||||
> | **index 跳跃(0 → 2)** | 正常处理,index 透传,content 数组留空位 |
|
||||
> | **delta index 与最新 start 不匹配** | ⚠ warn,仍然按 delta 自带 index 处理 |
|
||||
> | **message_delta 缺 thinking.signature** | `thinking_signature`: None |
|
||||
> | **message_delta 缺 usage** | 不发射 CostUpdate,仅发射 MessageComplete |
|
||||
> | **两次 message_delta** | ⚠ warn,第二次忽略 |
|
||||
> | **content_block_stop 后同 index 又来 delta** | ⚠ warn 忽略 |
|
||||
> | **ping 事件** | 忽略,不发射任何事件 |
|
||||
> | **网络断开** | emit `Error`,state = Terminated |
|
||||
> | **JSON 解析失败** | emit `Error`,state = Terminated |
|
||||
> | **stop_reason 映射** | `"end_turn"`→`Stop`, `"max_tokens"`→`MaxTokens`, `"tool_use"`→`ToolUse`, `"stop_sequence"`→`StopSequence`, 其他→`Other` |
|
||||
>
|
||||
> ### 文件变更清单
|
||||
>
|
||||
> | 操作 | 文件 | 说明 |
|
||||
> |------|------|------|
|
||||
> | 新增 | `src/llm/provider/anthropic.rs` | AnthropicProvider 实现(chat + chat_stream) |
|
||||
> | 新增 | `src/llm/provider/anthropic/` | 目录,按 2018 版风格组织 |
|
||||
> | 新增 | `src/llm/provider/anthropic/stream.rs` | `AnthropicStreamToEvents` 转换器 + 事件分发器 |
|
||||
> | 增强 | `src/llm/provider/sse.rs` | `SseByteStream` 增强为支持 `event:` 行 + 空行分帧(已在 §5.1 中描述) |
|
||||
> | 修改 | `src/llm/provider.rs` | `ProviderType` 增加 `Anthropic`;`create_provider` 增加分支 |
|
||||
> | 无变更 | `src/llm/cycle.rs` | StreamEvent 事件序列格式不变,无需改动 |
|
||||
> | 无变更 | `src/llm/stream.rs` | 汇聚算法 `apply_to/finalize` 不变 |
|
||||
>
|
||||
> 优先级:高(Phase 4 AnthropicProvider 实现的前提条件)
|
||||
|
||||
### 5.3 OpenAI Response API(草案)
|
||||
|
||||
```rust
|
||||
// 核心思路:Response API 的 "input as messages" 模式映射到 IR
|
||||
|
||||
// MessageRequest → Response API Request:
|
||||
// model → model
|
||||
// messages → input (作为 multi-turn conversation)
|
||||
// tools → tools (tool 定义)
|
||||
// extra.previous_response_id → previous_response_id
|
||||
// extra.built_in_tools → tools (内置工具配置)
|
||||
// extra.instructions → instructions
|
||||
|
||||
// Response → MessageResponse:
|
||||
// output[0] (type="message") → message
|
||||
// output[1..n] → ContentBlock::Extension
|
||||
|
||||
// 内置工具(web_search 等)需要额外的能力 trait:
|
||||
#[async_trait]
|
||||
pub trait BuiltInToolsCapable: LlmProvider {
|
||||
fn available_builtin_tools(&self) -> Vec<(&'static str, serde_json::Value)>;
|
||||
async fn execute_builtin_tool(&self, name: &str, input: Value) -> Result<Value, LlmError>;
|
||||
}
|
||||
```
|
||||
|
||||
> **🔄 待深入推演:OpenAI Response API 完整映射表**
|
||||
> 当前 §5.3 只有注释级别的草案,缺乏完整映射。
|
||||
> **需要推演:**
|
||||
> 1. `input` 字段支持三种模式:字符串、`Vec<Message>`(IR 消息列表)、`response_id`(前序响应)。
|
||||
> IR 的 `MessageRequest.messages` 能否同时覆盖这三种?`previous_response_id` 通过 extra 传递后,
|
||||
> `messages` 是否还需要存在?
|
||||
> 2. `output` 中的每种类型(`message`, `web_search_call`, `file_search_call`,
|
||||
> `code_interpreter_call`, `computer_call`, `reasoning`)如何映射到 `ContentBlock`?
|
||||
> 当前 `Extension` 逃生舱能否承载?是否需要新增 ContentBlock variant?
|
||||
> 3. Response API 的 `tools` 参数除了定义 function 工具外,还支持配置内置工具的参数
|
||||
> (如 `web_search` 的 `search_context_size`)。`ToolDefinition` 能否表达?
|
||||
> 4. Streaming 差异:Response API 的流式事件类型(`response.output_items.added` 等)与 Chat API
|
||||
> 完全不同,如何映射到 StreamEvent?
|
||||
> 优先级:低(Phase 4 之后的远期计划)
|
||||
|
||||
### 5.4 DeepSeek / Qwen 等兼容 Provider 的落地策略
|
||||
|
||||
当前 `ProviderType` 枚举中已列出 DeepSeek 和 Qwen,但实现均标记为 `unimplemented!()`。
|
||||
|
||||
> **🔄 待深入推演:DeepSeek/Qwen Provider 的落地策略**
|
||||
> 这些 Provider 通常兼容 OpenAI Chat API 格式。在新 IR 设计下,有两种落地路径:
|
||||
> **路径 A — 复用 OpenaiProvider(推荐):**
|
||||
> 在 `ProviderRegistry` 中注册时直接使用 `OpenaiProvider::new(base_url, api_key, model)`,
|
||||
> 仅需换 base_url。适合 DeepSeek、Qwen、Groq、Azure 等 API 格式与 OpenAI 完全一致的场景。
|
||||
> 此时 `ProviderType` 枚举可能不再需要(`OpenaiProvider` 通过 `capabilities().provider_name`
|
||||
> 标识自身为 "openai-compatible" 或具体名称)。
|
||||
> **路径 B — 独立 Provider 实现:**
|
||||
> 如果某 Provider 在 OpenAI 格式基础上做了扩展/修改(如自定义参数、不同的错误格式),
|
||||
> 可独立实现 `LlmProvider` trait,复用 IR 类型,仅在 IR ↔ 原生格式映射层做差异处理。
|
||||
> **需要推演:**
|
||||
> 1. `ProviderType` 枚举在新的注册体系中是否还有存在的必要(工厂函数模式 vs 直接 new Provider)
|
||||
> 2. `OpenaiProvider` 是否要重命名为更通用的 `OpenaiCompatibleProvider`?
|
||||
> 优先级:低(Phase 4 后梳理)
|
||||
@@ -0,0 +1,435 @@
|
||||
# LlmCycle 改造与上层适配
|
||||
|
||||
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §6 LlmCycle 改造 + §7 对上层的影响 + §8 兼容性策略。
|
||||
>
|
||||
> **相关文件:**
|
||||
> - [9b-ir-type-system.md](9b-ir-type-system.md) — IR 类型定义(Message、MessageRequest、ContentBlock 等)
|
||||
> - [9c-llm-provider-trait.md](9c-llm-provider-trait.md) — LlmProvider trait(chat、chat_stream 签名)
|
||||
> - [9d-provider-implementations.md](9d-provider-implementations.md) — Provider 实现策略(system prompt 处理方式)
|
||||
> - [9f-edge-cases.md](9f-edge-cases.md) — 边界情况(tool 定义传递路径等)
|
||||
|
||||
## 6. LlmCycle 改造
|
||||
|
||||
### 6.1 内部存储变化
|
||||
|
||||
```rust
|
||||
pub struct LlmCycle {
|
||||
provider: Arc<dyn LlmProvider>,
|
||||
config: CycleConfig,
|
||||
usage: CostTracker,
|
||||
messages: Vec<Message>, // ← 原 Vec<OpenaiChatMessage>
|
||||
system_prompt: Option<String>,
|
||||
hook_executor: Option<Arc<HookExecutor>>,
|
||||
compact_config: Option<CompactConfig>,
|
||||
compact_state: CompactState,
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 build_request → 新签名
|
||||
|
||||
```rust
|
||||
fn build_request(&self, tools: &[ToolDefinition]) -> MessageRequest {
|
||||
let mut messages = self.messages.clone();
|
||||
|
||||
if let Some(sys_prompt) = &self.system_prompt {
|
||||
// `system_prompt` 是权威来源:显式设置时替换 messages 中的任何 System
|
||||
messages.retain(|m| !matches!(m, Message::System { .. }));
|
||||
messages.insert(0, Message::system(sys_prompt));
|
||||
}
|
||||
// 如果 `system_prompt` 为 None,messages 中的 System 保持原样,
|
||||
// 由各 Provider 在 IR→原生映射层各自处理
|
||||
|
||||
MessageRequest {
|
||||
model: self.config.model.clone(),
|
||||
messages,
|
||||
tools: tools.to_vec(),
|
||||
tool_choice: ToolChoice::Auto,
|
||||
max_tokens: self.config.max_tokens,
|
||||
temperature: self.config.temperature,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **✅ 推演结论(2026-06-22):方案 D —— 移除 `system` 字段,IR 中只留一个入口**
|
||||
>
|
||||
> **决策:** 从 `MessageRequest` 中移除 `system: Option<String>` 字段,统一通过 `messages: Vec<Message>`
|
||||
> 中的 `Message::System { content }` 表达系统提示。各 Provider 在 IR→原生 映射层自行处理差异。
|
||||
>
|
||||
> **理由:**
|
||||
> 1. **与整套 IR 设计的理念一致**——IR 只描述"有什么",不关心"怎么传"。ToolResult 嵌套约束、
|
||||
> Thinking 端到端、StreamEvent 汇总等问题的解决方向都是"Provider 层负责格式差异",
|
||||
> system prompt 的双重入口是同一个问题,应用同样的原则。
|
||||
> 2. **消除歧义的最佳方式是砍掉一个入口**——两个入口导致的"谁优先"问题在类型层面就解决了,
|
||||
> 不需要运行时规则。
|
||||
> 3. **每个 Provider 做自己的转换本来就是 Provider 层的职责**——OpenaiProvider 几乎零成本
|
||||
> (System 直接序列化为 role=`system`),AnthropicProvider 做提取+移除(约 10 行代码),
|
||||
> DeepSeek/Qwen 同 OpenAI。没有 Provider 需要额外做反向工作。
|
||||
>
|
||||
> **具体做法:**
|
||||
>
|
||||
> **① `MessageRequest`([9b-ir-type-system.md](9b-ir-type-system.md#36-messagerequest--统一请求))**
|
||||
> 删除 `system: Option<String>` 字段。`messages: Vec<Message>` 是系统提示的唯一载体。
|
||||
>
|
||||
> **② `LlmCycle::build_request`(本节上方代码)**
|
||||
> 增加"替换"语义:`self.system_prompt` 设置时,先 `retain` 移除 messages 中已有的所有
|
||||
> `Message::System`,再插入新的。确保 `system_prompt` 作为权威来源。
|
||||
>
|
||||
> **③ `OpenaiProvider::ir_to_native`**
|
||||
> 零改动。`Message::System { content }` 直接映射为 `role: "system"`(或 `role: "developer"`)。
|
||||
>
|
||||
> **④ `AnthropicProvider::ir_to_native`**([9d-provider-implementations.md](9d-provider-implementations.md))
|
||||
> 新增提取逻辑:
|
||||
> ```rust
|
||||
> // 1. 遍历 messages,收集所有 System 的纯文本内容
|
||||
> // 2. 若有多个 System,合并为一个字符串(Anthropic 只接受一个)
|
||||
> // 3. 设置 Anthropic 请求的顶层 `system` 参数
|
||||
> // 4. 从 messages 中移除所有 System 消息
|
||||
> // 5. 非文本 ContentBlock 静默丢弃 + warn! log
|
||||
> ```
|
||||
>
|
||||
> **边界情况处理:**
|
||||
> | `self.system_prompt` | messages 中已有的 System | 结果 |
|
||||
> |---|---|---|
|
||||
> | `None` | 无 System | messages 不变 |
|
||||
> | `None` | `System("B")` | 保留,Provider 层处理 |
|
||||
> | `Some("A")` | 无 System | 插入 System("A") |
|
||||
> | `Some("A")` | `System("B")` | **移除 B,插入 A**(显式设置优先) |
|
||||
> | `Some("A")` | 多个 System("B1"), ("B2") | **移除所有,插入 A** |
|
||||
>
|
||||
> **何时实现:** Phase 2-3 实现 AnthropicProvider 时同步完成。
|
||||
> **影响范围:** `MessageRequest` 删除一个字段 + `build_request` 增 2 行 `retain` + AnthropicProvider 增约 10 行提取逻辑。
|
||||
|
||||
主要变化:
|
||||
- 返回类型 `MessageRequest`(非 `ChatRequest`)
|
||||
- `tools` 直接传入,不再需要 `OpenaiTool::Function` 包装
|
||||
- `..Default::default()` 填充剩余字段
|
||||
|
||||
### 6.3 submit_with_tools —— 新的 tool 循环逻辑
|
||||
|
||||
```rust
|
||||
pub async fn submit_with_tools(
|
||||
&mut self,
|
||||
prompt: String,
|
||||
registry: &ToolRegistry,
|
||||
) -> Result<MessageResponse, LlmError> {
|
||||
let tools = registry.definitions();
|
||||
let max_turns = self.config.max_tool_turns.unwrap_or(10);
|
||||
|
||||
self.messages.push(Message::user(prompt));
|
||||
self.maybe_compact();
|
||||
|
||||
let mut turn = 0;
|
||||
loop {
|
||||
turn += 1;
|
||||
if turn > max_turns { /* error */ }
|
||||
|
||||
let response = self.submit_request(&tools).await?;
|
||||
|
||||
// 从 content blocks 中检测 ToolUse(不再需要额外函数)
|
||||
let tool_uses: Vec<&ContentBlock> = response.message.content.iter()
|
||||
.filter_map(|b| if let ContentBlock::ToolUse { .. } = b { Some(b) } else { None })
|
||||
.collect();
|
||||
let should_execute = matches!(response.stop_reason, StopReason::ToolUse) && !tool_uses.is_empty();
|
||||
|
||||
self.messages.push(response.message.clone());
|
||||
if !should_execute { return Ok(response); }
|
||||
|
||||
let calls: Vec<(String, Value)> = tool_uses.iter()
|
||||
.map(|b| match b {
|
||||
ContentBlock::ToolUse { name, input, .. } => (name.clone(), input.clone()),
|
||||
_ => unreachable!(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let results = registry.invoke_all(calls, self.config.tool_timeout_secs).await;
|
||||
for result in results {
|
||||
let content = /* 序列化/截断逻辑 ... */;
|
||||
self.messages.push(Message::tool_result(result.tool_name, content));
|
||||
}
|
||||
self.maybe_compact();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
关键简化:
|
||||
- 不再需要 `has_tool_calls_in_message()` 和 `extract_tool_calls_from_message()` 辅助函数
|
||||
- 仅仅遍历 `content` 即可发现所有 `ToolUse` block
|
||||
- 逻辑对 Provider 类型**完全透明**
|
||||
|
||||
### 6.4 submit_stream —— Provider 直接返回 StreamEvent
|
||||
|
||||
```rust
|
||||
pub async fn submit_stream(
|
||||
&mut self,
|
||||
prompt: String,
|
||||
tools: Vec<ToolDefinition>,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>, LlmError> {
|
||||
self.messages.push(Message::user(prompt));
|
||||
let request = self.build_request(&tools);
|
||||
|
||||
// Provider 直接返回 StreamEvent 流,无需二次转换
|
||||
let stream = self.provider.chat_stream(request).await?;
|
||||
|
||||
// 如果需要,可在 LlmCycle 层叠加额外处理
|
||||
// (当前 pipeline:stream → hook 触发 → 直接返回)
|
||||
Ok(stream)
|
||||
}
|
||||
```
|
||||
|
||||
不再需要 `parse_chunk_stream()`(`stream.rs` 中的 `ChunkToEventStream` 可以移除)。
|
||||
|
||||
### 6.5 HookContext 引用调整
|
||||
|
||||
```rust
|
||||
pub struct HookContext<'a> {
|
||||
pub request: Option<&'a MessageRequest>, // ← 原 &'a ChatRequest
|
||||
pub error: Option<&'a LlmError>,
|
||||
pub attempt: u32,
|
||||
pub turn_index: Option<u32>,
|
||||
pub plan_step_index: Option<usize>,
|
||||
}
|
||||
```
|
||||
|
||||
### 6.6 compact 逻辑调整
|
||||
|
||||
`compact.rs` 中的 `estimate_message_tokens()`、`microcompact()` 需要从 `Vec<OpenaiChatMessage>` 改为 `Vec<Message>`,核心逻辑不变。
|
||||
|
||||
> **✅ 推演结论(2026-06-22):`microcompact` 压缩对象 + 估算策略 + Thinking 压缩策略**
|
||||
>
|
||||
> 推演覆盖三个议题(压缩对象、token 估算、Thinking 压缩),并引入**大小 × 重要性二维决策框架**作为统一的压缩决策模型。
|
||||
>
|
||||
> ---
|
||||
>
|
||||
> ### 议题 1:`microcompact` 压缩哪个?
|
||||
>
|
||||
> **决策:方案 B —— 同时压缩 `Message::Tool` 和 `Message::Assistant` 中的 `ContentBlock::ToolResult`。**
|
||||
>
|
||||
> **理由:**
|
||||
> 1. 两者存储的是相同语义的数据(工具执行结果),组织结构不同但语义等价。只压缩一种会漏掉另一种。
|
||||
> 2. LlmCycle 工具循环主路径产生 `Message::Tool`,但 Anthropic 格式反序列化后可能出现 `ToolResult` 嵌入在 Assistant 中。未来 `AnthropicProvider` 实现后后者场景会增加。
|
||||
> 3. `ContentBlock::ToolUse` **不压缩**——id/name/input 是工具调用的必需元数据,体积小(通常 < 1K),压缩反而破坏后续工具重放。
|
||||
>
|
||||
> 无需优先级排序(两者不在同一条消息中,不存在先后问题):
|
||||
> - `Message::Tool`:独立的 Tool 消息,压缩其整个 `content` 字段
|
||||
> - `Message::Assistant` 中的 `ToolResult`:遍历 content 找到所有 `ToolResult` block,压缩其内部的 `content`
|
||||
>
|
||||
> **实现示意:**
|
||||
>
|
||||
> ```rust
|
||||
> pub fn microcompact(messages: &mut [Message], keep_recent: usize, config: &CompactConfig) -> u32 {
|
||||
> if messages.len() <= keep_recent { return 0; }
|
||||
> let prune_start = messages.len() - keep_recent;
|
||||
> let mut freed_tokens: u32 = 0;
|
||||
>
|
||||
> // Phase 1:估算压缩前 token 数
|
||||
> for msg in &messages[..prune_start] {
|
||||
> freed_tokens += estimate_compressible_tokens(msg, config);
|
||||
> }
|
||||
>
|
||||
> // Phase 2:执行压缩
|
||||
> for msg in &mut messages[..prune_start] {
|
||||
> apply_compact_action(msg, config);
|
||||
> }
|
||||
>
|
||||
> freed_tokens
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> ---
|
||||
>
|
||||
> ### 议题 2:`estimate_message_tokens` 的估算策略
|
||||
>
|
||||
> **决策:方案 B —— 按 ContentBlock 类型分档估算,非 Text block 使用差异化经验值。**
|
||||
>
|
||||
> **理由:**
|
||||
> - compact 是启发式压缩,不需要精确 token 计数(最终由 Provider 的 tokenizer 精确计算),方案 C 的精度收益不值得这个复杂度
|
||||
> - 方案 A(统一固定 50)精度太低——Thinking block 可长达几万 token,固定 50 会导致严重低估
|
||||
>
|
||||
> **分档表:**
|
||||
>
|
||||
> | `ContentBlock` 类型 | 估算方式 | 说明 |
|
||||
> |---|---|------|
|
||||
> | `Text { text }` | `(len * 4).div_ceil(3)` | 保持当前字符估算逻辑 |
|
||||
> | `Image { .. }` | 固定 85 | OpenAI 低分辨率固定定价 |
|
||||
> | `Audio { .. }` | 固定 100 | 音频通常大于图片 |
|
||||
> | `File { source }` | 50 + 文件名文本估算 | 元数据开销 + 文件名 |
|
||||
> | `ToolUse { name, input }` | `estimate_text(name) + estimate_text(input.to_string())` | name + JSON 参数 |
|
||||
> | `ToolResult { content }` | 递归估算内部 content block 列表 | 递归到叶子节点 |
|
||||
> | `Thinking { text }` | `estimate_text(text)` | 按文本估算(内容往往很长) |
|
||||
> | `Extension { data }` | `estimate_text(data.to_string())` | 逃生舱,按 JSON 大小估算 |
|
||||
>
|
||||
> ---
|
||||
>
|
||||
> ### 议题 3:Thinking 是否在压缩范围内
|
||||
>
|
||||
> **决策:方案 A —— 默认不压缩 Thinking,`CompactConfig` 增加 `compact_thinking: bool` 选项。**
|
||||
>
|
||||
> **理由:**
|
||||
> 1. Thinking 不同于 ToolResult——ToolResult 是"已执行的事实结果",压缩后语义无损;Thinking 是"推理过程",压缩后可能丢失决策上下文
|
||||
> 2. 但 thinking 内容确实可能很长(尤其 Anthropic extended thinking 模式),不应完全放弃压缩能力
|
||||
> 3. 提供选项让用户根据场景自行决定:任务型 Agent(可压) vs 推理密集型 Agent(不压)
|
||||
>
|
||||
> ```rust
|
||||
> #[derive(Debug, Clone)]
|
||||
> pub struct CompactConfig {
|
||||
> pub context_window: u32,
|
||||
> pub reserved_tokens: u32,
|
||||
> pub keep_recent: usize,
|
||||
> /// 是否压缩 Thinking 内容。默认 false。
|
||||
> pub compact_thinking: bool,
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> 压缩时将 Thinking block 的 `text` 替换为 `"[pruned thinking]"` 以区别于 ToolResult 的 `"[pruned]"`。
|
||||
>
|
||||
> ---
|
||||
>
|
||||
> ### ⭐ 新推演维度:大小 × 重要性二维决策框架
|
||||
>
|
||||
> 上述三个议题各自独立解决,但**压缩强度的选择**需要融合两个互补指标:
|
||||
>
|
||||
> | 维度 | 解决什么问题 | 来源 | 性质 |
|
||||
> |------|------------|------|------|
|
||||
> | **大小** | "这东西有多重?"——为释放空间值不值得动手 | 纯计算(字符数) | 精确 |
|
||||
> | **重要性** | "动了之后损失什么?"——压缩对后续推理的影响 | 多来源综合 | 语义 |
|
||||
>
|
||||
> #### 二维决策矩阵
|
||||
>
|
||||
> ```
|
||||
> 重 要 性
|
||||
> 低 (Action) 高 (Factual)
|
||||
> ┌────────────────────────────────
|
||||
> 小 │ 跳过 跳过
|
||||
> │ (< 200 char, 重要但小,不值得为它费劲
|
||||
> 大 | 不值得)
|
||||
> | │
|
||||
> 小 │ 截断保留开头 结构化摘要
|
||||
> │ ("[前200字]...") (保留 JSON 骨架 + 截断数据体)
|
||||
> │
|
||||
> 大 │ 替换 [pruned] 截断优先 → 元数据保留
|
||||
> │ (零价值 + 大体积) (实在太大才 [pruned])
|
||||
> ```
|
||||
>
|
||||
> #### 重要性的四个来源
|
||||
>
|
||||
> | 来源 | 时机 | 输入 | 输出 |
|
||||
> |------|------|------|------|
|
||||
> | **① 工具注册声明** | 编译/初始化时 | `ToolDefinition.result_semantic: ResultSemantic` | 基础分:Factual=+1, Action=-1, Mixed=0 |
|
||||
> | **② 内容模式启发式** | compact 触发时 | 检测结果中的 JSON 特征 | 加分:列表数据+1,状态确认-1,错误结果→跳过 |
|
||||
> | **③ 对话轮次衰退** | compact 触发时 | 距离最后一次被引用的轮次 | 每超 K 轮 → -0.5 |
|
||||
> | **④ 后续引用跟踪** | 事后(为下次积累) | Assistant 内容与结果的关键词重叠 | 更新引用时间戳 |
|
||||
>
|
||||
> ```rust
|
||||
> /// 工具注册时声明结果的语义类型
|
||||
> #[derive(Debug, Clone, Copy)]
|
||||
> pub enum ResultSemantic {
|
||||
> /// 结果是"事实依据"——后续对话可能反复引用(搜索、文档查询)
|
||||
> Factual,
|
||||
> /// 结果是"一次性动作确认"——执行完就过了(发送邮件、创建记录)
|
||||
> Action,
|
||||
> /// 兼具两者特征 / 不确定(默认)
|
||||
> Mixed,
|
||||
> }
|
||||
>
|
||||
> /// 压缩动作 —— 由 [大小, 重要性] 综合决定
|
||||
> pub enum CompactAction {
|
||||
> /// 不压缩
|
||||
> Skip,
|
||||
> /// 截断保留前 N 字符
|
||||
> Truncate { keep: usize },
|
||||
> /// 尝试保留 JSON 结构 + 截断数据体
|
||||
> TruncateStructured { keep: usize },
|
||||
> /// 替换为 [pruned] 或 [pruned thinking]
|
||||
> Replace,
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> #### 综合评分 → 动作映射
|
||||
>
|
||||
> ```rust
|
||||
> fn decide_strategy(size: usize, score: i8) -> CompactAction {
|
||||
> match (size, score) {
|
||||
> (0..=200, _) => CompactAction::Skip, // 太小不压
|
||||
> (201..=2000, s) if s >= 1 => CompactAction::Skip, // 重要 + 中等 → 保留
|
||||
> (201..=2000, _) => CompactAction::Truncate { keep: 200 },
|
||||
> (2001.., s) if s >= 2 => CompactAction::TruncateStructured { keep: 500 },
|
||||
> (2001.., s) if s >= 1 => CompactAction::Truncate { keep: 200 },
|
||||
> (2001.., _) => CompactAction::Replace, // 大 + 不重要 → 全换
|
||||
> }
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **何时实现:** Phase 3 适配 LlmCycle 时同步修改 compact.rs。
|
||||
> **影响范围:** `compact.rs` 全部重写(保留函数签名,内部逻辑适配 IR)+ `CompactConfig` 变更 + 需要在 `ToolDefinition` 中新增 `result_semantic` 字段(Phase 3)+ `llm-cycle.rs` 中 compact 调用点的接口适配。
|
||||
|
||||
---
|
||||
|
||||
## 7. 对上层的影响
|
||||
|
||||
### 7.1 ProviderRegistry —— 零改动
|
||||
|
||||
```rust
|
||||
pub struct ProviderRegistry {
|
||||
providers: HashMap<String, Box<dyn LlmProvider>>,
|
||||
default_name: Option<String>,
|
||||
}
|
||||
// 所有方法逻辑不变
|
||||
```
|
||||
|
||||
### 7.2 AgentSession —— 极小影响
|
||||
|
||||
```rust
|
||||
// 当前
|
||||
let response: ChatResponse = cycle.submit_with_tools(input, ®istry).await?;
|
||||
self.cost_so_far.add(&response.usage); // Usage 类型不变
|
||||
|
||||
// 新
|
||||
let response: MessageResponse = cycle.submit_with_tools(input, ®istry).await?;
|
||||
self.cost_so_far.add(&response.usage); // 仍然可用——Usage 类型一致
|
||||
```
|
||||
|
||||
`response.usage` 类型不变(仍是 `Usage`),`response.text()` 替代了 `response.message.content` 的文本提取。
|
||||
|
||||
### 7.3 Agent trait —— 零改动
|
||||
|
||||
`Agent::tool_definitions()` 返回 `Vec<ToolDefinition>`,类型不变。
|
||||
|
||||
### 7.4 PromptComposer —— 内部类型替换
|
||||
|
||||
`PromptComposer` 内部存储从 `Vec<OpenaiChatMessage>` 改为 `Vec<Message>`,公共方法签名不变(返回 `Message` 类型)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 兼容性策略
|
||||
|
||||
### 8.1 From trait 双向转换
|
||||
|
||||
提供新旧类型之间的转换,平滑迁移:
|
||||
|
||||
```rust
|
||||
// IR → 旧类型(兼容层)
|
||||
impl From<ChatResponse> for MessageResponse { ... }
|
||||
impl From<MessageResponse> for ChatResponse { ... }
|
||||
impl From<OpenaiChatMessage> for Message { ... }
|
||||
impl From<Message> for OpenaiChatMessage { ... }
|
||||
impl From<ChatRequest> for MessageRequest { ... }
|
||||
impl From<MessageRequest> for ChatRequest { ... }
|
||||
```
|
||||
|
||||
### 8.2 LlmCycle 兼容 getter
|
||||
|
||||
```rust
|
||||
impl LlmCycle {
|
||||
// 新
|
||||
pub fn messages(&self) -> &[Message] { &self.messages }
|
||||
// 兼容旧
|
||||
pub fn messages_openai(&self) -> Vec<OpenaiChatMessage> {
|
||||
self.messages.iter().map(|m| m.clone().into()).collect()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 测试代码的过渡
|
||||
|
||||
测试中大量使用 `MockProvider` 和旧类型,需要更新为 IR 类型。可在 Phase 1 中先保留旧类型别名以减少改动。
|
||||
@@ -0,0 +1,121 @@
|
||||
# 边界情况
|
||||
|
||||
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §9 边界情况。
|
||||
>
|
||||
> **相关文件:**
|
||||
> - [9b-ir-type-system.md](9b-ir-type-system.md) — IR 类型定义(ContentBlock、ThinkingConfig、MessageRequest/Response 等)
|
||||
> - [9c-llm-provider-trait.md](9c-llm-provider-trait.md) — StreamEvent 类型定义(ThinkingDelta 等)
|
||||
> - [9d-provider-implementations.md](9d-provider-implementations.md) — Anthropic 流式状态机(与 Thinking signature 强相关)
|
||||
> - [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md) — LlmCycle 的 tool 循环与 compact 逻辑
|
||||
|
||||
## 9. 边界情况
|
||||
|
||||
### 9.1 工具定义的传递路径变化
|
||||
|
||||
| 阶段 | 路径 |
|
||||
|------|------|
|
||||
| **当前** | `ToolRegistry.definitions()` → `Vec<ToolDefinition>` → `build_request()` 包装为 `Vec<OpenaiTool>` → `ChatRequest.tools` |
|
||||
| **方案 C** | `ToolRegistry.definitions()` → `Vec<ToolDefinition>` → `build_request()` 直接放入 → `MessageRequest.tools` → **Provider 内部**包装为原生格式 |
|
||||
|
||||
工具定义的抽象层次从 LlmCycle 下移到 Provider 内部,更合理。
|
||||
|
||||
### 9.2 Thinking 的端到端流程
|
||||
|
||||
```
|
||||
Agent 启用 thinking:
|
||||
config.thinking = Some(ThinkingConfig { budget_tokens: 16000 })
|
||||
|
||||
LlmCycle.build_request():
|
||||
→ MessageRequest { thinking: config.thinking, ... }
|
||||
|
||||
OpenaiProvider:
|
||||
→ capabilities().features.thinking == false
|
||||
→ 忽略 thinking 字段(或转为 extra.reasoning_tokens)
|
||||
|
||||
AnthropicProvider:
|
||||
→ capabilities().features.thinking == true
|
||||
→ 将 thinking 写入 Anthropic 请求参数
|
||||
→ 流式响应中返回 StreamEvent::ThinkingDelta
|
||||
→ 最终消息的 content 中包含 ContentBlock::Thinking
|
||||
```
|
||||
|
||||
> **✅ 推演结论(2026-06-17):方案 C —— MessageComplete 兜底 + finalize 统一回填**
|
||||
>
|
||||
> **决策:** Thinking signature 不在 event 级传递,而是通过 `MessageComplete.thinking_signature` 携带,
|
||||
> 由 `PartialMessageResponse::finalize()` 统一回填到最后一个 Thinking block。
|
||||
>
|
||||
> **具体路径:**
|
||||
> ```
|
||||
> Anthropic message_delta
|
||||
> → AnthropicProvider 提取 thinking.signature
|
||||
> → 发出 MessageComplete { stop_reason, thinking_signature: Some("0x...") }
|
||||
>
|
||||
> PartialMessageResponse:
|
||||
> ContentBlockEnd(thinking_idx) ← signature 还没到,builder 中 signature = None
|
||||
> ...
|
||||
> MessageComplete { thinking_signature: Some("0x...") }
|
||||
> → state.thinking_signature = Some("0x...")
|
||||
>
|
||||
> finalize():
|
||||
> 遍历 blocks → 找到 Thinking builder
|
||||
> → builder.signature 为 None,用 state.thinking_signature 回填
|
||||
> → ContentBlock::Thinking { text, signature: Some("0x...") }
|
||||
> ```
|
||||
>
|
||||
> **理由:**
|
||||
> 1. 不新增独立事件类型(保持 StreamEvent 简洁)
|
||||
> 2. signature 在 message_delta 中下发(晚于 content_block_stop),MessageComplete 是该时刻的天然载体
|
||||
> 3. Anthropic 同一响应中最多一个 thinking block,不存在"多 thinking block 归属"的歧义
|
||||
> 4. 非流式响应中,thinking block 的 signature 直接通过 IR→原生映射填充,路径不变
|
||||
>
|
||||
> **影响范围:**
|
||||
> - `StreamEvent::MessageComplete` 增加 `thinking_signature: Option<String>` 字段
|
||||
> - `PartialMessageResponse` 增加 `thinking_signature: Option<String>` 暂存字段
|
||||
> - `PartialMessageResponse::finalize()` 增加回填逻辑(约 3 行代码)
|
||||
> - AnthropicProvider 流式状态机在 `message_delta` 处理中提取 thinking.signature
|
||||
>
|
||||
> **何时实现:** Phase 4 AnthropicProvider 流式状态机实现时一并完成
|
||||
|
||||
### 9.3 Multiple ContentBlock 的处理
|
||||
|
||||
消息的 `content` 为 `Vec<ContentBlock>`,可包含多种类型的混合:
|
||||
|
||||
```rust
|
||||
Message::Assistant {
|
||||
content: vec![
|
||||
ContentBlock::Text { text: "让我思考一下..." },
|
||||
ContentBlock::Thinking { text: "先用加法工具...", signature: None },
|
||||
ContentBlock::Text { text: "答案是 3" },
|
||||
ContentBlock::ToolUse { id: "call_1", name: "add", input: json!({"a":1,"b":2}) },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
LlmCycle 处理 tool 循环时只关心 `ToolUse` block,其他 block 按原样传递给消息历史。
|
||||
|
||||
### 9.4 多 Choice 场景
|
||||
|
||||
OpenAI 的 `n > 1` 参数在 IR 中通过 `extra` 传递:
|
||||
|
||||
```rust
|
||||
// 请求
|
||||
request.set_extra("n", json!(3));
|
||||
// 响应
|
||||
response.extra["all_choices"] = json!([...choices 2..n]);
|
||||
```
|
||||
|
||||
`MessageResponse` 只承载 `choices[0]`(主消息),其他 choices 放 `extra`。
|
||||
|
||||
### 9.5 OpenAI Response API 的内置工具
|
||||
|
||||
通过 `extra` + 可选能力 trait 支持:
|
||||
|
||||
```rust
|
||||
// 配置内置工具
|
||||
request.set_extra("built_in_tools", json!(["web_search", "file_search"]));
|
||||
|
||||
// Response API 返回的搜索结果
|
||||
// → ContentBlock::Extension { kind: "web_search_result", data: {...} }
|
||||
```
|
||||
|
||||
如果未来内置工具成为跨 Provider 通用特性,将 `BuiltInToolsCapable` 升级为核心 trait。
|
||||
@@ -0,0 +1,96 @@
|
||||
# 风险评估、迁移路径与验收标准
|
||||
|
||||
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §10 风险评估 + §11 类型差异总结 + §12 迁移路径 + §13 验收标准。
|
||||
>
|
||||
> **相关文件:**
|
||||
> - [9a-background-and-architecture.md](9a-background-and-architecture.md) — 背景与架构总览
|
||||
> - [9b-ir-type-system.md](9b-ir-type-system.md) — IR 类型体系
|
||||
> - [9c-llm-provider-trait.md](9c-llm-provider-trait.md) — LlmProvider Trait 设计
|
||||
> - [9d-provider-implementations.md](9d-provider-implementations.md) — Provider 实现策略
|
||||
> - [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md) — LlmCycle 改造与上层适配
|
||||
> - [9f-edge-cases.md](9f-edge-cases.md) — 边界情况
|
||||
|
||||
## 10. 风险评估
|
||||
|
||||
| 风险 | 等级 | 缓解措施 |
|
||||
|------|------|----------|
|
||||
| ContentField::String 与 Vec<ContentBlock> 的统一导致文本消息需要包装 | 低 | Message::user("text") 便捷函数自动包装 |
|
||||
| 无法完整覆盖 OpenAI 所有参数 | 中 | extra 逃生舱兜底 |
|
||||
| IR ↔ OpenAI 的转换有性能开销 | 低 | 纯字段映射,相对 HTTP 延迟可忽略 |
|
||||
| HookContext 引用类型变更影响 Hook 实现 | 中 | 影响范围小(主要为测试代码) |
|
||||
| LlmCycle 返回类型变化破坏上层 | 中 | 提供兼容层 + From 转换 |
|
||||
| compact.rs 需要适配新 Message 类型 | 低 | 核心逻辑不变,仅改类型匹配 |
|
||||
| 流式事件格式变化破坏现有 StreamEvent 使用者 | 中 | 影响 LlmCycle::submit_stream 的调用者(主要是测试层) |
|
||||
| ProviderType 枚举需要扩展 | 低 | 新增变体即可 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 当前类型与 IR 的差异总结
|
||||
|
||||
| 当前类型 | 方案 C IR | 核心变化 |
|
||||
|---------|----------|---------|
|
||||
| `ChatRequest` (= `OpenaiChatRequest`) | `MessageRequest` | 独立类型,不绑定 OpenAI |
|
||||
| `ChatResponse` | `MessageResponse` | 独立类型,content 用 ContentBlock |
|
||||
| `OpenaiChatMessage` | `Message` | tool_calls 融入 content |
|
||||
| `ContentField` | `Vec<ContentBlock>` | 统一为数组,不再有 String variant |
|
||||
| `OpenaiContentPart` | `ContentBlock` | 新增 ToolUse/ToolResult/Thinking/Extension |
|
||||
| `FinishReason` | `StopReason` | 语义化(如 tool_calls → ToolUse) |
|
||||
| `OpenaiChatChunk` | — | 移除(StreamEvent 取代)|
|
||||
| `OpenaiChatResponse` | — | 不再暴露(Provider 内部使用)|
|
||||
| `OpenaiToolCall` | `ContentBlock::ToolUse` | 融入 content block |
|
||||
| `OpenaiToolDefinition` | `ToolDefinition` | 不变 |
|
||||
| `Usage` | `Usage` | 不变 |
|
||||
| `StreamEvent` | `StreamEvent` | 扩展(ThinkingDelta、MessageStart、ToolCallStart 等) |
|
||||
|
||||
---
|
||||
|
||||
## 12. 迁移路径
|
||||
|
||||
### Phase 1:定义 IR 类型 + From 转换
|
||||
|
||||
- 新增 `src/llm/types/ir.rs`,包含完整的 IR 类型定义
|
||||
- 实现 IR ↔ 现有类型的 `From`/`Into` trait
|
||||
- 新增 `pub type` 别名保持现有代码可编译
|
||||
- ✅ 零已有代码改动
|
||||
|
||||
### Phase 2:重写 LlmProvider trait
|
||||
|
||||
- 修改 `src/llm/provider.rs`:trait 签名改为 IR 类型
|
||||
- 重构 `OpenaiProvider`:内部 IR → OpenAI → IR 转换
|
||||
- 新增 `chat_stream` 的 `StreamEvent` 实现
|
||||
- 新增 `capabilities()` 方法
|
||||
- ❌ OpenaiProvider 需重构;LlmCycle 暂时不兼容
|
||||
|
||||
### Phase 3:适配 LlmCycle
|
||||
|
||||
- LlmCycle 内部消息历史改为 `Vec<Message>`
|
||||
- `build_request` 改为生成 `MessageRequest`
|
||||
- tool 循环逻辑改为遍历 `ContentBlock`
|
||||
- `HookContext` 引用改为 `MessageRequest`
|
||||
- `compact.rs` 适配新消息类型
|
||||
- ❌ LlmCycle API 变更;AgentSession 需适配
|
||||
|
||||
### Phase 4:实现 AnthropicProvider + 清理
|
||||
|
||||
- 新增 `src/llm/provider/anthropic.rs`
|
||||
- 实现 IR ↔ Anthropic JSON 映射 + 流式转换
|
||||
- 移除旧的 `parse_chunk_stream`、`ChunkToEventStream`
|
||||
- 清理不再需要的旧类型公开使用
|
||||
- 补测试
|
||||
|
||||
---
|
||||
|
||||
## 13. 验收标准
|
||||
|
||||
| 编号 | 标准 | 验证方式 |
|
||||
|------|------|----------|
|
||||
| A1 | `OpenaiProvider` 通过 IR trait 正常工作 | 现有测试通过 + ChatCompletion 集成测试 |
|
||||
| A2 | `AnthropicProvider` 通过 IR trait 正常工作 | Anthropic Messages API 集成测试 |
|
||||
| A3 | Tool 循环在 IR 上正确工作(在 content 中检测 ToolUse) | `submit_with_tools` 测试通过 |
|
||||
| A4 | 流式事件包含 ThinkingDelta 等新类型 | Provider 流式测试 |
|
||||
| A5 | ProviderCapabilities 正确描述 Provider 特性 | 单元测试 |
|
||||
| A6 | `extra` 逃生舱可传递 Provider 特有参数 | 测试各 Provider 的 extra 参数 |
|
||||
| A7 | 兼容层保持旧 API 可用 | 旧代码编译通过 |
|
||||
| A8 | `compact.rs` 在 IR 上正常工作 | 压缩测试通过 |
|
||||
| A9 | HookContext 使用 MessageRequest | Hook 测试通过 |
|
||||
| A10 | AgentSession 编译通过 | 编译检查 |
|
||||
+26
-2
@@ -1,13 +1,13 @@
|
||||
# AG Core Roadmap
|
||||
|
||||
> 定稿日期:2026-05-11
|
||||
> 最后更新:2026-06-11(Phase 4c 编码实施完成)
|
||||
> 最后更新:2026-07-04(v0.1 发布完成)
|
||||
|
||||
## 愿景
|
||||
|
||||
AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可插拔的架构,提供大模型调用、提示词工程、工具系统、记忆检索四大核心能力,支持快速组合出符合业务需求的智能体应用。
|
||||
|
||||
**当前状态**:Phase 0 基础设施已全部完成,Phase 1 提示词工程已全部完成,Phase 2 工具系统已全部完成,Phase 3 记忆系统已全部完成,Phase 4a 核心胶水层已全部完成,Phase 4b 任务执行已全部完成,Phase 4c 会话级记忆已全部完成(116 个测试通过,0 警告)。
|
||||
**当前状态**:Phase 0-4c 全部完成;Provider IR 重构(统一类型系统 + OpenAI/Anthropic/DeepSeek/Qwen Provider)已完成;LlmCycle 简化(IR 消息类型切换 + 桥接层移除)已完成;v0.1 发布就绪(**182 个测试通过、0 clippy 警告、7 个离线示例可运行**)。
|
||||
|
||||
---
|
||||
|
||||
@@ -338,3 +338,27 @@ graph BT
|
||||
- ✅ Phase 4a Core Glue — 全部交付物已完成
|
||||
- ✅ Phase 4b Task Execution — 全部交付物已完成
|
||||
- ✅ Phase 4c Session Memory — 全部交付物已完成
|
||||
- ✅ Provider IR 重构 — 统一类型系统 + OpenAI/Anthropic/DeepSeek/Qwen 适配(方案:`docs/10-llm-provider-refinement.md`、`docs/10a-phase0-types-and-trait.md`、`docs/10b-phase1-provider-adaptation.md`)
|
||||
- ✅ LlmCycle 简化 — IR 消息类型切换 + Phase 0 桥接层移除(方案:`docs/10c-phase2-llm-cycle-simplify.md`)
|
||||
- ✅ v0.1 Release — 技术债扫清、MockProvider 公开化、7 个离线示例、README + 错误消息友好化、Roadmap 同步、CHANGELOG 初始化(计划:`docs/11-v0.1-release-plan.md`)
|
||||
|
||||
---
|
||||
|
||||
## v0.1 发布里程碑(2026-07-04)
|
||||
|
||||
**质量基线**:
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| `cargo build --all-targets` | ✅ 通过 |
|
||||
| `cargo test --all-targets` | ✅ **182 passed / 0 failed** |
|
||||
| `cargo clippy --all-targets -- -D warnings` | ✅ 0 警告 |
|
||||
| 离线示例(`cargo run --example`) | ✅ 7 个全部 exit 0 |
|
||||
|
||||
**关键交付**:
|
||||
1. **Provider IR 重构** — 统一 `Message` / `ContentBlock` / `MessageRequest` / `MessageResponse` 类型层;4 个 Provider 适配(OpenAI Chat / Anthropic Messages / DeepSeek / Qwen);`LlmProvider` trait 签名同步切换
|
||||
2. **LlmCycle 简化** — `LlmCycle` 内部消息类型切到 IR 层;移除 Phase 0 的 `OpenaiChatMessage ↔ Message` 桥接;测试从 116 → 182(含 provider 测试)
|
||||
3. **`MockProvider` 公开化** — `agcore::llm::mock::MockProvider` 支持 `chat` + `chat_stream`,无需 API key 即可运行示例
|
||||
4. **7 个离线示例** — `prompt_composer` / `custom_tool` / `agent_session_demo` / `task_agent_demo` / `conversation_memory_demo` / `knowledge_search_demo` / `streaming_events_demo`
|
||||
5. **错误消息友好化** — `AgentError` / `LlmError` / `ToolError` / `MemoryError` / `PromptError` 全部面向最终用户改写(给出可操作的建议)
|
||||
6. **文档完整** — README 完整版(快速上手 + 架构图 + 环境变量)、Apache-2.0 LICENSE
|
||||
|
||||
@@ -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 完成");
|
||||
}
|
||||
+24
-14
@@ -4,23 +4,32 @@ use agcore::init_tracing;
|
||||
use agcore::llm::{
|
||||
cycle::{CycleConfig, LlmCycle},
|
||||
provider::{create_provider, ProviderConfig, ProviderType},
|
||||
types::{ChatResponse, OpenaiContentPart},
|
||||
types::{message::ContentBlock, message::Message, response_v2::MessageResponse},
|
||||
};
|
||||
|
||||
fn extract_response_text(response: &ChatResponse) -> &str {
|
||||
fn extract_response_text(response: &MessageResponse) -> &str {
|
||||
// Phase 0:MessageResponse.text() 直接给出拼接好的 Assistant 文本。
|
||||
if !response.text().is_empty() {
|
||||
// ⚠️ 返回的是 owned String 的引用,调用方需在 response 生命周期内使用。
|
||||
// 此 example 短命,足以演示。
|
||||
return response_text_ref(response);
|
||||
}
|
||||
"[无文本内容]"
|
||||
}
|
||||
|
||||
fn response_text_ref(response: &MessageResponse) -> &str {
|
||||
// ponytail: example 助手 —— 不在正式 crate API 中,单纯绕开 borrow 限制。
|
||||
// 真实调用请直接使用 `response.text()` 拿到 owned String。
|
||||
match &response.message {
|
||||
agcore::llm::types::OpenaiChatMessage::Assistant { content, .. } => match content {
|
||||
agcore::llm::types::ContentField::String(s) => s,
|
||||
agcore::llm::types::ContentField::Array(parts) => {
|
||||
for part in parts {
|
||||
if let OpenaiContentPart::Text { text } = part {
|
||||
return text;
|
||||
}
|
||||
agcore::llm::types::message::Message::Assistant { content } => {
|
||||
for block in content {
|
||||
if let ContentBlock::Text { text } = block {
|
||||
return text.as_str();
|
||||
}
|
||||
"[无文本内容]"
|
||||
}
|
||||
},
|
||||
_ => "[非 assistant 消息]",
|
||||
""
|
||||
}
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,8 +63,9 @@ async fn main() {
|
||||
..CycleConfig::default()
|
||||
};
|
||||
|
||||
let mut cycle = LlmCycle::new(provider, cycle_config)
|
||||
.with_system_prompt("你是一个简洁的助手,对于任何问题都是用一句话回答。".to_string());
|
||||
let mut cycle = LlmCycle::new(provider, cycle_config).with_messages(vec![
|
||||
Message::system("你是一个简洁的助手,对于任何问题都是用一句话回答。"),
|
||||
]);
|
||||
|
||||
println!("发送请求...");
|
||||
|
||||
|
||||
@@ -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 上
|
||||
|
||||
use crate::agent::runtime::RuntimeBundle;
|
||||
#[allow(deprecated)]
|
||||
use crate::llm::types::ToolDefinition;
|
||||
|
||||
/// Agent 角色抽象。
|
||||
///
|
||||
/// 实现此 trait 即可接入 Agent Runtime。典型实现是 struct 持有静态配置(name、system prompt 模板),
|
||||
/// 也可以是基于配置动态生成的轻量实现。
|
||||
#[allow(deprecated)]
|
||||
pub trait Agent: Send + Sync {
|
||||
/// 角色名(用于日志、调试、UI 展示)。
|
||||
fn name(&self) -> &str;
|
||||
|
||||
+25
-7
@@ -89,17 +89,18 @@ impl AgentBuilder {
|
||||
/// 构造 `RuntimeBundle`,校验必填字段。
|
||||
///
|
||||
/// **错误**:`provider` / `tool_registry` / `hook_executor` 任一缺失则返回
|
||||
/// `AgentError::Config("missing <field>")`,不 panic。
|
||||
/// `AgentError::Config(...)`,提示调用 `.provider(...)` / `.tool_registry(...)` /
|
||||
/// `.hook_executor(...)` 补齐。不 panic。
|
||||
pub fn build(self) -> Result<RuntimeBundle, AgentError> {
|
||||
let provider = self
|
||||
.provider
|
||||
.ok_or_else(|| AgentError::Config("missing provider".into()))?;
|
||||
.ok_or_else(|| AgentError::Config("缺少 LLM provider,请先调用 .provider(...)".into()))?;
|
||||
let tool_registry = self
|
||||
.tool_registry
|
||||
.ok_or_else(|| AgentError::Config("missing tool_registry".into()))?;
|
||||
.ok_or_else(|| AgentError::Config("缺少 tool_registry,请先调用 .tool_registry(...)(即使是空 ToolRegistry 也需要传入)".into()))?;
|
||||
let hook_executor = self
|
||||
.hook_executor
|
||||
.ok_or_else(|| AgentError::Config("missing hook_executor".into()))?;
|
||||
.ok_or_else(|| AgentError::Config("缺少 hook_executor,请先调用 .hook_executor(...)(空 HookExecutor 也可)".into()))?;
|
||||
|
||||
let config = self.config.unwrap_or_default();
|
||||
|
||||
@@ -118,17 +119,34 @@ impl AgentBuilder {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm::provider::LlmProvider;
|
||||
use crate::llm::types::{ChatRequest, ChatResponse};
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::provider::{LlmProvider, ProviderCapabilities, ProviderFeatures};
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
use crate::llm::types::response_v2::{MessageResponse, StreamEvent};
|
||||
use async_trait::async_trait;
|
||||
use futures_core::Stream;
|
||||
use std::pin::Pin;
|
||||
|
||||
struct StubProvider;
|
||||
#[async_trait]
|
||||
impl LlmProvider for StubProvider {
|
||||
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, LlmError> {
|
||||
async fn chat(&self, _request: MessageRequest) -> Result<MessageResponse, LlmError> {
|
||||
unimplemented!()
|
||||
}
|
||||
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: "stub",
|
||||
supported_models: None,
|
||||
features: ProviderFeatures::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+1
-1
@@ -129,7 +129,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn config_not_recoverable() {
|
||||
assert!(!AgentError::Config("missing provider".into()).is_recoverable());
|
||||
assert!(!AgentError::Config("缺少 provider".into()).is_recoverable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+25
-48
@@ -15,7 +15,8 @@ use crate::agent::runtime::RuntimeBundle;
|
||||
use crate::agent::session_memory::SessionMemory;
|
||||
use crate::llm::cycle::{CostTracker, CycleConfig, LlmCycle};
|
||||
use crate::llm::hooks::{HookContext, HookEvent};
|
||||
use crate::llm::types::ChatResponse;
|
||||
use crate::llm::types::message::Message;
|
||||
use crate::llm::types::response_v2::MessageResponse;
|
||||
use crate::memory::store::InMemoryStore;
|
||||
|
||||
/// Agent 会话实例。
|
||||
@@ -120,7 +121,7 @@ impl AgentSession {
|
||||
pub async fn submit_turn(
|
||||
&mut self,
|
||||
user_input: impl Into<String>,
|
||||
) -> Result<ChatResponse, AgentError> {
|
||||
) -> Result<MessageResponse, AgentError> {
|
||||
let turn_index = self.turn_index;
|
||||
let hook_executor = Arc::clone(&self.bundle.hook_executor);
|
||||
|
||||
@@ -138,8 +139,14 @@ impl AgentSession {
|
||||
let _ = self.agent.tool_definitions(&self.bundle);
|
||||
let mut cycle = LlmCycle::new_with_arc(Arc::clone(&self.bundle.provider), CycleConfig::default())
|
||||
.with_messages(Vec::new());
|
||||
// Phase 2 切换 system_prompt 字段为 Message::System(FIX-D)。
|
||||
// 若 agent 自带 system prompt,预置到 messages 列表头部。
|
||||
let mut initial_messages: Vec<Message> = Vec::new();
|
||||
if let Some(prompt) = self.agent.system_prompt() {
|
||||
cycle = cycle.with_system_prompt(prompt.to_string());
|
||||
initial_messages.push(Message::system(prompt));
|
||||
}
|
||||
if !initial_messages.is_empty() {
|
||||
cycle = cycle.with_messages(initial_messages);
|
||||
}
|
||||
if let Some(cfg) = self.bundle.config.compact_config.clone() {
|
||||
cycle = cycle.with_compact_config(cfg);
|
||||
@@ -169,10 +176,9 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::agent::builder::AgentBuilder;
|
||||
use crate::llm::hooks::{Hook, HookContext, HookExecutor, HookResult};
|
||||
use crate::llm::provider::LlmProvider;
|
||||
use crate::llm::types::{
|
||||
ChatRequest, ChatResponse, FinishReason, OpenaiChatMessage,
|
||||
};
|
||||
use crate::llm::mock::MockProvider;
|
||||
use crate::llm::types::message::ContentBlock;
|
||||
use crate::llm::types::response_v2::{MessageResponse, StopReason};
|
||||
use crate::tools::ToolRegistry;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
@@ -198,32 +204,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// MockProvider:按调用顺序返回预设响应。
|
||||
struct MockProvider {
|
||||
responses: std::sync::Mutex<Vec<ChatResponse>>,
|
||||
}
|
||||
|
||||
impl MockProvider {
|
||||
fn new(responses: Vec<ChatResponse>) -> Self {
|
||||
Self {
|
||||
responses: std::sync::Mutex::new(responses),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for MockProvider {
|
||||
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, crate::llm::error::LlmError> {
|
||||
let mut responses = self.responses.lock().unwrap();
|
||||
if responses.is_empty() {
|
||||
return Err(crate::llm::error::LlmError::Other(
|
||||
"no more mock responses".into(),
|
||||
));
|
||||
}
|
||||
Ok(responses.remove(0))
|
||||
}
|
||||
}
|
||||
|
||||
struct StubAgent {
|
||||
name: String,
|
||||
prompt: Option<String>,
|
||||
@@ -238,11 +218,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn assistant_text(text: &str) -> ChatResponse {
|
||||
ChatResponse {
|
||||
message: OpenaiChatMessage::assistant_text(text),
|
||||
fn assistant_text(text: &str) -> MessageResponse {
|
||||
MessageResponse {
|
||||
id: String::new(),
|
||||
model: String::new(),
|
||||
message: Message::Assistant {
|
||||
content: vec![ContentBlock::Text {
|
||||
text: text.into(),
|
||||
}],
|
||||
},
|
||||
usage: crate::llm::types::Usage::from_input_output(10, 5),
|
||||
stop_reason: Some(FinishReason::Stop),
|
||||
stop_reason: StopReason::Stop,
|
||||
extra: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,17 +254,7 @@ mod tests {
|
||||
assert_eq!(session.turn_index(), 0);
|
||||
|
||||
let response = session.submit_turn("hi").await.unwrap();
|
||||
let text = match &response.message {
|
||||
OpenaiChatMessage::Assistant { content, .. } => {
|
||||
if let crate::llm::types::ContentField::String(s) = content {
|
||||
s.clone()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
assert_eq!(text, "hello back");
|
||||
assert_eq!(response.text(), "hello back");
|
||||
assert_eq!(session.turn_index(), 1);
|
||||
assert_eq!(session.usage().total().prompt_tokens, 10);
|
||||
assert_eq!(session.usage().total().completion_tokens, 5);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
//! - 重试由上层新建 `Plan` 实现,`TaskAgent` 不做自动重试
|
||||
|
||||
use crate::agent::error::AgentError;
|
||||
#[allow(deprecated)]
|
||||
use crate::llm::types::ChatResponse;
|
||||
|
||||
use async_trait::async_trait;
|
||||
@@ -55,6 +56,7 @@ impl Step {
|
||||
/// 均未派生 `Clone`(保留原始错误信息,传递所有权而非克隆)。如需复制 `Plan`,
|
||||
/// 只能 clone 处于 `Pending` / `Running` / `Completed` / `Skipped` 状态的步骤。
|
||||
#[derive(Debug)]
|
||||
#[allow(deprecated)]
|
||||
pub enum StepStatus {
|
||||
/// 初始状态 —— 等待执行。
|
||||
Pending,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! LLM 调用周期 —— 大模型基础调用周期控制。
|
||||
|
||||
pub mod compact;
|
||||
pub mod convert;
|
||||
pub mod cycle;
|
||||
pub mod error;
|
||||
pub mod hooks;
|
||||
pub mod mock;
|
||||
pub mod provider;
|
||||
pub mod stream;
|
||||
pub mod types;
|
||||
|
||||
+133
-28
@@ -1,6 +1,6 @@
|
||||
//! 上下文自动压缩 —— 当对话历史过长时自动压缩。
|
||||
|
||||
use crate::llm::types::{ContentField, OpenaiChatMessage, OpenaiContentPart};
|
||||
use crate::llm::types::message::{ContentBlock, Message};
|
||||
|
||||
const AUTOCOMPACT_BUFFER_TOKENS: u32 = 13_000;
|
||||
const RESERVED_OUTPUT_TOKENS: u32 = 20_000;
|
||||
@@ -72,37 +72,43 @@ impl CompactState {
|
||||
}
|
||||
|
||||
/// 粗略估计消息列表的 token 数(基于字符数,4 字符 ≈ 1 token)。
|
||||
pub fn estimate_message_tokens(messages: &[OpenaiChatMessage]) -> u32 {
|
||||
pub fn estimate_message_tokens(messages: &[Message]) -> u32 {
|
||||
messages
|
||||
.iter()
|
||||
.map(estimate_single_message_tokens)
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn estimate_single_message_tokens(msg: &OpenaiChatMessage) -> u32 {
|
||||
fn estimate_single_message_tokens(msg: &Message) -> u32 {
|
||||
let role_overhead: u32 = 4;
|
||||
let content_tokens = match msg {
|
||||
OpenaiChatMessage::Developer { content, .. }
|
||||
| OpenaiChatMessage::System { content, .. }
|
||||
| OpenaiChatMessage::User { content, .. }
|
||||
| OpenaiChatMessage::Assistant { content, .. }
|
||||
| OpenaiChatMessage::Function { content, .. } => estimate_content_tokens(content),
|
||||
OpenaiChatMessage::Tool { content, .. } => estimate_content_tokens(content),
|
||||
Message::System { content }
|
||||
| Message::User { content }
|
||||
| Message::Assistant { content }
|
||||
| Message::ToolResult { content, .. } => estimate_content_blocks_tokens(content),
|
||||
Message::UserImage { .. } => 50,
|
||||
};
|
||||
role_overhead + content_tokens
|
||||
}
|
||||
|
||||
fn estimate_content_tokens(content: &ContentField) -> u32 {
|
||||
match content {
|
||||
ContentField::String(s) => estimate_text_tokens(s),
|
||||
ContentField::Array(parts) => parts.iter().map(estimate_part_tokens).sum(),
|
||||
}
|
||||
fn estimate_content_blocks_tokens(blocks: &[ContentBlock]) -> u32 {
|
||||
blocks.iter().map(estimate_block_tokens).sum()
|
||||
}
|
||||
|
||||
fn estimate_part_tokens(part: &OpenaiContentPart) -> u32 {
|
||||
match part {
|
||||
OpenaiContentPart::Text { text } => estimate_text_tokens(text),
|
||||
_ => 50,
|
||||
fn estimate_block_tokens(block: &ContentBlock) -> u32 {
|
||||
match block {
|
||||
ContentBlock::Text { text } => estimate_text_tokens(text),
|
||||
ContentBlock::Thinking { text, .. } => estimate_text_tokens(text),
|
||||
ContentBlock::ToolUse { input, .. } => {
|
||||
estimate_text_tokens(&input.to_string())
|
||||
}
|
||||
ContentBlock::ToolResult { content, .. } => estimate_content_blocks_tokens(content),
|
||||
// ponytail: Image / Audio / File / Extension 在 IR 中固定估算。
|
||||
// 无文本的视觉/音频 block 用兜底估算,避免 token 计数膨胀。
|
||||
ContentBlock::Image { .. }
|
||||
| ContentBlock::Audio { .. }
|
||||
| ContentBlock::File { .. }
|
||||
| ContentBlock::Extension { .. } => 50,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,11 +121,7 @@ fn estimate_text_tokens(text: &str) -> u32 {
|
||||
}
|
||||
|
||||
/// 判断是否需要触发自动压缩。
|
||||
pub fn should_compact(
|
||||
messages: &[OpenaiChatMessage],
|
||||
config: &CompactConfig,
|
||||
state: &CompactState,
|
||||
) -> bool {
|
||||
pub fn should_compact(messages: &[Message], config: &CompactConfig, state: &CompactState) -> bool {
|
||||
if state.consecutive_failures >= MAX_CONSECUTIVE_FAILURES {
|
||||
return false;
|
||||
}
|
||||
@@ -133,7 +135,10 @@ pub fn should_compact(
|
||||
/// 保留最近的 `keep_recent` 条消息不变。
|
||||
///
|
||||
/// 返回释放的估算 token 数。
|
||||
pub fn microcompact(messages: &mut [OpenaiChatMessage], keep_recent: usize) -> u32 {
|
||||
///
|
||||
/// **审查 FIX-F**:仅压缩 `is_error: false` 的 `ToolResult` —— 错误结果包含对 LLM
|
||||
/// 理解失败原因至关重要的诊断信息,压缩后 LLM 无法理解。
|
||||
pub fn microcompact(messages: &mut [Message], keep_recent: usize) -> u32 {
|
||||
if messages.len() <= keep_recent {
|
||||
return 0;
|
||||
}
|
||||
@@ -141,19 +146,119 @@ pub fn microcompact(messages: &mut [OpenaiChatMessage], keep_recent: usize) -> u
|
||||
let prune_start = messages.len() - keep_recent;
|
||||
let mut freed_tokens: u32 = 0;
|
||||
|
||||
// 第一遍:计算可释放 token(仅非错误 ToolResult)
|
||||
for msg in &messages[..prune_start] {
|
||||
if matches!(msg, OpenaiChatMessage::Tool { .. }) {
|
||||
if matches!(msg, Message::ToolResult { is_error: false, .. }) {
|
||||
freed_tokens += estimate_single_message_tokens(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// 第二遍:替换内容(仅非错误 ToolResult)
|
||||
for msg in &mut messages[..prune_start] {
|
||||
if let OpenaiChatMessage::Tool { content, .. } = msg {
|
||||
*content = ContentField::Array(vec![OpenaiContentPart::Text {
|
||||
if let Message::ToolResult { content, is_error: false, .. } = msg {
|
||||
*content = vec![ContentBlock::Text {
|
||||
text: "[pruned]".to_string(),
|
||||
}]);
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
freed_tokens
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn user_msg(s: &str) -> Message {
|
||||
Message::user_text(s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimate_message_tokens_handles_all_variants() {
|
||||
let messages = vec![
|
||||
Message::System {
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "sys".into(),
|
||||
}],
|
||||
},
|
||||
Message::user_text("hi"),
|
||||
Message::assistant("ans"),
|
||||
Message::user_image("b64", "image/png", crate::llm::types::shared::ImageDetail::Auto),
|
||||
Message::tool_result("call_1", "tool res", false),
|
||||
];
|
||||
let tokens = estimate_message_tokens(&messages);
|
||||
// 至少 5 条消息 × 4 role overhead = 20 + 文本/估算
|
||||
assert!(tokens > 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn microcompact_replaces_old_tool_result_with_pruned() {
|
||||
let mut messages = vec![
|
||||
user_msg("hi"),
|
||||
Message::tool_result("call_1", "raw result".repeat(50), false),
|
||||
user_msg("again"),
|
||||
user_msg("keep recent 1"),
|
||||
user_msg("keep recent 2"),
|
||||
];
|
||||
let before_len = messages.len();
|
||||
let freed = microcompact(&mut messages, 2);
|
||||
assert!(freed > 0);
|
||||
assert_eq!(messages.len(), before_len); // 只改内容,不删消息
|
||||
// 索引 1 是被压缩的 ToolResult
|
||||
if let Message::ToolResult { content, is_error, .. } = &messages[1] {
|
||||
assert_eq!(content.len(), 1);
|
||||
assert!(matches!(&content[0], ContentBlock::Text { text } if text == "[pruned]"));
|
||||
assert!(!is_error);
|
||||
} else {
|
||||
panic!("expected ToolResult at index 1");
|
||||
}
|
||||
}
|
||||
|
||||
/// FIX-F 验证:错误结果不被压缩,诊断信息完整保留。
|
||||
#[test]
|
||||
fn microcompact_preserves_error_tool_results() {
|
||||
let mut messages = vec![
|
||||
user_msg("hi"),
|
||||
Message::tool_result("call_1", "important error info: backend down", true),
|
||||
user_msg("keep recent 1"),
|
||||
user_msg("keep recent 2"),
|
||||
];
|
||||
let before_len = messages.len();
|
||||
let freed = microcompact(&mut messages, 2);
|
||||
assert_eq!(freed, 0); // 错误 ToolResult 不计入
|
||||
assert_eq!(messages.len(), before_len);
|
||||
// 错误信息保留完整
|
||||
if let Message::ToolResult { content, is_error, .. } = &messages[1] {
|
||||
assert!(is_error);
|
||||
assert!(matches!(&content[0], ContentBlock::Text { text } if text.contains("backend down")));
|
||||
} else {
|
||||
panic!("expected ToolResult at index 1");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn microcompact_keeps_recent_messages() {
|
||||
let mut messages = vec![
|
||||
Message::tool_result("c1", "old", false),
|
||||
user_msg("m1"),
|
||||
user_msg("m2"),
|
||||
user_msg("recent"),
|
||||
];
|
||||
let freed = microcompact(&mut messages, 1);
|
||||
// keep_recent=1 → 仅最近 1 条不动,其余 ToolResult 压缩
|
||||
// 索引 0 (ToolResult) 被压缩,索引 1-3 保留
|
||||
assert!(freed > 0);
|
||||
if let Message::ToolResult { content, .. } = &messages[0] {
|
||||
assert!(matches!(&content[0], ContentBlock::Text { text } if text == "[pruned]"));
|
||||
}
|
||||
assert!(matches!(messages[3], Message::User { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_compact_respects_threshold() {
|
||||
let cfg = CompactConfig::default();
|
||||
let state = CompactState::new();
|
||||
let empty: Vec<Message> = vec![];
|
||||
assert!(!should_compact(&empty, &cfg, &state));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
//! 跨 Provider 类型转换 —— `Message` ↔ OpenAI `OpenaiChatMessage`。
|
||||
//!
|
||||
//! Phase 1 引入:避免转换逻辑在 `LlmCycle`、各 Provider 中重复。
|
||||
//! 这些函数期望作为**纯函数**被调用 —— 无内部状态,方便跨 Provider 复用。
|
||||
//!
|
||||
//! 转换范围:仅处理 `OpenaiChatMessage` ↔ `Message`、`ContentField` ↔ `Vec<ContentBlock>`。
|
||||
//! 流式 chunk 转换见各 Provider 内部(OpenAI Chat / Anthropic)。
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm::types::message::{ContentBlock, Message};
|
||||
use crate::llm::types::openai_message::{
|
||||
ContentField, OpenaiChatMessage, OpenaiContentPart,
|
||||
};
|
||||
use crate::llm::types::OpenaiToolCall;
|
||||
|
||||
/// `OpenaiChatMessage` → IR `Message`。
|
||||
///
|
||||
/// 转换规则:
|
||||
/// - `Developer` / `System` → `Message::System`
|
||||
/// - `User` → `Message::User`(含图片/音频等多模态 part → `ContentBlock`)
|
||||
/// - `Assistant` → `Message::Assistant`(`tool_calls` 转为 `ContentBlock::ToolUse`)
|
||||
/// - `Tool` → `Message::ToolResult`(`is_error` 暂为 `false`,OpenAI 不携带此标记)
|
||||
/// - `Function`(已废弃)→ `Message::ToolResult`(`name` 作为 `tool_call_id` 兜底)
|
||||
pub fn from_openai(msg: &OpenaiChatMessage) -> Message {
|
||||
match msg {
|
||||
OpenaiChatMessage::Developer { content, .. } | OpenaiChatMessage::System { content, .. } => {
|
||||
Message::System {
|
||||
content: content_to_blocks(content),
|
||||
}
|
||||
}
|
||||
OpenaiChatMessage::User { content, .. } => Message::User {
|
||||
content: content_to_blocks(content),
|
||||
},
|
||||
OpenaiChatMessage::Assistant {
|
||||
content,
|
||||
tool_calls,
|
||||
..
|
||||
} => {
|
||||
let mut blocks = content_to_blocks(content);
|
||||
if let Some(calls) = tool_calls {
|
||||
for call in calls {
|
||||
let OpenaiToolCall::Function { id, function } = call;
|
||||
let input: Value =
|
||||
serde_json::from_str(&function.arguments).unwrap_or(Value::Null);
|
||||
blocks.push(ContentBlock::ToolUse {
|
||||
id: id.clone(),
|
||||
name: function.name.clone(),
|
||||
input,
|
||||
});
|
||||
}
|
||||
}
|
||||
Message::Assistant { content: blocks }
|
||||
}
|
||||
OpenaiChatMessage::Tool {
|
||||
content,
|
||||
tool_call_id,
|
||||
} => Message::ToolResult {
|
||||
tool_call_id: tool_call_id.clone(),
|
||||
content: content_to_blocks(content),
|
||||
is_error: false,
|
||||
},
|
||||
// ponytail: `function` 是 OpenAI 旧版 `function_call` API 残留变体;
|
||||
// 当前主流为 `tool_calls`,此处仅保留兼容路径。
|
||||
OpenaiChatMessage::Function { content, name } => Message::ToolResult {
|
||||
tool_call_id: name.clone(),
|
||||
content: content_to_blocks(content),
|
||||
is_error: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// IR `Message` → `OpenaiChatMessage`。
|
||||
///
|
||||
/// ponytail 简化:当前 `Assistant.content` 仅序列化 Text → `content: String`,
|
||||
/// `ToolUse` 拆出到 `tool_calls` 字段;thinking / image / audio / file / extension
|
||||
/// 视为非 OpenAI 原生,**回传时丢弃**。Phase 2 切换为 `Vec<Message>` 后该丢弃
|
||||
/// 自然消失(不再需要回传旧 wire 格式)。
|
||||
pub fn to_openai(msg: &Message) -> OpenaiChatMessage {
|
||||
match msg {
|
||||
Message::System { content } => OpenaiChatMessage::System {
|
||||
content: blocks_to_content(content),
|
||||
name: None,
|
||||
},
|
||||
Message::User { content } => OpenaiChatMessage::User {
|
||||
content: blocks_to_content(content),
|
||||
name: None,
|
||||
},
|
||||
Message::UserImage { data, mime_type, detail } => {
|
||||
// ponytail: 构造为单 image part 的 User 消息(OpenAI 多模态格式)。
|
||||
let mime = mime_type.clone();
|
||||
let is_url = data.starts_with("http://") || data.starts_with("https://");
|
||||
let image_url = if is_url {
|
||||
crate::llm::types::openai_message::ImageURL {
|
||||
url: data.clone(),
|
||||
detail: Some(*detail),
|
||||
}
|
||||
} else {
|
||||
crate::llm::types::openai_message::ImageURL {
|
||||
url: format!("data:{mime};base64,{data}"),
|
||||
detail: Some(*detail),
|
||||
}
|
||||
};
|
||||
OpenaiChatMessage::User {
|
||||
content: ContentField::Array(vec![OpenaiContentPart::Image {
|
||||
image_url,
|
||||
detail: Some(*detail),
|
||||
}]),
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
Message::Assistant { content } => {
|
||||
let mut text_blocks: Vec<String> = Vec::new();
|
||||
let mut tool_call_blocks: Vec<OpenaiToolCall> = Vec::new();
|
||||
for block in content {
|
||||
match block {
|
||||
ContentBlock::Text { text } => text_blocks.push(text.clone()),
|
||||
ContentBlock::ToolUse { id, name, input } => {
|
||||
tool_call_blocks.push(OpenaiToolCall::Function {
|
||||
id: id.clone(),
|
||||
function: crate::llm::types::tool::FunctionCall {
|
||||
name: name.clone(),
|
||||
arguments: serde_json::to_string(input)
|
||||
.unwrap_or_else(|_| "null".to_string()),
|
||||
},
|
||||
});
|
||||
}
|
||||
// ponytail: thinking/refusal/image/audio/file/extension 在回传时被丢弃。
|
||||
// Phase 2 切换后此函数整体移除,自然修复。
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let text: String = text_blocks.into_iter().collect();
|
||||
OpenaiChatMessage::Assistant {
|
||||
content: if text.is_empty() {
|
||||
ContentField::Array(vec![])
|
||||
} else {
|
||||
ContentField::String(text)
|
||||
},
|
||||
refusal: None,
|
||||
name: None,
|
||||
tool_calls: if tool_call_blocks.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(tool_call_blocks)
|
||||
},
|
||||
}
|
||||
}
|
||||
Message::ToolResult {
|
||||
tool_call_id,
|
||||
content,
|
||||
is_error: _,
|
||||
} => OpenaiChatMessage::Tool {
|
||||
content: blocks_to_content(content),
|
||||
tool_call_id: tool_call_id.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `ContentField` → `Vec<ContentBlock>`。
|
||||
///
|
||||
/// 仅产出 OpenAI 有 wire 对应的 `Text` / `Image` / `Audio` /
|
||||
/// `Refusal`(合并为 `Text`)block;`File` 被截断。
|
||||
pub fn content_to_blocks(field: &ContentField) -> Vec<ContentBlock> {
|
||||
match field {
|
||||
ContentField::String(s) => vec![ContentBlock::Text { text: s.clone() }],
|
||||
ContentField::Array(parts) => parts
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
OpenaiContentPart::Text { text } => {
|
||||
Some(ContentBlock::Text { text: text.clone() })
|
||||
}
|
||||
OpenaiContentPart::Refusal { refusal } => {
|
||||
Some(ContentBlock::Text { text: refusal.clone() })
|
||||
}
|
||||
OpenaiContentPart::Image { image_url, .. } => {
|
||||
// ponytail: 简化处理 —— URL 直接通过,data URI 拆出
|
||||
// data:<mime>;base64,<b64> → ImageSource { data: b64, mime, is_url: false }。
|
||||
let url = &image_url.url;
|
||||
if let Some(rest) = url.strip_prefix("data:")
|
||||
&& let Some((mime, b64)) = rest.split_once(";base64,") {
|
||||
return Some(ContentBlock::Image {
|
||||
source: crate::llm::types::message::ImageSource {
|
||||
data: b64.to_string(),
|
||||
mime_type: mime.to_string(),
|
||||
is_url: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
Some(ContentBlock::Image {
|
||||
source: crate::llm::types::message::ImageSource {
|
||||
data: url.clone(),
|
||||
mime_type: "image/url".to_string(),
|
||||
is_url: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
OpenaiContentPart::InputAudio { input_audio } => Some(ContentBlock::Audio {
|
||||
source: crate::llm::types::message::AudioSource {
|
||||
data: input_audio.data.clone(),
|
||||
format: input_audio.format,
|
||||
},
|
||||
}),
|
||||
// File 暂不映射(OpenAI File API 与 IR 不对齐)
|
||||
OpenaiContentPart::File { .. } => None,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `Vec<ContentBlock>` → `ContentField`。
|
||||
///
|
||||
/// 单文本块 → `ContentField::String`;多块或非文本主导 → `ContentField::Array`。
|
||||
pub fn blocks_to_content(blocks: &[ContentBlock]) -> ContentField {
|
||||
if let [ContentBlock::Text { text }] = blocks {
|
||||
return ContentField::String(text.clone());
|
||||
}
|
||||
let parts: Vec<OpenaiContentPart> = blocks
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
ContentBlock::Text { text } => Some(OpenaiContentPart::Text { text: text.clone() }),
|
||||
ContentBlock::Image { source } => Some(OpenaiContentPart::Image {
|
||||
image_url: if source.is_url {
|
||||
crate::llm::types::openai_message::ImageURL {
|
||||
url: source.data.clone(),
|
||||
detail: None,
|
||||
}
|
||||
} else {
|
||||
crate::llm::types::openai_message::ImageURL {
|
||||
url: format!("data:{};base64,{}", source.mime_type, source.data),
|
||||
detail: None,
|
||||
}
|
||||
},
|
||||
detail: None,
|
||||
}),
|
||||
ContentBlock::Audio { source } => Some(OpenaiContentPart::InputAudio {
|
||||
input_audio: crate::llm::types::openai_message::InputAudio {
|
||||
data: source.data.clone(),
|
||||
format: source.format,
|
||||
},
|
||||
}),
|
||||
// ponytail: 其他 block 类型在 OpenAI wire 上无对应,回传时被截断。
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if parts.is_empty() {
|
||||
ContentField::Array(vec![])
|
||||
} else {
|
||||
ContentField::Array(parts)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm::types::shared::ImageDetail;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn from_openai_system_maps_to_message_system() {
|
||||
let m = OpenaiChatMessage::system_text("you are helpful");
|
||||
let ir = from_openai(&m);
|
||||
match ir {
|
||||
Message::System { content } => {
|
||||
assert_eq!(content.len(), 1);
|
||||
assert!(matches!(&content[0], ContentBlock::Text { text } if text == "you are helpful"));
|
||||
}
|
||||
_ => panic!("expected System variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_openai_assistant_tool_call_to_tool_use_block() {
|
||||
let m = OpenaiChatMessage::Assistant {
|
||||
content: ContentField::String("ok".into()),
|
||||
refusal: None,
|
||||
name: None,
|
||||
tool_calls: Some(vec![OpenaiToolCall::Function {
|
||||
id: "call_1".into(),
|
||||
function: crate::llm::types::tool::FunctionCall {
|
||||
name: "search".into(),
|
||||
arguments: r#"{"q":"rust"}"#.into(),
|
||||
},
|
||||
}]),
|
||||
};
|
||||
let ir = from_openai(&m);
|
||||
match ir {
|
||||
Message::Assistant { content } => {
|
||||
assert_eq!(content.len(), 2);
|
||||
match &content[1] {
|
||||
ContentBlock::ToolUse { id, name, input } => {
|
||||
assert_eq!(id, "call_1");
|
||||
assert_eq!(name, "search");
|
||||
assert_eq!(input, &json!({"q": "rust"}));
|
||||
}
|
||||
_ => panic!("expected ToolUse"),
|
||||
}
|
||||
}
|
||||
_ => panic!("expected Assistant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_openai_tool_to_tool_result() {
|
||||
let m = OpenaiChatMessage::Tool {
|
||||
content: ContentField::String("ok".into()),
|
||||
tool_call_id: "call_1".into(),
|
||||
};
|
||||
let ir = from_openai(&m);
|
||||
match ir {
|
||||
Message::ToolResult {
|
||||
tool_call_id,
|
||||
content,
|
||||
is_error,
|
||||
} => {
|
||||
assert_eq!(tool_call_id, "call_1");
|
||||
assert!(!is_error);
|
||||
assert_eq!(content.len(), 1);
|
||||
}
|
||||
_ => panic!("expected ToolResult"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_openai_assistant_text_singular_string_content() {
|
||||
let m = Message::Assistant {
|
||||
content: vec![ContentBlock::Text { text: "hi".into() }],
|
||||
};
|
||||
let wire = to_openai(&m);
|
||||
match wire {
|
||||
OpenaiChatMessage::Assistant {
|
||||
content,
|
||||
tool_calls,
|
||||
..
|
||||
} => {
|
||||
assert!(matches!(content, ContentField::String(s) if s == "hi"));
|
||||
assert!(tool_calls.is_none());
|
||||
}
|
||||
_ => panic!("expected Assistant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_openai_assistant_with_tool_use_produces_tool_calls() {
|
||||
let m = Message::Assistant {
|
||||
content: vec![
|
||||
ContentBlock::Text { text: "ok".into() },
|
||||
ContentBlock::ToolUse {
|
||||
id: "call_1".into(),
|
||||
name: "search".into(),
|
||||
input: json!({"q": "rust"}),
|
||||
},
|
||||
],
|
||||
};
|
||||
let wire = to_openai(&m);
|
||||
match wire {
|
||||
OpenaiChatMessage::Assistant {
|
||||
content,
|
||||
tool_calls,
|
||||
..
|
||||
} => {
|
||||
assert!(matches!(content, ContentField::String(_)));
|
||||
let calls = tool_calls.expect("tool_calls");
|
||||
assert_eq!(calls.len(), 1);
|
||||
let OpenaiToolCall::Function { id, function } = &calls[0];
|
||||
assert_eq!(id, "call_1");
|
||||
assert_eq!(function.name, "search");
|
||||
assert!(function.arguments.contains("rust"));
|
||||
}
|
||||
_ => panic!("expected Assistant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_openai_user_image_builds_data_uri() {
|
||||
let m = Message::UserImage {
|
||||
data: "BASE64DATA".into(),
|
||||
mime_type: "image/png".into(),
|
||||
detail: ImageDetail::High,
|
||||
};
|
||||
let wire = to_openai(&m);
|
||||
match wire {
|
||||
OpenaiChatMessage::User { content, .. } => match content {
|
||||
ContentField::Array(parts) => {
|
||||
assert_eq!(parts.len(), 1);
|
||||
match &parts[0] {
|
||||
OpenaiContentPart::Image { image_url, .. } => {
|
||||
assert_eq!(
|
||||
image_url.url,
|
||||
"data:image/png;base64,BASE64DATA"
|
||||
);
|
||||
}
|
||||
_ => panic!("expected Image part"),
|
||||
}
|
||||
}
|
||||
_ => panic!("expected Array content"),
|
||||
},
|
||||
_ => panic!("expected User"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_to_content_singular_text_uses_string() {
|
||||
let blocks = vec![ContentBlock::Text { text: "hi".into() }];
|
||||
let f = blocks_to_content(&blocks);
|
||||
assert!(matches!(f, ContentField::String(s) if s == "hi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_to_content_multiple_blocks_uses_array() {
|
||||
let blocks = vec![
|
||||
ContentBlock::Text { text: "a".into() },
|
||||
ContentBlock::Text { text: "b".into() },
|
||||
];
|
||||
let f = blocks_to_content(&blocks);
|
||||
assert!(matches!(f, ContentField::Array(parts) if parts.len() == 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_to_blocks_refusal_merges_to_text() {
|
||||
let field = ContentField::Array(vec![OpenaiContentPart::Refusal {
|
||||
refusal: "policy violation".into(),
|
||||
}]);
|
||||
let blocks = content_to_blocks(&field);
|
||||
assert_eq!(blocks.len(), 1);
|
||||
assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "policy violation"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_image_via_data_uri() {
|
||||
let field = ContentField::Array(vec![OpenaiContentPart::Image {
|
||||
image_url: crate::llm::types::openai_message::ImageURL {
|
||||
url: "data:image/png;base64,AAA".into(),
|
||||
detail: Some(ImageDetail::Auto),
|
||||
},
|
||||
detail: None,
|
||||
}]);
|
||||
let blocks = content_to_blocks(&field);
|
||||
assert_eq!(blocks.len(), 1);
|
||||
match &blocks[0] {
|
||||
ContentBlock::Image { source } => {
|
||||
assert_eq!(source.data, "AAA");
|
||||
assert_eq!(source.mime_type, "image/png");
|
||||
assert!(!source.is_url);
|
||||
}
|
||||
_ => panic!("expected Image"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+215
-215
@@ -18,10 +18,11 @@ use crate::llm::error::LlmError;
|
||||
use crate::llm::hooks::{HookContext, HookExecutor};
|
||||
use crate::llm::provider::LlmProvider;
|
||||
use crate::llm::stream::StreamEvent;
|
||||
use crate::llm::types::{
|
||||
ChatRequest, ChatResponse, FinishReason, OpenaiChatMessage, OpenaiTool, OpenaiToolCall,
|
||||
ToolChoice, ToolDefinition,
|
||||
};
|
||||
use crate::llm::types::message::{ContentBlock, Message};
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
use crate::llm::types::response_v2::{MessageResponse, StopReason};
|
||||
#[allow(deprecated)]
|
||||
use crate::llm::types::{ToolChoice, ToolDefinition};
|
||||
|
||||
/// LLM 调用周期配置。
|
||||
pub struct CycleConfig {
|
||||
@@ -62,17 +63,24 @@ impl Default for CycleConfig {
|
||||
}
|
||||
|
||||
/// LLM 调用周期 —— 管理一次或多次 LLM 请求的生命周期。
|
||||
///
|
||||
/// Phase 2 修订:
|
||||
/// - `messages` 字段从 `Vec<OpenaiChatMessage>` 切换为 `Vec<Message>`(IR 类型)。
|
||||
/// - 移除 `system_prompt` 字段(FIX-D)—— 调用方通过 `Message::system_text()` +
|
||||
/// `with_messages()` 自行管理系统提示消息,避免重复维护两条路径。
|
||||
/// - `with_system_prompt()` 方法标记 `#[deprecated]`,过渡期内仍可用。
|
||||
pub struct LlmCycle {
|
||||
provider: Arc<dyn LlmProvider>,
|
||||
config: CycleConfig,
|
||||
usage: CostTracker,
|
||||
messages: Vec<OpenaiChatMessage>,
|
||||
system_prompt: Option<String>,
|
||||
/// 消息历史 —— 直接存储 IR `Message` 类型,build_request 不再做转换。
|
||||
messages: Vec<Message>,
|
||||
hook_executor: Option<Arc<HookExecutor>>,
|
||||
compact_config: Option<CompactConfig>,
|
||||
compact_state: CompactState,
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
impl LlmCycle {
|
||||
/// 创建一个新的 LlmCycle(持有 `Box<dyn LlmProvider>` 的独占所有权)。
|
||||
///
|
||||
@@ -91,16 +99,22 @@ impl LlmCycle {
|
||||
config,
|
||||
usage: CostTracker::default(),
|
||||
messages: Vec::new(),
|
||||
system_prompt: None,
|
||||
hook_executor: None,
|
||||
compact_config: None,
|
||||
compact_state: CompactState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置系统提示词。
|
||||
/// 设置系统提示词(**已废弃** —— Phase 2 起使用 `Message::system_text()` + `with_messages()`)。
|
||||
///
|
||||
/// 当前实现为过渡期保留:在 messages 头部插入 `Message::System { content: [Text { text }] }`。
|
||||
#[deprecated(
|
||||
since = "0.2.0",
|
||||
note = "请改用 Message::system_text() + with_messages()"
|
||||
)]
|
||||
pub fn with_system_prompt(mut self, prompt: String) -> Self {
|
||||
self.system_prompt = Some(prompt);
|
||||
self.messages
|
||||
.insert(0, Message::System { content: vec![ContentBlock::Text { text: prompt }] });
|
||||
self
|
||||
}
|
||||
|
||||
@@ -121,8 +135,8 @@ impl LlmCycle {
|
||||
&self.usage
|
||||
}
|
||||
|
||||
/// 获取消息历史引用。
|
||||
pub fn messages(&self) -> &[OpenaiChatMessage] {
|
||||
/// 获取消息历史引用(Phase 2:返回 `&[Message]` IR 类型)。
|
||||
pub fn messages(&self) -> &[Message] {
|
||||
&self.messages
|
||||
}
|
||||
|
||||
@@ -137,45 +151,39 @@ impl LlmCycle {
|
||||
}
|
||||
|
||||
/// 直接设置消息历史(覆盖已有消息),支持 Builder 链式调用。
|
||||
pub fn with_messages(mut self, messages: Vec<OpenaiChatMessage>) -> Self {
|
||||
pub fn with_messages(mut self, messages: Vec<Message>) -> Self {
|
||||
self.messages = messages;
|
||||
self
|
||||
}
|
||||
|
||||
/// 追加消息到历史尾部。
|
||||
pub fn extend_messages(&mut self, messages: Vec<OpenaiChatMessage>) {
|
||||
pub fn extend_messages(&mut self, messages: Vec<Message>) {
|
||||
self.messages.extend(messages);
|
||||
}
|
||||
|
||||
/// 追加单条消息到历史尾部。
|
||||
///
|
||||
/// 公开给 `submit_stream()` 消费方在收到 `MessageComplete` 事件后调用。
|
||||
pub fn push_message(&mut self, msg: Message) {
|
||||
self.messages.push(msg);
|
||||
}
|
||||
|
||||
/// 使用预构建消息提交(跳过自动 push user prompt)。
|
||||
///
|
||||
/// 与 `submit()` 不同,不自动添加 `user_text(prompt)`,也不自动插入 system prompt。
|
||||
/// 与 `submit()` 不同,不自动添加 `user_text(prompt)`。
|
||||
/// 调用方完全控制消息序列内容。
|
||||
pub async fn submit_messages(
|
||||
&mut self,
|
||||
messages: Vec<OpenaiChatMessage>,
|
||||
messages: Vec<Message>,
|
||||
tools: Vec<ToolDefinition>,
|
||||
) -> Result<ChatResponse, LlmError> {
|
||||
let openai_tools: Option<Vec<OpenaiTool>> = if tools.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
tools
|
||||
.iter()
|
||||
.map(|t| OpenaiTool::Function {
|
||||
function: t.clone(),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
|
||||
let request = ChatRequest {
|
||||
) -> Result<MessageResponse, LlmError> {
|
||||
let request = MessageRequest {
|
||||
model: self.config.model.clone(),
|
||||
messages,
|
||||
tools,
|
||||
tool_choice: ToolChoice::Auto,
|
||||
max_tokens: self.config.max_tokens,
|
||||
temperature: self.config.temperature,
|
||||
tools: openai_tools,
|
||||
tool_choice: Some(ToolChoice::Auto),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -198,11 +206,7 @@ impl LlmCycle {
|
||||
match self.provider.chat(request).await {
|
||||
Ok(response) => {
|
||||
if let Some(ref executor) = self.hook_executor {
|
||||
let post_request = ChatRequest {
|
||||
model: self.config.model.clone(),
|
||||
messages: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
let post_request = MessageRequest::default();
|
||||
let ctx = HookContext::new(crate::llm::hooks::HookEvent::PostRequest)
|
||||
.with_request(&post_request);
|
||||
executor
|
||||
@@ -229,8 +233,8 @@ impl LlmCycle {
|
||||
&mut self,
|
||||
prompt: String,
|
||||
tools: Vec<ToolDefinition>,
|
||||
) -> Result<ChatResponse, LlmError> {
|
||||
self.messages.push(OpenaiChatMessage::user_text(prompt));
|
||||
) -> Result<MessageResponse, LlmError> {
|
||||
self.messages.push(Message::user_text(prompt));
|
||||
|
||||
if let Some(ref config) = self.compact_config
|
||||
&& should_compact(&self.messages, config, &self.compact_state)
|
||||
@@ -273,6 +277,7 @@ impl LlmCycle {
|
||||
.await;
|
||||
}
|
||||
|
||||
// ponytail: Phase 2 直接存储 IR Message —— 不再转换。
|
||||
self.messages.push(response.message.clone());
|
||||
self.usage.add(&response.usage);
|
||||
|
||||
@@ -308,16 +313,38 @@ impl LlmCycle {
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交用户消息并返回语义事件流。
|
||||
/// 提交用户消息并返回语义事件流(Phase 2 / FIX-E 简化方案)。
|
||||
///
|
||||
/// 与 `submit` 不同,该方法返回流式事件而非完整响应。
|
||||
/// 适用于需要实时处理 LLM 输出的场景。
|
||||
///
|
||||
/// **Phase 2 设计决策(FIX-E)**:
|
||||
/// - `Item = StreamEvent`(**不再是 `Result<StreamEvent, LlmError>`**)
|
||||
/// - 错误统一以 `StreamEvent::Error { message }` 形式在流中传出
|
||||
/// - `self.messages` 不会在流结束后自动 push Assistant 响应 ——
|
||||
/// **调用方** 在收到 `StreamEvent::MessageComplete` 后手动调用
|
||||
/// `cycle.push_message(response.message.clone())` 完成消息历史追加
|
||||
///
|
||||
/// 调用模式(FIX-E 推荐):
|
||||
/// ```ignore
|
||||
/// use futures_util::StreamExt;
|
||||
/// let mut stream = cycle.submit_stream(prompt, tools).await?;
|
||||
/// let mut final_response: Option<MessageResponse> = None;
|
||||
/// while let Some(event) = stream.next().await {
|
||||
/// if let StreamEvent::MessageComplete { full_response } = &event {
|
||||
/// final_response = Some(full_response.clone());
|
||||
/// }
|
||||
/// }
|
||||
/// if let Some(resp) = final_response {
|
||||
/// cycle.push_message(resp.message.clone());
|
||||
/// }
|
||||
/// ```
|
||||
pub async fn submit_stream(
|
||||
&mut self,
|
||||
prompt: String,
|
||||
tools: Vec<ToolDefinition>,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>, LlmError> {
|
||||
self.messages.push(OpenaiChatMessage::user_text(prompt));
|
||||
self.messages.push(Message::user_text(prompt));
|
||||
|
||||
if let Some(ref config) = self.compact_config
|
||||
&& should_compact(&self.messages, config, &self.compact_state)
|
||||
@@ -330,6 +357,7 @@ impl LlmCycle {
|
||||
|
||||
let request = self.build_request(&tools);
|
||||
|
||||
// PreRequest hook
|
||||
if let Some(ref executor) = self.hook_executor {
|
||||
let ctx = HookContext::new(crate::llm::hooks::HookEvent::PreRequest)
|
||||
.with_request(&request);
|
||||
@@ -346,62 +374,27 @@ impl LlmCycle {
|
||||
}
|
||||
}
|
||||
|
||||
let chunk_stream = self.provider.chat_stream(request).await?;
|
||||
let ir_event_stream = self.provider.chat_stream(request).await?;
|
||||
let hook_executor = self.hook_executor.clone();
|
||||
let post_request = self.build_request(&tools);
|
||||
|
||||
// ponytail: Phase 2 简化方案(FIX-E)。流是延迟求值的,&mut self 无法进入闭包。
|
||||
// 调用方在收到 MessageComplete 后手动调 push_message()。
|
||||
Ok(Box::pin(stream! {
|
||||
use futures_util::StreamExt;
|
||||
let mut chunk_stream = chunk_stream;
|
||||
let mut ir_event_stream = ir_event_stream;
|
||||
|
||||
while let Some(result) = chunk_stream.next().await {
|
||||
while let Some(result) = ir_event_stream.next().await {
|
||||
match result {
|
||||
Ok(chunk) => {
|
||||
let mut assistant_text = String::new();
|
||||
let mut tool_started: Option<(String, String, String)> = None;
|
||||
|
||||
for choice in &chunk.choices {
|
||||
let delta = &choice.delta;
|
||||
|
||||
if let Some(content) = &delta.content {
|
||||
assistant_text.push_str(content);
|
||||
}
|
||||
|
||||
if let Some(tool_calls) = &delta.tool_calls
|
||||
&& let Some(tc) = tool_calls.first()
|
||||
{
|
||||
let crate::llm::types::OpenaiToolCall::Function { id, function } = tc;
|
||||
tool_started = Some((id.clone(), function.name.clone(), function.arguments.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
if !assistant_text.is_empty() {
|
||||
yield StreamEvent::AssistantTextDelta { text: assistant_text };
|
||||
}
|
||||
|
||||
if let Some((tool_call_id, tool_name, arguments)) = tool_started {
|
||||
let args: serde_json::Value = serde_json::from_str(&arguments)
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
yield StreamEvent::ToolExecutionStarted {
|
||||
tool_name,
|
||||
input: args,
|
||||
tool_call_id,
|
||||
};
|
||||
}
|
||||
|
||||
for choice in &chunk.choices {
|
||||
if let Some(finish_reason) = &choice.finish_reason {
|
||||
yield StreamEvent::TurnComplete {
|
||||
reason: *finish_reason,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(usage_info) = &chunk.usage {
|
||||
yield StreamEvent::CostUpdate { usage: *usage_info };
|
||||
Ok(event) => {
|
||||
let is_terminal =
|
||||
matches!(event, StreamEvent::MessageComplete { .. } | StreamEvent::Error { .. });
|
||||
yield event;
|
||||
if is_terminal {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// ponytail: 错误不再走 Err 分支,统一以 StreamEvent::Error 传出。
|
||||
if let Some(ref executor) = hook_executor {
|
||||
let ctx = crate::llm::hooks::HookContext::new(
|
||||
crate::llm::hooks::HookEvent::OnError,
|
||||
@@ -417,11 +410,13 @@ impl LlmCycle {
|
||||
}
|
||||
}
|
||||
|
||||
// ponytail: post_request hook 收到的消息列表**不包含本次 Assistant 响应**
|
||||
// (因为流是延迟求值,本轮响应还未到达)。调用方如需完整上下文,
|
||||
// 应在收到 MessageComplete 后手动触发 hook。
|
||||
if let Some(ref executor) = hook_executor {
|
||||
let ctx = crate::llm::hooks::HookContext::new(
|
||||
crate::llm::hooks::HookEvent::PostRequest,
|
||||
)
|
||||
.with_request(&post_request);
|
||||
);
|
||||
executor
|
||||
.execute(crate::llm::hooks::HookEvent::PostRequest, &ctx)
|
||||
.await;
|
||||
@@ -429,37 +424,16 @@ impl LlmCycle {
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_request(&self, tools: &[ToolDefinition]) -> ChatRequest {
|
||||
let mut messages = self.messages.clone();
|
||||
|
||||
if let Some(sys_prompt) = &self.system_prompt
|
||||
&& !messages
|
||||
.iter()
|
||||
.any(|m| matches!(m, OpenaiChatMessage::System { .. }))
|
||||
{
|
||||
messages.insert(0, OpenaiChatMessage::system_text(sys_prompt));
|
||||
}
|
||||
|
||||
let openai_tools: Option<Vec<OpenaiTool>> = if tools.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
tools
|
||||
.iter()
|
||||
.map(|t| OpenaiTool::Function {
|
||||
function: t.clone(),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
|
||||
ChatRequest {
|
||||
fn build_request(&self, tools: &[ToolDefinition]) -> MessageRequest {
|
||||
// ponytail: Phase 2 简化 —— 直接 clone self.messages,无任何转换 / system prompt 注入。
|
||||
// 系统消息如需存在,由调用方通过 `with_messages()` 自行管理。
|
||||
MessageRequest {
|
||||
model: self.config.model.clone(),
|
||||
messages,
|
||||
messages: self.messages.clone(),
|
||||
tools: tools.to_vec(),
|
||||
tool_choice: ToolChoice::Auto,
|
||||
max_tokens: self.config.max_tokens,
|
||||
temperature: self.config.temperature,
|
||||
tools: openai_tools,
|
||||
tool_choice: Some(ToolChoice::Auto),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -470,7 +444,7 @@ impl LlmCycle {
|
||||
async fn submit_request(
|
||||
&mut self,
|
||||
tools: &[ToolDefinition],
|
||||
) -> Result<ChatResponse, LlmError> {
|
||||
) -> Result<MessageResponse, LlmError> {
|
||||
let mut attempts = 0;
|
||||
|
||||
loop {
|
||||
@@ -548,13 +522,13 @@ impl LlmCycle {
|
||||
&mut self,
|
||||
prompt: String,
|
||||
registry: &crate::tools::ToolRegistry,
|
||||
) -> Result<ChatResponse, LlmError> {
|
||||
) -> Result<MessageResponse, LlmError> {
|
||||
let tools = registry.definitions();
|
||||
let max_turns = self.config.max_tool_turns.unwrap_or(10);
|
||||
let tool_timeout = self.config.tool_timeout_secs;
|
||||
let max_bytes = self.config.max_tool_result_bytes;
|
||||
|
||||
self.messages.push(OpenaiChatMessage::user_text(prompt));
|
||||
self.messages.push(Message::user_text(prompt));
|
||||
self.maybe_compact();
|
||||
|
||||
let mut turn = 0;
|
||||
@@ -570,10 +544,10 @@ impl LlmCycle {
|
||||
let response = self.submit_request(&tools).await?;
|
||||
|
||||
// 判断是否需要执行工具
|
||||
let should_execute = matches!(response.stop_reason, Some(FinishReason::ToolCalls))
|
||||
&& has_tool_calls_in_message(&response.message);
|
||||
let should_execute = matches!(response.stop_reason, StopReason::ToolUse)
|
||||
&& has_tool_calls_in_response(&response);
|
||||
|
||||
// 将 Assistant 响应(含 tool_calls 或最终文本)追加到消息历史
|
||||
// ponytail: Phase 2 直接存储 IR Message —— 不再转换。
|
||||
self.messages.push(response.message.clone());
|
||||
|
||||
if !should_execute {
|
||||
@@ -581,13 +555,13 @@ impl LlmCycle {
|
||||
}
|
||||
|
||||
// 解析 tool_calls 并执行
|
||||
let tool_calls = extract_tool_calls_from_message(&response.message);
|
||||
let calls: Vec<(String, serde_json::Value)> = tool_calls
|
||||
let tool_calls = extract_tool_calls_from_response(&response);
|
||||
let calls: Vec<(String, String, serde_json::Value)> = tool_calls
|
||||
.into_iter()
|
||||
.map(|(_id, name, args)| {
|
||||
let args: serde_json::Value =
|
||||
.map(|(id, name, args)| {
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&args).unwrap_or(serde_json::Value::Null);
|
||||
(name, args)
|
||||
(id, name, value)
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -595,9 +569,10 @@ impl LlmCycle {
|
||||
|
||||
// 回传工具结果
|
||||
for result in results {
|
||||
let content = match result.output {
|
||||
let is_error = result.output.is_err();
|
||||
let content = match &result.output {
|
||||
Ok(value) => {
|
||||
let serialized = serde_json::to_string(&value).unwrap_or_else(|e| {
|
||||
let serialized = serde_json::to_string(value).unwrap_or_else(|e| {
|
||||
tracing::warn!("工具结果序列化失败: {}", e);
|
||||
"{}".to_string()
|
||||
});
|
||||
@@ -613,8 +588,16 @@ impl LlmCycle {
|
||||
}
|
||||
};
|
||||
|
||||
self.messages
|
||||
.push(OpenaiChatMessage::tool_result(result.tool_name, content));
|
||||
// ponytail: 当前 self.messages 仍是 Vec<OpenaiChatMessage> (Phase 2 切换后
|
||||
// 改为 Message::tool_result 并传入 result.tool_call_id)。当前实现已经使用
|
||||
// 真实 tool_call_id 而非 tool_name 充当 —— 这条 FIX-A 修复与 Phase 2 消息切换
|
||||
// 同步生效。
|
||||
// ponytail: Phase 2 直接存储 Message::ToolResult,is_error 由 ToolInvocation.output 推断。
|
||||
self.messages.push(Message::tool_result(
|
||||
result.tool_call_id,
|
||||
content,
|
||||
is_error,
|
||||
));
|
||||
}
|
||||
|
||||
// 每轮工具执行后触发 compaction
|
||||
@@ -642,38 +625,35 @@ impl LlmCycle {
|
||||
}
|
||||
|
||||
/// 判断 Assistant 消息是否包含 tool_calls。
|
||||
fn has_tool_calls_in_message(msg: &OpenaiChatMessage) -> bool {
|
||||
matches!(
|
||||
msg,
|
||||
OpenaiChatMessage::Assistant {
|
||||
tool_calls: Some(calls),
|
||||
..
|
||||
} if !calls.is_empty()
|
||||
)
|
||||
fn has_tool_calls_in_response(response: &MessageResponse) -> bool {
|
||||
match &response.message {
|
||||
Message::Assistant { content } => content
|
||||
.iter()
|
||||
.any(|b| matches!(b, ContentBlock::ToolUse { .. })),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 提取 Assistant 消息中的 tool_calls。
|
||||
///
|
||||
/// 返回 `(tool_call_id, tool_name, arguments_json_string)` 列表。
|
||||
fn extract_tool_calls_from_message(
|
||||
msg: &OpenaiChatMessage,
|
||||
///
|
||||
/// ponytail: 当前 Phase 0 实现,`arguments_json_string` 内含 JSON 序列化的 input。
|
||||
/// 消费方在调用 `registry.invoke_all()` 时反序列化一次。该小段冗余序列化
|
||||
/// 在 Phase 2 切换为 `Vec<Message>` 后可整体消除。
|
||||
fn extract_tool_calls_from_response(
|
||||
response: &MessageResponse,
|
||||
) -> Vec<(String, String, String)> {
|
||||
if let OpenaiChatMessage::Assistant {
|
||||
tool_calls: Some(calls),
|
||||
..
|
||||
} = msg
|
||||
{
|
||||
calls
|
||||
.iter()
|
||||
.map(|c| match c {
|
||||
OpenaiToolCall::Function { id, function } => {
|
||||
(id.clone(), function.name.clone(), function.arguments.clone())
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
let mut out = Vec::new();
|
||||
if let Message::Assistant { content } = &response.message {
|
||||
for block in content {
|
||||
if let ContentBlock::ToolUse { id, name, input } = block {
|
||||
let args = serde_json::to_string(input).unwrap_or_else(|_| "null".to_string());
|
||||
out.push((id.clone(), name.clone(), args));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 截断工具结果到指定字节数。
|
||||
@@ -691,19 +671,21 @@ fn truncate_tool_result(s: &str, max_bytes: usize) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm::types::{ContentField, OpenaiContentPart};
|
||||
use crate::llm::provider::{ProviderCapabilities, ProviderFeatures};
|
||||
use crate::tools::{BaseTool, ToolRegistry};
|
||||
use async_trait::async_trait;
|
||||
use futures_core::Stream;
|
||||
use serde_json::{json, Value};
|
||||
use std::pin::Pin;
|
||||
|
||||
/// 模拟 Provider —— 预定义响应序列,按调用顺序返回。
|
||||
struct MockProvider {
|
||||
responses: std::sync::Mutex<Vec<ChatResponse>>,
|
||||
responses: std::sync::Mutex<Vec<MessageResponse>>,
|
||||
call_count: std::sync::Mutex<u32>,
|
||||
}
|
||||
|
||||
impl MockProvider {
|
||||
fn new(responses: Vec<ChatResponse>) -> Self {
|
||||
fn new(responses: Vec<MessageResponse>) -> Self {
|
||||
Self {
|
||||
responses: std::sync::Mutex::new(responses),
|
||||
call_count: std::sync::Mutex::new(0),
|
||||
@@ -713,7 +695,7 @@ mod tests {
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for MockProvider {
|
||||
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, LlmError> {
|
||||
async fn chat(&self, _request: MessageRequest) -> Result<MessageResponse, LlmError> {
|
||||
let mut count = self.call_count.lock().unwrap();
|
||||
*count += 1;
|
||||
let mut responses = self.responses.lock().unwrap();
|
||||
@@ -722,45 +704,61 @@ mod tests {
|
||||
}
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_usage() -> crate::llm::types::Usage {
|
||||
crate::llm::types::Usage::default()
|
||||
}
|
||||
|
||||
fn assistant_text_response(text: &str) -> ChatResponse {
|
||||
ChatResponse {
|
||||
message: OpenaiChatMessage::assistant_text(text),
|
||||
fn assistant_text_response(text: &str) -> MessageResponse {
|
||||
MessageResponse {
|
||||
id: String::new(),
|
||||
model: String::new(),
|
||||
message: Message::Assistant {
|
||||
content: vec![ContentBlock::Text {
|
||||
text: text.into(),
|
||||
}],
|
||||
},
|
||||
usage: empty_usage(),
|
||||
stop_reason: Some(FinishReason::Stop),
|
||||
stop_reason: StopReason::Stop,
|
||||
extra: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn assistant_tool_call_response(
|
||||
calls: Vec<(&str, &str, &str)>,
|
||||
) -> ChatResponse {
|
||||
use crate::llm::types::{OpenaiToolCall, FunctionCall};
|
||||
let tool_calls: Vec<OpenaiToolCall> = calls
|
||||
fn assistant_tool_call_response(calls: Vec<(&str, &str, &str)>) -> MessageResponse {
|
||||
let tool_blocks: Vec<ContentBlock> = calls
|
||||
.into_iter()
|
||||
.map(|(id, name, args)| OpenaiToolCall::Function {
|
||||
id: id.to_string(),
|
||||
function: FunctionCall {
|
||||
.map(|(id, name, args)| {
|
||||
let input: serde_json::Value =
|
||||
serde_json::from_str(args).unwrap_or(serde_json::Value::Null);
|
||||
ContentBlock::ToolUse {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
arguments: args.to_string(),
|
||||
},
|
||||
input,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
ChatResponse {
|
||||
message: OpenaiChatMessage::Assistant {
|
||||
content: ContentField::Array(vec![OpenaiContentPart::Text {
|
||||
text: String::new(),
|
||||
}]),
|
||||
refusal: None,
|
||||
name: None,
|
||||
tool_calls: Some(tool_calls),
|
||||
},
|
||||
MessageResponse {
|
||||
id: String::new(),
|
||||
model: String::new(),
|
||||
message: Message::Assistant { content: tool_blocks },
|
||||
usage: empty_usage(),
|
||||
stop_reason: Some(FinishReason::ToolCalls),
|
||||
stop_reason: StopReason::ToolUse,
|
||||
extra: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -790,7 +788,6 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_submit_with_tools_single_turn() {
|
||||
// 第一轮:返回 tool_call;第二轮:返回最终文本
|
||||
let responses = vec![
|
||||
assistant_tool_call_response(vec![("call_1", "add", r#"{"a":1,"b":2}"#)]),
|
||||
assistant_text_response("答案是 3"),
|
||||
@@ -805,24 +802,37 @@ mod tests {
|
||||
.submit_with_tools("1+2=?".to_string(), ®istry)
|
||||
.await
|
||||
.unwrap();
|
||||
// 验证最终响应是文本响应
|
||||
assert!(matches!(
|
||||
response.message,
|
||||
OpenaiChatMessage::Assistant { .. }
|
||||
));
|
||||
// 最终响应是 Assistant 消息
|
||||
assert!(matches!(response.message, Message::Assistant { .. }));
|
||||
|
||||
// 验证消息历史:user, assistant(tool_calls), tool, assistant(text)
|
||||
// 验证消息历史:
|
||||
// user, assistant(含 tool_use), tool_result, assistant(text)
|
||||
let messages = cycle.messages();
|
||||
assert_eq!(messages.len(), 4);
|
||||
assert!(matches!(messages[0], OpenaiChatMessage::User { .. }));
|
||||
assert!(matches!(messages[1], OpenaiChatMessage::Assistant { .. }));
|
||||
assert!(matches!(messages[2], OpenaiChatMessage::Tool { .. }));
|
||||
assert!(matches!(messages[3], OpenaiChatMessage::Assistant { .. }));
|
||||
assert!(matches!(messages[0], Message::User { .. }));
|
||||
assert!(matches!(
|
||||
messages[1],
|
||||
Message::Assistant {
|
||||
content: _,
|
||||
}
|
||||
));
|
||||
if let Message::Assistant { content } = &messages[1] {
|
||||
assert!(content
|
||||
.iter()
|
||||
.any(|b| matches!(b, ContentBlock::ToolUse { .. })));
|
||||
}
|
||||
assert!(matches!(
|
||||
messages[2],
|
||||
Message::ToolResult {
|
||||
is_error: false,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(matches!(messages[3], Message::Assistant { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_submit_with_tools_multi_turn() {
|
||||
// 3 轮 tool 调用后给出最终答案
|
||||
let responses = vec![
|
||||
assistant_tool_call_response(vec![("call_1", "add", r#"{"a":1,"b":2}"#)]),
|
||||
assistant_tool_call_response(vec![("call_2", "add", r#"{"a":3,"b":4}"#)]),
|
||||
@@ -839,10 +849,7 @@ mod tests {
|
||||
.submit_with_tools("计算总和".to_string(), ®istry)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
response.message,
|
||||
OpenaiChatMessage::Assistant { .. }
|
||||
));
|
||||
assert!(matches!(response.message, Message::Assistant { .. }));
|
||||
|
||||
// user + 3*(assistant + tool) + final assistant = 8
|
||||
let messages = cycle.messages();
|
||||
@@ -851,10 +858,10 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_submit_with_tools_max_turns_exceeded() {
|
||||
// 配置 max_tool_turns = 2
|
||||
let mut config = CycleConfig::default();
|
||||
config.max_tool_turns = Some(2);
|
||||
// 4 轮 tool 调用 + 终止
|
||||
let config = CycleConfig {
|
||||
max_tool_turns: Some(2),
|
||||
..Default::default()
|
||||
};
|
||||
let responses = vec![
|
||||
assistant_tool_call_response(vec![("c1", "add", r#"{"a":1,"b":1}"#)]),
|
||||
assistant_tool_call_response(vec![("c2", "add", r#"{"a":1,"b":1}"#)]),
|
||||
@@ -867,15 +874,12 @@ mod tests {
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(std::sync::Arc::new(AddTool)).unwrap();
|
||||
|
||||
let result = cycle
|
||||
.submit_with_tools("test".to_string(), ®istry)
|
||||
.await;
|
||||
let result = cycle.submit_with_tools("test".to_string(), ®istry).await;
|
||||
assert!(matches!(result, Err(LlmError::Other(msg)) if msg.contains("达到最大工具循环轮次")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_submit_with_tools_no_tool_call_response() {
|
||||
// LLM 直接给出最终响应(不调用工具)
|
||||
let responses = vec![assistant_text_response("直接回答")];
|
||||
let provider = Box::new(MockProvider::new(responses));
|
||||
let mut cycle = LlmCycle::new(provider, CycleConfig::default());
|
||||
@@ -887,10 +891,7 @@ mod tests {
|
||||
.submit_with_tools("直接回答".to_string(), ®istry)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
response.message,
|
||||
OpenaiChatMessage::Assistant { .. }
|
||||
));
|
||||
assert!(matches!(response.message, Message::Assistant { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -911,7 +912,6 @@ mod tests {
|
||||
fn test_truncate_tool_result_chinese_chars() {
|
||||
let s = "中".repeat(100);
|
||||
let truncated = truncate_tool_result(&s, 50);
|
||||
// 不会在字符中间截断
|
||||
assert!(truncated.starts_with("中"));
|
||||
}
|
||||
}
|
||||
|
||||
+15
-13
@@ -5,33 +5,35 @@ use std::time::Duration;
|
||||
/// 错误按可重试性分为两类:
|
||||
/// - **可重试**:`RateLimit`、`Timeout`、状态码 >= 500
|
||||
/// - **不可重试**:`Authentication`、`ContextLength`、状态码 4xx(除 429)
|
||||
///
|
||||
/// 错误消息面向最终用户(中文),并尽量附带可操作的修复建议(如检查 API key、减少上下文)。
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum LlmError {
|
||||
/// API 认证失败(如 API key 无效)。
|
||||
#[error("认证失败: {0}")]
|
||||
/// API 认证失败(API key 无效、过期或权限不足)。
|
||||
#[error("LLM 认证失败: {0}。请检查环境变量中的 API key(如 OPENAI_API_KEY / ANTHROPIC_API_KEY)是否正确")]
|
||||
Authentication(String),
|
||||
|
||||
/// 请求被限流,可选地附带重试等待时间。
|
||||
#[error("限流(retry_after={retry_after:?})")]
|
||||
/// 请求被限流,可选地附带重试等待时间。可重试。
|
||||
#[error("LLM 限流(服务方),建议等待 {retry_after:?} 后重试")]
|
||||
RateLimit { retry_after: Option<Duration> },
|
||||
|
||||
/// HTTP 请求失败,包含状态码和响应体。
|
||||
#[error("请求失败(status={status}): {body}")]
|
||||
/// HTTP 请求失败(网络错误或非 2xx 状态码),包含状态码与响应体。
|
||||
#[error("LLM 请求失败(HTTP {status}): {body}。请检查 Provider 端点地址(base_url)和网络连通性")]
|
||||
Request { status: u16, body: String },
|
||||
|
||||
/// 请求超时。
|
||||
#[error("请求超时(duration={duration:?})")]
|
||||
/// 请求超时。可重试。
|
||||
#[error("LLM 请求超时({duration:?})。请检查网络连接,或调大 LlmCycle 超时配置")]
|
||||
Timeout { duration: Duration },
|
||||
|
||||
/// 流式响应处理错误(预留)。
|
||||
#[error("流式响应错误: {0}")]
|
||||
/// 流式响应处理错误(SSE 解析失败、流中断等)。可重试。
|
||||
#[error("LLM 流式响应错误: {0}。可重试或改用非流式接口")]
|
||||
Stream(String),
|
||||
|
||||
/// 上下文长度超限。
|
||||
#[error("上下文超限(actual={actual}, limit={limit})")]
|
||||
/// 上下文长度超出模型窗口限制。
|
||||
#[error("LLM 上下文超限:当前 {actual} tokens > 模型上限 {limit} tokens。请减少消息历史、缩短 prompt,或启用 auto-compaction(llm::compact)")]
|
||||
ContextLength { actual: u32, limit: u32 },
|
||||
|
||||
/// 其他未分类的 LLM 调用失败。
|
||||
#[error("LLM 调用失败: {0}")]
|
||||
Other(String),
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -3,7 +3,7 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::types::ChatRequest;
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
|
||||
/// 生命周期钩子事件点。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -30,7 +30,7 @@ pub struct HookContext<'a> {
|
||||
/// 当前事件点。
|
||||
pub event: HookEvent,
|
||||
/// 当前的请求(部分事件点可用)。
|
||||
pub request: Option<&'a ChatRequest>,
|
||||
pub request: Option<&'a MessageRequest>,
|
||||
/// 当前错误(仅 OnError 和 OnRetry 可用)。
|
||||
pub error: Option<&'a LlmError>,
|
||||
/// 当前重试次数(从 1 开始,仅 OnRetry 可用)。
|
||||
@@ -53,7 +53,7 @@ impl<'a> HookContext<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_request(mut self, request: &'a ChatRequest) -> Self {
|
||||
pub(crate) fn with_request(mut self, request: &'a MessageRequest) -> Self {
|
||||
self.request = Some(request);
|
||||
self
|
||||
}
|
||||
@@ -75,6 +75,7 @@ impl<'a> HookContext<'a> {
|
||||
}
|
||||
|
||||
/// 设置 plan step 序号(仅 OnPlanStepComplete 使用,Phase 4b 新增)。
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn with_plan_step_index(mut self, plan_step_index: usize) -> Self {
|
||||
self.plan_step_index = Some(plan_step_index);
|
||||
self
|
||||
|
||||
+305
@@ -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);
|
||||
}
|
||||
}
|
||||
+92
-28
@@ -1,18 +1,33 @@
|
||||
pub mod anthropic;
|
||||
pub mod openai;
|
||||
pub mod openai_compat;
|
||||
pub mod registry;
|
||||
|
||||
use std::pin::Pin;
|
||||
|
||||
use tokio_stream::Stream;
|
||||
use futures_core::Stream;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::types::{ChatRequest, ChatResponse, OpenaiChatChunk};
|
||||
use async_trait::async_trait;
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
use crate::llm::types::response_v2::{MessageResponse, StreamEvent};
|
||||
|
||||
/// Provider 类型枚举 —— `create_provider()` 在编译期 exhaustive match 中使用。
|
||||
///
|
||||
/// 设计依据(见 `docs/10-llm-provider-refinement.md` §2.5 Decision-05):
|
||||
/// 当前协议数量(5 种以内)完全可控,enum 的编译期安全检查优于运行时的 `HashMap::get()`。
|
||||
/// 未来如果扩展到 15+ 种以上,再改为注册表模式。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProviderType {
|
||||
OpenAI,
|
||||
/// OpenAI Chat Completions API(兼容 DeepSeek / Qwen 等 `/chat/completions` 端点)。
|
||||
OpenaiChat,
|
||||
/// OpenAI Response API。
|
||||
OpenaiResponse,
|
||||
/// Anthropic Messages API。
|
||||
Anthropic,
|
||||
/// DeepSeek(OpenAI-compatible `/chat/completions`)。
|
||||
DeepSeek,
|
||||
/// Qwen / 阿里云百炼(OpenAI-compatible `/chat/completions`)。
|
||||
Qwen,
|
||||
}
|
||||
|
||||
@@ -21,60 +36,109 @@ impl std::str::FromStr for ProviderType {
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"openai" => Ok(ProviderType::OpenAI),
|
||||
"openai" | "openai-chat" | "openai_chat" => Ok(ProviderType::OpenaiChat),
|
||||
"openai-response" | "openai_response" | "response" => Ok(ProviderType::OpenaiResponse),
|
||||
"anthropic" | "claude" => Ok(ProviderType::Anthropic),
|
||||
"deepseek" => Ok(ProviderType::DeepSeek),
|
||||
"qwen" | "dashscope" | "tongyi" => Ok(ProviderType::Qwen),
|
||||
_ => Err(format!("未知的 Provider 类型: {}", s)),
|
||||
_ => Err(format!("未知的 Provider 类型: {s}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 构造参数 —— 通用 base_url + api_key + model。
|
||||
pub struct ProviderConfig {
|
||||
pub base_url: String,
|
||||
pub api_key: String,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
/// Provider 工厂 —— exhaustive match 在编译期保证新 Provider 被注册。
|
||||
pub fn create_provider(
|
||||
provider_type: ProviderType,
|
||||
config: ProviderConfig,
|
||||
) -> Result<Box<dyn LlmProvider>, LlmError> {
|
||||
match provider_type {
|
||||
ProviderType::OpenAI => Ok(Box::new(openai::OpenaiProvider::new(
|
||||
ProviderType::OpenaiChat => Ok(Box::new(openai::OpenaiChatProvider::new(
|
||||
config.base_url,
|
||||
config.api_key,
|
||||
config.model,
|
||||
))),
|
||||
ProviderType::OpenaiResponse => Err(LlmError::Other(
|
||||
"OpenaiResponse Provider 在 Phase 1 暂不实现;请使用 OpenaiChat".into(),
|
||||
)),
|
||||
ProviderType::Anthropic => Ok(Box::new(anthropic::AnthropicProvider::new(
|
||||
config.base_url,
|
||||
config.api_key,
|
||||
config.model,
|
||||
))),
|
||||
ProviderType::DeepSeek => Ok(Box::new(openai_compat::DeepSeekProvider::new(
|
||||
config.base_url,
|
||||
config.api_key,
|
||||
config.model,
|
||||
))),
|
||||
ProviderType::Qwen => Ok(Box::new(openai_compat::QwenProvider::new(
|
||||
config.base_url,
|
||||
config.api_key,
|
||||
config.model,
|
||||
))),
|
||||
ProviderType::DeepSeek => {
|
||||
unimplemented!("DeepSeek Provider 尚未实现")
|
||||
}
|
||||
ProviderType::Qwen => {
|
||||
unimplemented!("Qwen Provider 尚未实现")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 能力描述 —— 静态元信息,调用方据此决定可用特性。
|
||||
///
|
||||
/// 设计依据(见 `docs/10-llm-provider-refinement.md` §4 任务 6 决策):
|
||||
/// `ProviderCapabilities` 与 trait 同文件(`provider.rs`),不分散到类型目录。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderCapabilities {
|
||||
/// 人类可读的 Provider 名(如 `"openai"` / `"anthropic"`)。
|
||||
pub provider_name: &'static str,
|
||||
/// 支持的模型列表(`None` 表示"未列举全部")。
|
||||
pub supported_models: Option<Vec<String>>,
|
||||
/// 详细功能开关。
|
||||
pub features: ProviderFeatures,
|
||||
}
|
||||
|
||||
/// Provider 功能开关集合。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ProviderFeatures {
|
||||
/// 是否支持流式响应。
|
||||
pub streaming: bool,
|
||||
/// 是否支持 thinking / 推理。
|
||||
pub thinking: bool,
|
||||
/// 是否支持图片输入。
|
||||
pub vision: bool,
|
||||
/// 是否支持音频输入。
|
||||
pub audio_input: bool,
|
||||
/// 是否支持工具调用。
|
||||
pub tool_use: bool,
|
||||
/// 是否支持并行工具调用。
|
||||
pub parallel_tool_calls: bool,
|
||||
/// system prompt 是否放在 messages 中(`true`)还是顶层 `system` 字段(`false`)。
|
||||
pub system_prompt_in_messages: bool,
|
||||
/// 模型上下文窗口(tokens);`0` 表示未知。
|
||||
pub max_context_window: u32,
|
||||
}
|
||||
|
||||
/// LLM Provider 抽象接口。
|
||||
///
|
||||
/// 所有具体的 LLM 后端实现(OpenAI、Anthropic、Azure 等)
|
||||
/// 所有具体的 LLM 后端实现(OpenAI、Anthropic、DeepSeek、Qwen 等)
|
||||
/// 均需实现此 trait,以实现可插拔替换。
|
||||
#[async_trait]
|
||||
///
|
||||
/// 修订(Phase 0):签名由 `chat(ChatRequest) → ChatResponse` 切换为
|
||||
/// `chat(MessageRequest) → MessageResponse`,`chat_stream` 返回新 `StreamEvent` 流,
|
||||
/// 新增 `capabilities()` 方法。
|
||||
#[async_trait::async_trait]
|
||||
pub trait LlmProvider: Send + Sync {
|
||||
/// 发送聊天请求并返回完整响应。
|
||||
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, LlmError>;
|
||||
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError>;
|
||||
|
||||
/// 流式聊天请求 —— 返回原始 SSE chunk 流。
|
||||
///
|
||||
/// 默认实现回退到非流式调用(包装为单元素流)。
|
||||
/// 流式聊天请求 —— 返回新 IR `StreamEvent` 流。
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
request: ChatRequest,
|
||||
) -> Result<
|
||||
Pin<Box<dyn Stream<Item = Result<OpenaiChatChunk, LlmError>> + Send>>,
|
||||
LlmError,
|
||||
> {
|
||||
let response = self.chat(request).await?;
|
||||
let chunk = OpenaiChatChunk::from(response);
|
||||
Ok(Box::pin(tokio_stream::once(Ok(chunk))))
|
||||
}
|
||||
request: MessageRequest,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>;
|
||||
|
||||
/// 返回 Provider 静态能力描述。
|
||||
fn capabilities(&self) -> ProviderCapabilities;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+879
-117
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,234 @@
|
||||
//! DeepSeek / Qwen Provider —— OpenAI-compatible 协议的 newtype 包装。
|
||||
//!
|
||||
//! DeepSeek 与 Qwen 都是 OpenAI-compatible(共享 `/v1/chat/completions` 协议),
|
||||
//! 但二者 base_url 与 Qwen 需要额外请求头(`X-DashScope-SSE: enable`)。
|
||||
//! 通过 newtype 包装 `GenericOpenaiProvider` 提供:
|
||||
//! - 独立的 `capabilities().provider_name`
|
||||
//! - 未来可独立扩展(如 Qwen 的特殊错误映射、DeepSeek 的特殊响应解析)
|
||||
//!
|
||||
//! 类型别名方案(`pub type DeepSeekProvider = GenericOpenaiProvider`)被否决:类型别名
|
||||
//! 无法在编译期区分 DeepSeek vs OpenAI 调用,编译期安全检查失效。
|
||||
//! (参考 `docs/10b-phase1-provider-adaptation.md` §"OpenAI-compatible 复用策略")
|
||||
|
||||
use std::pin::Pin;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures_core::Stream;
|
||||
|
||||
use super::openai::GenericOpenaiProvider;
|
||||
use super::ProviderCapabilities;
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
use crate::llm::types::response_v2::{MessageResponse, StreamEvent};
|
||||
use crate::llm::provider::LlmProvider;
|
||||
|
||||
// =============================================================================
|
||||
// DeepSeek
|
||||
// =============================================================================
|
||||
|
||||
pub struct DeepSeekProvider(pub GenericOpenaiProvider);
|
||||
|
||||
impl DeepSeekProvider {
|
||||
pub fn new(base_url: String, api_key: String, model: String) -> Self {
|
||||
let url = if base_url.is_empty() {
|
||||
"https://api.deepseek.com".to_string()
|
||||
} else {
|
||||
base_url
|
||||
};
|
||||
Self(GenericOpenaiProvider::new_with_name(
|
||||
url,
|
||||
api_key,
|
||||
model,
|
||||
"deepseek",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl DeepSeekProvider {
|
||||
/// 测试中(带 mock_client)使用的构造器。
|
||||
pub fn new_with_client(
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
model: String,
|
||||
client: reqwest::Client,
|
||||
) -> Self {
|
||||
let url = if base_url.is_empty() {
|
||||
"https://api.deepseek.com".to_string()
|
||||
} else {
|
||||
base_url
|
||||
};
|
||||
let mut inner = GenericOpenaiProvider::new_with_name(url, api_key, model, "deepseek");
|
||||
inner.http_client = client;
|
||||
Self(inner)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for DeepSeekProvider {
|
||||
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError> {
|
||||
self.0.chat(request).await
|
||||
}
|
||||
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
request: MessageRequest,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
|
||||
{
|
||||
self.0.chat_stream(request).await
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
let mut caps = self.0.capabilities();
|
||||
caps.provider_name = "deepseek";
|
||||
caps
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Qwen
|
||||
// =============================================================================
|
||||
|
||||
pub struct QwenProvider(pub GenericOpenaiProvider);
|
||||
|
||||
impl QwenProvider {
|
||||
pub fn new(base_url: String, api_key: String, model: String) -> Self {
|
||||
let url = if base_url.is_empty() {
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1".to_string()
|
||||
} else {
|
||||
base_url
|
||||
};
|
||||
// Qwen 兼容模式流式需要 DashScope 特定的 SSE 启用头。
|
||||
let inner = GenericOpenaiProvider::new_with_name_and_headers(
|
||||
url,
|
||||
api_key,
|
||||
model,
|
||||
"qwen",
|
||||
vec![("X-DashScope-SSE".to_string(), "enable".to_string())],
|
||||
);
|
||||
Self(inner)
|
||||
}
|
||||
|
||||
/// 测试构造器。
|
||||
pub fn new_with_client(
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
model: String,
|
||||
client: reqwest::Client,
|
||||
) -> Self {
|
||||
let url = if base_url.is_empty() {
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1".to_string()
|
||||
} else {
|
||||
base_url
|
||||
};
|
||||
let mut inner = GenericOpenaiProvider::new_with_name_and_headers(
|
||||
url,
|
||||
api_key,
|
||||
model,
|
||||
"qwen",
|
||||
vec![("X-DashScope-SSE".to_string(), "enable".to_string())],
|
||||
);
|
||||
inner.http_client = client;
|
||||
Self(inner)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for QwenProvider {
|
||||
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError> {
|
||||
self.0.chat(request).await
|
||||
}
|
||||
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
request: MessageRequest,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
|
||||
{
|
||||
self.0.chat_stream(request).await
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
let mut caps = self.0.capabilities();
|
||||
caps.provider_name = "qwen";
|
||||
caps
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
use crate::llm::types::message::Message as IrMessage;
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
#[tokio::test]
|
||||
async fn deepseek_chat_basic_text_response() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/chat/completions"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"id": "ds-1",
|
||||
"object": "chat.completion",
|
||||
"created": 1718000000,
|
||||
"model": "deepseek-chat",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "DeepSeek hi"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = DeepSeekProvider::new(
|
||||
server.uri(),
|
||||
"sk-test".into(),
|
||||
"deepseek-chat".into(),
|
||||
);
|
||||
let response = provider
|
||||
.chat(MessageRequest {
|
||||
model: "deepseek-chat".into(),
|
||||
messages: vec![IrMessage::user_text("hi")],
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.text(), "DeepSeek hi");
|
||||
assert_eq!(provider.capabilities().provider_name, "deepseek");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn qwen_chat_basic_text_response() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/chat/completions"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"id": "qw-1",
|
||||
"object": "chat.completion",
|
||||
"created": 1718000000,
|
||||
"model": "qwen-plus",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "Qwen 你好"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 6, "completion_tokens": 2, "total_tokens": 8}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = QwenProvider::new(server.uri(), "sk-test".into(), "qwen-plus".into());
|
||||
let response = provider
|
||||
.chat(MessageRequest {
|
||||
model: "qwen-plus".into(),
|
||||
messages: vec![IrMessage::user_text("hi")],
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.text(), "Qwen 你好");
|
||||
assert_eq!(provider.capabilities().provider_name, "qwen");
|
||||
}
|
||||
}
|
||||
+140
-44
@@ -1,4 +1,14 @@
|
||||
//! 流式事件系统 —— 将 LLM 流式响应解析为语义化事件。
|
||||
//!
|
||||
//! Phase 0 修订(参见 `docs/10a-phase0-types-and-trait.md` §"StreamEvent 命名冲突处理"):
|
||||
//! - 对外暴露的 `StreamEvent` 是高精度 IR 版本(来自 `response_v2::StreamEvent`)。
|
||||
//! - 旧变体(`AssistantTextDelta` / `ToolExecutionStarted` 等)重命名为 `LegacyStreamEvent`
|
||||
//! 放在 `crate::llm::types::old_stream` 模块,本文件内部消费。
|
||||
//! - Phase 1 重写 Provider 时可直接消费新事件流后整体删除 `LegacyStreamEvent` 相关代码。
|
||||
//!
|
||||
//! 当前实现:旧的 `parse_chunk_stream` 内部消费 `OpenaiChatChunk`,映射为
|
||||
//! `LegacyStreamEvent`,再在 `LegacyToIrEventStream` 中映射为新 IR `StreamEvent`
|
||||
//! 后输出。Phase 1 会重写此层(OpenAI Provider 直接产出新事件流)。
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
@@ -9,54 +19,47 @@ use futures_util::FutureExt;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::types::{FinishReason, OpenaiChatChunk, OpenaiToolCall, Usage};
|
||||
use crate::llm::types::old_stream::LegacyStreamEvent;
|
||||
use crate::llm::types::response_v2::MessageResponse;
|
||||
use crate::llm::types::response_v2::StopReason;
|
||||
use crate::llm::types::usage::Usage;
|
||||
use crate::llm::types::{OpenaiChatChunk, OpenaiToolCall};
|
||||
|
||||
/// 流式事件 —— LLM 调用全生命周期的语义化事件。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum StreamEvent {
|
||||
/// 助手回复文本增量。
|
||||
AssistantTextDelta { text: String },
|
||||
/// 工具调用开始。
|
||||
ToolExecutionStarted {
|
||||
tool_name: String,
|
||||
input: Value,
|
||||
tool_call_id: String,
|
||||
},
|
||||
/// 工具调用完成。
|
||||
ToolExecutionCompleted {
|
||||
tool_name: String,
|
||||
output: Value,
|
||||
is_error: bool,
|
||||
},
|
||||
/// Token 用量更新。
|
||||
CostUpdate { usage: Usage },
|
||||
/// 一轮会话完成。
|
||||
TurnComplete { reason: FinishReason },
|
||||
/// 错误事件。
|
||||
Error { message: String },
|
||||
}
|
||||
// 唯一的对外 `StreamEvent` 定义(高精度 IR 事件,来自 `response_v2`)。
|
||||
//
|
||||
// 此 `pub use` 同时起到两个作用:
|
||||
// 1. 让 `crate::llm::stream::StreamEvent` 路径仍指向新高精度 IR 事件,
|
||||
// 保持与既有 `use crate::llm::stream::StreamEvent` 的代码兼容;
|
||||
// 2. 把模块内部的 `StreamEvent` 名字指向 `response_v2::StreamEvent`。
|
||||
pub use crate::llm::types::response_v2::StreamEvent;
|
||||
|
||||
impl StreamEvent {
|
||||
fn error(message: impl Into<String>) -> Self {
|
||||
Self::Error {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 将原始 OpenaiChatChunk 流解析为 StreamEvent 流。
|
||||
/// 将原始 OpenaiChatChunk 流解析为新高精度 IR StreamEvent 流。
|
||||
///
|
||||
/// ponytail: 每个产出事件都用 `Result<_, LlmError>` 包装,让上层 `chat_stream`
|
||||
/// trait 方法直接消费并保持错误传播链。当前 `LegacyToIrEventStream` 内部
|
||||
/// 不会产生错误,所有结果都是 `Ok`;后续 Phase 1 重写 Provider 时,
|
||||
/// 真实 IR 流转换可在此层注入 error 事件。
|
||||
pub fn parse_chunk_stream(
|
||||
chunks: Pin<Box<dyn futures_core::Stream<Item = Result<OpenaiChatChunk, LlmError>> + Send>>,
|
||||
) -> Pin<Box<dyn futures_core::Stream<Item = StreamEvent> + Send>> {
|
||||
Box::pin(ChunkToEventStream { chunks })
|
||||
) -> Pin<Box<dyn futures_core::Stream<Item = Result<StreamEvent, LlmError>> + Send>> {
|
||||
let legacy = parse_chunk_stream_legacy(chunks);
|
||||
Box::pin(LegacyToIrEventStream { inner: legacy })
|
||||
}
|
||||
|
||||
struct ChunkToEventStream {
|
||||
// --- 内部:chunk → LegacyStreamEvent ---
|
||||
|
||||
fn parse_chunk_stream_legacy(
|
||||
chunks: Pin<Box<dyn futures_core::Stream<Item = Result<OpenaiChatChunk, LlmError>> + Send>>,
|
||||
) -> Pin<Box<dyn futures_core::Stream<Item = LegacyStreamEvent> + Send>> {
|
||||
Box::pin(ChunkToLegacyEventStream { chunks })
|
||||
}
|
||||
|
||||
struct ChunkToLegacyEventStream {
|
||||
chunks: Pin<Box<dyn futures_core::Stream<Item = Result<OpenaiChatChunk, LlmError>> + Send>>,
|
||||
}
|
||||
|
||||
impl Stream for ChunkToEventStream {
|
||||
type Item = StreamEvent;
|
||||
impl Stream for ChunkToLegacyEventStream {
|
||||
type Item = LegacyStreamEvent;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
let this = &mut *self;
|
||||
@@ -66,7 +69,7 @@ impl Stream for ChunkToEventStream {
|
||||
let delta = &choice.delta;
|
||||
|
||||
if let Some(content) = &delta.content {
|
||||
return Poll::Ready(Some(StreamEvent::AssistantTextDelta {
|
||||
return Poll::Ready(Some(LegacyStreamEvent::AssistantTextDelta {
|
||||
text: content.clone(),
|
||||
}));
|
||||
}
|
||||
@@ -77,7 +80,7 @@ impl Stream for ChunkToEventStream {
|
||||
let OpenaiToolCall::Function { id, function } = tc;
|
||||
let args: Value =
|
||||
serde_json::from_str(&function.arguments).unwrap_or(Value::Null);
|
||||
return Poll::Ready(Some(StreamEvent::ToolExecutionStarted {
|
||||
return Poll::Ready(Some(LegacyStreamEvent::ToolExecutionStarted {
|
||||
tool_name: function.name.clone(),
|
||||
input: args,
|
||||
tool_call_id: id.clone(),
|
||||
@@ -85,24 +88,117 @@ impl Stream for ChunkToEventStream {
|
||||
}
|
||||
|
||||
if let Some(finish_reason) = &choice.finish_reason {
|
||||
return Poll::Ready(Some(StreamEvent::TurnComplete {
|
||||
return Poll::Ready(Some(LegacyStreamEvent::TurnComplete {
|
||||
reason: *finish_reason,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(usage) = &chunk.usage {
|
||||
return Poll::Ready(Some(StreamEvent::CostUpdate {
|
||||
return Poll::Ready(Some(LegacyStreamEvent::CostUpdate {
|
||||
usage: *usage,
|
||||
}));
|
||||
}
|
||||
|
||||
Poll::Ready(None)
|
||||
}
|
||||
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(StreamEvent::error(e.to_string()))),
|
||||
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(LegacyStreamEvent::error(e.to_string()))),
|
||||
Poll::Ready(None) => Poll::Ready(None),
|
||||
Poll::Pending => Poll::Pending,
|
||||
})
|
||||
.poll_unpin(cx)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 内部:LegacyStreamEvent → 新 StreamEvent ---
|
||||
|
||||
struct LegacyToIrEventStream {
|
||||
inner: Pin<Box<dyn futures_core::Stream<Item = LegacyStreamEvent> + Send>>,
|
||||
}
|
||||
|
||||
impl Stream for LegacyToIrEventStream {
|
||||
type Item = Result<StreamEvent, LlmError>;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
let this = &mut *self;
|
||||
match Pin::new(&mut this.inner).poll_next(cx) {
|
||||
Poll::Ready(Some(legacy)) => Poll::Ready(Some(Ok(map_legacy_to_ir(legacy)))),
|
||||
Poll::Ready(None) => {
|
||||
// 旧流结束 → 主动补一个 MessageComplete(full_response 为兜底空快照)。
|
||||
// ponytail: Phase 0 中 OpenaiProvider 桥接层负责产出真实 MessageResponse,
|
||||
// 此处仅防止消费方无限等待。若 Provider 层已正确发出 MessageComplete,
|
||||
// LlmCycle 不会走到这里 —— 因为桥接层 inline 处理。
|
||||
Poll::Ready(Some(Ok(StreamEvent::MessageComplete {
|
||||
full_response: empty_message_response(),
|
||||
})))
|
||||
}
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_message_response() -> MessageResponse {
|
||||
use crate::llm::types::message::Message;
|
||||
use std::collections::HashMap;
|
||||
MessageResponse {
|
||||
id: String::new(),
|
||||
model: String::new(),
|
||||
message: Message::Assistant {
|
||||
content: vec![],
|
||||
},
|
||||
usage: Usage::default(),
|
||||
stop_reason: StopReason::Stop,
|
||||
extra: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 把旧 LegacyStreamEvent 映射到新高精度 IR StreamEvent。
|
||||
///
|
||||
/// Phase 1 重写 Provider 后可直接删除此映射函数。当前映射语义:
|
||||
/// - `AssistantTextDelta` → `TextDelta`
|
||||
/// - `ToolExecutionStarted` → `ToolCallArgumentsDelta`(OpenAI 单 chunk 模式下整段 arguments 一次性下发)
|
||||
/// - `CostUpdate` → `CostUpdate`(Usage → PartialUsage 全字段)
|
||||
/// - `TurnComplete` → `MessageComplete`(Phase 1 重写 Provider 后正确产出)
|
||||
/// - `Error` → `Error`
|
||||
///
|
||||
/// ponytail: 这是一个"目前能跑通未来会被删除"的适配层。当前实现为单事件映射,
|
||||
/// 旧 `ToolExecutionStarted` 携带的 (id, name) 暂未填入 IR 事件(消费方
|
||||
/// Phase 2 中通过 MessageComplete.full_response.tool_use 提取)。Phase 1 重写时
|
||||
/// 由 OpenAI Provider 直接产出 IR 流,整体删除此映射。
|
||||
fn map_legacy_to_ir(legacy: LegacyStreamEvent) -> StreamEvent {
|
||||
use crate::llm::types::response_v2::PartialUsage;
|
||||
|
||||
match legacy {
|
||||
LegacyStreamEvent::AssistantTextDelta { text } => StreamEvent::TextDelta { text },
|
||||
LegacyStreamEvent::ToolExecutionStarted { input, .. } => {
|
||||
let arguments = serde_json::to_string(&input).unwrap_or_default();
|
||||
StreamEvent::ToolCallArgumentsDelta { index: 0, arguments }
|
||||
}
|
||||
LegacyStreamEvent::ToolExecutionCompleted { .. } => {
|
||||
// 旧 ToolExecutionCompleted 不在 IR 流协议中——工具执行是消费方职责。
|
||||
// Phase 1 重写时此处整体删除。当前给一个无副作用的占位事件。
|
||||
StreamEvent::CostUpdate {
|
||||
usage: PartialUsage::default(),
|
||||
}
|
||||
}
|
||||
LegacyStreamEvent::CostUpdate { usage } => StreamEvent::CostUpdate {
|
||||
usage: PartialUsage {
|
||||
prompt_tokens: Some(usage.prompt_tokens),
|
||||
completion_tokens: Some(usage.completion_tokens),
|
||||
total_tokens: Some(usage.total_tokens),
|
||||
completion_tokens_details: usage.completion_tokens_details,
|
||||
prompt_tokens_details: usage.prompt_tokens_details,
|
||||
},
|
||||
},
|
||||
LegacyStreamEvent::TurnComplete { reason } => {
|
||||
// 旧 TurnComplete 不直接对应 IR;映射为带 StopReason 的 MessageComplete。
|
||||
// ponytail: Phase 1 重写 Provider 后此适配整体删除,
|
||||
// OpenAI Provider 直接产出带正确 stop_reason 的 MessageComplete。
|
||||
let _ = reason;
|
||||
StreamEvent::MessageComplete {
|
||||
full_response: empty_message_response(),
|
||||
}
|
||||
}
|
||||
LegacyStreamEvent::Error { message } => StreamEvent::Error { message },
|
||||
}
|
||||
}
|
||||
|
||||
+384
-125
@@ -1,165 +1,424 @@
|
||||
use crate::llm::types::shared::{AudioFormat, ImageDetail};
|
||||
use crate::llm::types::tool::OpenaiToolCall;
|
||||
//! IR 层 Message 类型 —— 跨 Provider 统一的消息模型。
|
||||
//!
|
||||
//! 设计目标见 `docs/10-llm-provider-refinement.md` §2.1 Decision-01。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ContentField {
|
||||
String(String),
|
||||
Array(Vec<OpenaiContentPart>),
|
||||
use crate::llm::types::shared::AudioFormat;
|
||||
use crate::llm::types::shared::ImageDetail;
|
||||
|
||||
/// 跨 Provider 统一的消息类型(扁平大枚举)。
|
||||
///
|
||||
/// 每个变体直接承载完整语义,消费方 match 即可获得所有信息,
|
||||
/// 无需在嵌套的 `Vec` 中搜索。
|
||||
///
|
||||
/// 设计要点:
|
||||
/// - `Thinking` / `ToolUse` 不作为独立 Message 变体,保留在 `Assistant.content` 中,
|
||||
/// 因为 text ↔ tool_use 的**交错顺序**是 Assistant 响应的语义组成部分。
|
||||
/// - `UserImage` 是快捷变体,减少"图片只有 base64 字符串"的 boilerplate,
|
||||
/// 消费方 match 可直接区分文本和图片输入。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Message {
|
||||
/// 系统提示(User & Assistant 之外的引导指令)。
|
||||
System {
|
||||
content: Vec<ContentBlock>,
|
||||
},
|
||||
/// 用户输入。
|
||||
User {
|
||||
content: Vec<ContentBlock>,
|
||||
},
|
||||
/// 用户的图片输入(快捷构造,免去构造 ContentBlock 的 boilerplate)。
|
||||
UserImage {
|
||||
data: String,
|
||||
mime_type: String,
|
||||
detail: ImageDetail,
|
||||
},
|
||||
/// Assistant 回复内容块(可能包含 text、thinking、tool_use 等多种 block 的混合)。
|
||||
Assistant {
|
||||
content: Vec<ContentBlock>,
|
||||
},
|
||||
/// 工具调用结果。
|
||||
ToolResult {
|
||||
tool_call_id: String,
|
||||
content: Vec<ContentBlock>,
|
||||
is_error: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ContentField {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = Value::deserialize(deserializer)?;
|
||||
match value {
|
||||
Value::String(s) => Ok(ContentField::String(s)),
|
||||
Value::Array(arr) => {
|
||||
let parts: Result<Vec<OpenaiContentPart>, _> =
|
||||
serde_json::from_value(Value::Array(arr));
|
||||
match parts {
|
||||
Ok(parts) => Ok(ContentField::Array(parts)),
|
||||
Err(e) => Err(serde::de::Error::custom(e)),
|
||||
}
|
||||
}
|
||||
_ => Err(serde::de::Error::custom("content must be string or array")),
|
||||
impl Message {
|
||||
/// 构造纯文本 User 消息。
|
||||
pub fn user_text(text: impl Into<String>) -> Self {
|
||||
Message::User {
|
||||
content: vec![ContentBlock::Text { text: text.into() }],
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造 UserImage 快捷消息(直接传 base64 / URL + MIME)。
|
||||
pub fn user_image(
|
||||
data: impl Into<String>,
|
||||
mime_type: impl Into<String>,
|
||||
detail: ImageDetail,
|
||||
) -> Self {
|
||||
Message::UserImage {
|
||||
data: data.into(),
|
||||
mime_type: mime_type.into(),
|
||||
detail,
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造纯文本 Assistant 消息。
|
||||
pub fn assistant(text: impl Into<String>) -> Self {
|
||||
Message::Assistant {
|
||||
content: vec![ContentBlock::Text { text: text.into() }],
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造纯文本 System 消息。
|
||||
pub fn system(text: impl Into<String>) -> Self {
|
||||
Message::System {
|
||||
content: vec![ContentBlock::Text { text: text.into() }],
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造纯文本 ToolResult 消息。
|
||||
pub fn tool_result(
|
||||
tool_call_id: impl Into<String>,
|
||||
text: impl Into<String>,
|
||||
is_error: bool,
|
||||
) -> Self {
|
||||
Message::ToolResult {
|
||||
tool_call_id: tool_call_id.into(),
|
||||
content: vec![ContentBlock::Text { text: text.into() }],
|
||||
is_error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for ContentField {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
match self {
|
||||
ContentField::String(s) => s.serialize(serializer),
|
||||
ContentField::Array(arr) => arr.serialize(serializer),
|
||||
}
|
||||
}
|
||||
/// 内容块 —— 组成消息的最小语义单元。
|
||||
///
|
||||
/// 与 9b §3.1 设计保持一致:`Text` / `Image` / `Audio` / `File` / `ToolUse` /
|
||||
/// `ToolResult` / `Thinking` / `Extension`,其中 `Extension` 作为 Provider 特定
|
||||
/// block 的逃生舱。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentBlock {
|
||||
/// 纯文本。
|
||||
Text { text: String },
|
||||
/// 图片(含 URL 或 base64)。
|
||||
Image { source: ImageSource },
|
||||
/// 音频。
|
||||
Audio { source: AudioSource },
|
||||
/// 通用文件。
|
||||
File { source: FileSource },
|
||||
/// 工具调用。
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: Value,
|
||||
},
|
||||
/// 工具结果(在消息历史中由 Assistant 携带,如 Anthropic 内容回显)。
|
||||
ToolResult {
|
||||
tool_use_id: String,
|
||||
content: Vec<ContentBlock>,
|
||||
is_error: bool,
|
||||
},
|
||||
/// 推理 / 思考内容(可携带 signature 用于多轮续推)。
|
||||
Thinking {
|
||||
text: String,
|
||||
signature: Option<String>,
|
||||
},
|
||||
/// 逃生舱:Provider 特定 block 透传(OpenAI Response 内置工具等)。
|
||||
Extension {
|
||||
kind: String,
|
||||
data: Value,
|
||||
},
|
||||
}
|
||||
|
||||
/// 内容块类型标签 —— 用于 `StreamEvent::ContentBlockStart.block_type`。
|
||||
///
|
||||
/// 用途:在流式场景中,Provider 先下发 block 类型,再下发 block 内容增量。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImageURL {
|
||||
pub url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<ImageDetail>,
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentBlockType {
|
||||
/// 文本块。
|
||||
Text,
|
||||
/// 思考 / 推理块。
|
||||
Thinking,
|
||||
/// 拒绝 / 政策阻断块(OpenAI refusal)。
|
||||
Refusal,
|
||||
/// 工具调用块(携带 id 和 name)。
|
||||
ToolUse { id: String, name: String },
|
||||
}
|
||||
|
||||
/// 图片来源(URL 或 base64)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InputAudio {
|
||||
pub struct ImageSource {
|
||||
/// URL 或 base64 字符串。
|
||||
pub data: String,
|
||||
/// MIME 类型(如 `image/png` / `image/jpeg` / `image/webp`)。
|
||||
pub mime_type: String,
|
||||
/// `true` = URL;`false` = base64。
|
||||
pub is_url: bool,
|
||||
}
|
||||
|
||||
/// 音频来源。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AudioSource {
|
||||
/// base64 字符串。
|
||||
pub data: String,
|
||||
/// 音频格式。
|
||||
pub format: AudioFormat,
|
||||
}
|
||||
|
||||
/// 文件来源。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileData {
|
||||
pub file_data: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub file_id: Option<String>,
|
||||
pub struct FileSource {
|
||||
/// 文件内容(base64)或 URL。
|
||||
pub data: String,
|
||||
/// 原始文件名。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub filename: Option<String>,
|
||||
/// MIME 类型。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mime_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "type")]
|
||||
pub enum OpenaiContentPart {
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
Image {
|
||||
image_url: ImageURL,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
detail: Option<ImageDetail>,
|
||||
},
|
||||
InputAudio {
|
||||
input_audio: InputAudio,
|
||||
},
|
||||
File {
|
||||
file: FileData,
|
||||
},
|
||||
Refusal {
|
||||
refusal: String,
|
||||
},
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm::types::shared::AudioFormat;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "role")]
|
||||
pub enum OpenaiChatMessage {
|
||||
Developer {
|
||||
content: ContentField,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
},
|
||||
System {
|
||||
content: ContentField,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
},
|
||||
User {
|
||||
content: ContentField,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
},
|
||||
Assistant {
|
||||
content: ContentField,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
refusal: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_calls: Option<Vec<OpenaiToolCall>>,
|
||||
},
|
||||
Tool {
|
||||
content: ContentField,
|
||||
tool_call_id: String,
|
||||
},
|
||||
Function {
|
||||
content: ContentField,
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl OpenaiChatMessage {
|
||||
pub fn user_text<S: Into<String>>(text: S) -> Self {
|
||||
OpenaiChatMessage::User {
|
||||
content: ContentField::Array(vec![OpenaiContentPart::Text { text: text.into() }]),
|
||||
name: None,
|
||||
#[test]
|
||||
fn message_user_text_produces_text_block() {
|
||||
let m = Message::user_text("hello");
|
||||
match m {
|
||||
Message::User { content } => {
|
||||
assert_eq!(content.len(), 1);
|
||||
match &content[0] {
|
||||
ContentBlock::Text { text } => assert_eq!(text, "hello"),
|
||||
_ => panic!("expected Text block"),
|
||||
}
|
||||
}
|
||||
_ => panic!("expected User variant"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assistant_text<S: Into<String>>(text: S) -> Self {
|
||||
OpenaiChatMessage::Assistant {
|
||||
content: ContentField::String(text.into()),
|
||||
refusal: None,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
#[test]
|
||||
fn message_assistant_text_produces_text_block() {
|
||||
let m = Message::assistant("hi");
|
||||
match m {
|
||||
Message::Assistant { content } => {
|
||||
assert_eq!(content.len(), 1);
|
||||
assert!(matches!(&content[0], ContentBlock::Text { text } if text == "hi"));
|
||||
}
|
||||
_ => panic!("expected Assistant variant"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn system_text<S: Into<String>>(text: S) -> Self {
|
||||
OpenaiChatMessage::System {
|
||||
content: ContentField::Array(vec![OpenaiContentPart::Text { text: text.into() }]),
|
||||
name: None,
|
||||
#[test]
|
||||
fn message_system_text_produces_text_block() {
|
||||
let m = Message::system("sys");
|
||||
match m {
|
||||
Message::System { content } => {
|
||||
assert_eq!(content.len(), 1);
|
||||
assert!(matches!(&content[0], ContentBlock::Text { text } if text == "sys"));
|
||||
}
|
||||
_ => panic!("expected System variant"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn developer_text<S: Into<String>>(text: S) -> Self {
|
||||
OpenaiChatMessage::Developer {
|
||||
content: ContentField::Array(vec![OpenaiContentPart::Text { text: text.into() }]),
|
||||
name: None,
|
||||
#[test]
|
||||
fn message_user_image_carries_fields() {
|
||||
let m = Message::user_image("base64data", "image/png", ImageDetail::High);
|
||||
match m {
|
||||
Message::UserImage {
|
||||
data,
|
||||
mime_type,
|
||||
detail,
|
||||
} => {
|
||||
assert_eq!(data, "base64data");
|
||||
assert_eq!(mime_type, "image/png");
|
||||
assert_eq!(detail, ImageDetail::High);
|
||||
}
|
||||
_ => panic!("expected UserImage variant"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tool_result<S: Into<String>>(tool_call_id: String, content: S) -> Self {
|
||||
OpenaiChatMessage::Tool {
|
||||
content: ContentField::Array(vec![OpenaiContentPart::Text {
|
||||
text: content.into(),
|
||||
}]),
|
||||
tool_call_id,
|
||||
#[test]
|
||||
fn message_tool_result_text_produces_block() {
|
||||
let m = Message::tool_result("call_1", "result text", false);
|
||||
match m {
|
||||
Message::ToolResult {
|
||||
tool_call_id,
|
||||
content,
|
||||
is_error,
|
||||
} => {
|
||||
assert_eq!(tool_call_id, "call_1");
|
||||
assert!(!is_error);
|
||||
assert_eq!(content.len(), 1);
|
||||
match &content[0] {
|
||||
ContentBlock::Text { text } => assert_eq!(text, "result text"),
|
||||
_ => panic!("expected Text block"),
|
||||
}
|
||||
}
|
||||
_ => panic!("expected ToolResult variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_exhaustive_match() {
|
||||
// 编译器保证穷举;这里写一个 match 验证所有变体可被访问。
|
||||
let msgs = vec![
|
||||
Message::user_text("a"),
|
||||
Message::user_image("d", "image/png", ImageDetail::Auto),
|
||||
Message::assistant("b"),
|
||||
Message::system("c"),
|
||||
Message::tool_result("id", "r", false),
|
||||
];
|
||||
for m in &msgs {
|
||||
let s = match m {
|
||||
Message::System { .. } => "sys",
|
||||
Message::User { .. } => "user",
|
||||
Message::UserImage { .. } => "image",
|
||||
Message::Assistant { .. } => "asst",
|
||||
Message::ToolResult { .. } => "tool",
|
||||
};
|
||||
assert!(!s.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_block_roundtrip_each_variant() {
|
||||
let blocks = vec![
|
||||
ContentBlock::Text {
|
||||
text: "hello".into(),
|
||||
},
|
||||
ContentBlock::Image {
|
||||
source: ImageSource {
|
||||
data: "b64".into(),
|
||||
mime_type: "image/png".into(),
|
||||
is_url: false,
|
||||
},
|
||||
},
|
||||
ContentBlock::Audio {
|
||||
source: AudioSource {
|
||||
data: "audio_b64".into(),
|
||||
format: AudioFormat::Mp3,
|
||||
},
|
||||
},
|
||||
ContentBlock::File {
|
||||
source: FileSource {
|
||||
data: "file_data".into(),
|
||||
filename: Some("doc.pdf".into()),
|
||||
mime_type: Some("application/pdf".into()),
|
||||
},
|
||||
},
|
||||
ContentBlock::ToolUse {
|
||||
id: "call_1".into(),
|
||||
name: "search".into(),
|
||||
input: json!({"q": "rust"}),
|
||||
},
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id: "call_1".into(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "found".into(),
|
||||
}],
|
||||
is_error: false,
|
||||
},
|
||||
ContentBlock::Thinking {
|
||||
text: "thinking...".into(),
|
||||
signature: Some("sig".into()),
|
||||
},
|
||||
ContentBlock::Extension {
|
||||
kind: "openai.response.web_search_call".into(),
|
||||
data: json!({"query": "rust"}),
|
||||
},
|
||||
];
|
||||
|
||||
for original in blocks {
|
||||
let json = serde_json::to_string(&original).expect("serialize");
|
||||
let decoded: ContentBlock = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(format!("{:?}", original), format!("{:?}", decoded));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_roundtrip_each_variant() {
|
||||
let msgs = vec![
|
||||
Message::System {
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "sys".into(),
|
||||
}],
|
||||
},
|
||||
Message::User {
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
},
|
||||
Message::UserImage {
|
||||
data: "b64".into(),
|
||||
mime_type: "image/png".into(),
|
||||
detail: ImageDetail::High,
|
||||
},
|
||||
Message::Assistant {
|
||||
content: vec![
|
||||
ContentBlock::Thinking {
|
||||
text: "reasoning".into(),
|
||||
signature: None,
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "answer".into(),
|
||||
},
|
||||
],
|
||||
},
|
||||
Message::ToolResult {
|
||||
tool_call_id: "call_1".into(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "ok".into(),
|
||||
}],
|
||||
is_error: true,
|
||||
},
|
||||
];
|
||||
|
||||
for original in msgs {
|
||||
let json = serde_json::to_string(&original).expect("serialize");
|
||||
let decoded: Message = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(format!("{:?}", original), format!("{:?}", decoded));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_block_tool_result_nested_roundtrip() {
|
||||
let block = ContentBlock::ToolResult {
|
||||
tool_use_id: "call_1".into(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "result".into(),
|
||||
}],
|
||||
is_error: false,
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
let decoded: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(format!("{:?}", block), format!("{:?}", decoded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_block_type_roundtrip() {
|
||||
let types = vec![
|
||||
ContentBlockType::Text,
|
||||
ContentBlockType::Thinking,
|
||||
ContentBlockType::Refusal,
|
||||
ContentBlockType::ToolUse {
|
||||
id: "call_x".into(),
|
||||
name: "fn".into(),
|
||||
},
|
||||
];
|
||||
for original in types {
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let decoded: ContentBlockType = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(format!("{:?}", original), format!("{:?}", decoded));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-9
@@ -1,18 +1,27 @@
|
||||
pub mod message;
|
||||
pub mod old_stream;
|
||||
pub mod openai_message;
|
||||
pub mod request;
|
||||
pub mod request_v2;
|
||||
pub mod response;
|
||||
pub mod response_v2;
|
||||
pub mod shared;
|
||||
pub mod tool;
|
||||
pub mod usage;
|
||||
|
||||
pub use message::{
|
||||
pub use openai_message::{
|
||||
ContentField, FileData, ImageURL, InputAudio, OpenaiChatMessage, OpenaiContentPart,
|
||||
};
|
||||
pub use request::{OpenaiChatRequest, OpenaiTool, StreamOptions, ToolChoice};
|
||||
pub use request_v2::{ExtraError, MessageRequest, ThinkingConfig};
|
||||
pub use response::{
|
||||
Annotation, Choice, ChunkChoice, Delta, Logprobs, OpenaiAudio, OpenaiChatChunk,
|
||||
OpenaiChatResponse, TokenLogprob, TopLogprob, URLCitation,
|
||||
};
|
||||
pub use response_v2::{
|
||||
ContentBlockBuilder, MessageResponse, PartialMessageResponse, PartialUsage, StopReason,
|
||||
StreamEvent,
|
||||
};
|
||||
pub use shared::{
|
||||
AudioFormat, FinishReason, ImageDetail, Modality, ResponseFormat, Role, ServiceTier,
|
||||
StopSequence,
|
||||
@@ -20,6 +29,18 @@ pub use shared::{
|
||||
pub use tool::{FunctionCall, OpenaiToolCall, OpenaiToolDefinition};
|
||||
pub use usage::{CompletionTokensDetails, CostTracker, PromptTokensDetails, Usage};
|
||||
|
||||
// Re-export IR 内容块 / 消息类型供 `types::ContentBlock` 等历史路径消费。
|
||||
//
|
||||
// 注意:以下别名 *故意不暴露* `pub type Message = message::Message`、
|
||||
// `pub type ContentBlock = message::ContentBlock` —— 新 `Message` / `ContentBlock` /
|
||||
// `StopReason` 是独立类型,由 `Message` / `ContentBlock` / `StopReason` 直接路径访问,
|
||||
// 旧别名(指 `OpenaiChatMessage` / `OpenaiContentPart` / `FinishReason`)已移除,
|
||||
// 避免新类型阴影。Phase 2 完成后再统一收敛。
|
||||
//
|
||||
// Phase 1 起移除 `ChatRequest` 别名 —— 新代码统一使用 `MessageRequest`(v2 IR)。
|
||||
// `ChatResponse` 结构体仍存在,作为 OpenAI `chat_inner()` 内部 wire-format 转换目标。
|
||||
/// 旧 wire-format 响应结构(保留用于 OpenAI 内部转换层)。
|
||||
#[deprecated(since = "0.1.0", note = "请改用 MessageResponse")]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatResponse {
|
||||
pub message: OpenaiChatMessage,
|
||||
@@ -27,6 +48,7 @@ pub struct ChatResponse {
|
||||
pub stop_reason: Option<FinishReason>,
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
impl From<OpenaiChatResponse> for ChatResponse {
|
||||
fn from(response: OpenaiChatResponse) -> Self {
|
||||
let message = response
|
||||
@@ -43,6 +65,7 @@ impl From<OpenaiChatResponse> for ChatResponse {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
impl From<ChatResponse> for OpenaiChatChunk {
|
||||
fn from(response: ChatResponse) -> Self {
|
||||
let delta = Delta::from(response.message.clone());
|
||||
@@ -54,10 +77,13 @@ impl From<ChatResponse> for OpenaiChatChunk {
|
||||
};
|
||||
|
||||
OpenaiChatChunk {
|
||||
id: format!("chunk-{}", std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)),
|
||||
id: format!(
|
||||
"chunk-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
),
|
||||
object: "chat.completion.chunk".to_string(),
|
||||
created: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -71,8 +97,6 @@ impl From<ChatResponse> for OpenaiChatChunk {
|
||||
}
|
||||
}
|
||||
|
||||
pub type ChatRequest = OpenaiChatRequest;
|
||||
pub type Message = OpenaiChatMessage;
|
||||
pub type ContentBlock = OpenaiContentPart;
|
||||
/// 工具定义别名(无新类型冲突,保留)。
|
||||
#[deprecated(since = "0.1.0", note = "ToolDefinition 仍直接对应 OpenAI wire-format;未来 v0.2 引入 IR 工具类型后会再次更新")]
|
||||
pub type ToolDefinition = OpenaiToolDefinition;
|
||||
pub type StopReason = FinishReason;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//! 旧版流式事件 —— Phase 0 临时保留,仅供 `stream.rs` 中 `parse_chunk_stream` 内部使用。
|
||||
//!
|
||||
//! Phase 0 中:高精度 `StreamEvent`(定义在 `response_v2.rs`)是唯一的对外
|
||||
//! `StreamEvent`,旧变体迁移至此模块改名为 `LegacyStreamEvent`,
|
||||
//! 由 `parse_chunk_stream()` 内部消费 `LegacyStreamEvent`,对外返回值已被
|
||||
//! 重映射为新 `StreamEvent`。
|
||||
//!
|
||||
//! Phase 1 重写 Provider 时,`parse_chunk_stream` 可直接消费新事件流后整体删除此文件。
|
||||
|
||||
use crate::llm::types::shared::FinishReason;
|
||||
use crate::llm::types::usage::Usage;
|
||||
use serde_json::Value;
|
||||
|
||||
/// 旧 `StreamEvent` 变体迁移后的别名 —— 仅供 `stream.rs` 内部使用。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LegacyStreamEvent {
|
||||
/// 助手回复文本增量。
|
||||
AssistantTextDelta { text: String },
|
||||
/// 工具调用开始。
|
||||
ToolExecutionStarted {
|
||||
tool_name: String,
|
||||
input: Value,
|
||||
tool_call_id: String,
|
||||
},
|
||||
/// 工具调用完成。
|
||||
ToolExecutionCompleted {
|
||||
tool_name: String,
|
||||
output: Value,
|
||||
is_error: bool,
|
||||
},
|
||||
/// Token 用量更新。
|
||||
CostUpdate { usage: Usage },
|
||||
/// 一轮会话完成。
|
||||
TurnComplete { reason: FinishReason },
|
||||
/// 错误事件。
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
impl LegacyStreamEvent {
|
||||
pub(crate) fn error(message: impl Into<String>) -> Self {
|
||||
Self::Error {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
use crate::llm::types::shared::{AudioFormat, ImageDetail};
|
||||
use crate::llm::types::tool::OpenaiToolCall;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ContentField {
|
||||
String(String),
|
||||
Array(Vec<OpenaiContentPart>),
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ContentField {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = Value::deserialize(deserializer)?;
|
||||
match value {
|
||||
Value::String(s) => Ok(ContentField::String(s)),
|
||||
Value::Array(arr) => {
|
||||
let parts: Result<Vec<OpenaiContentPart>, _> =
|
||||
serde_json::from_value(Value::Array(arr));
|
||||
match parts {
|
||||
Ok(parts) => Ok(ContentField::Array(parts)),
|
||||
Err(e) => Err(serde::de::Error::custom(e)),
|
||||
}
|
||||
}
|
||||
_ => Err(serde::de::Error::custom("content must be string or array")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for ContentField {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
match self {
|
||||
ContentField::String(s) => s.serialize(serializer),
|
||||
ContentField::Array(arr) => arr.serialize(serializer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImageURL {
|
||||
pub url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<ImageDetail>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InputAudio {
|
||||
pub data: String,
|
||||
pub format: AudioFormat,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileData {
|
||||
pub file_data: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub file_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub filename: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "type")]
|
||||
pub enum OpenaiContentPart {
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
Image {
|
||||
image_url: ImageURL,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
detail: Option<ImageDetail>,
|
||||
},
|
||||
InputAudio {
|
||||
input_audio: InputAudio,
|
||||
},
|
||||
File {
|
||||
file: FileData,
|
||||
},
|
||||
Refusal {
|
||||
refusal: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "role")]
|
||||
pub enum OpenaiChatMessage {
|
||||
Developer {
|
||||
content: ContentField,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
},
|
||||
System {
|
||||
content: ContentField,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
},
|
||||
User {
|
||||
content: ContentField,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
},
|
||||
Assistant {
|
||||
content: ContentField,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
refusal: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_calls: Option<Vec<OpenaiToolCall>>,
|
||||
},
|
||||
Tool {
|
||||
content: ContentField,
|
||||
tool_call_id: String,
|
||||
},
|
||||
Function {
|
||||
content: ContentField,
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl OpenaiChatMessage {
|
||||
pub fn user_text<S: Into<String>>(text: S) -> Self {
|
||||
OpenaiChatMessage::User {
|
||||
content: ContentField::Array(vec![OpenaiContentPart::Text { text: text.into() }]),
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assistant_text<S: Into<String>>(text: S) -> Self {
|
||||
OpenaiChatMessage::Assistant {
|
||||
content: ContentField::String(text.into()),
|
||||
refusal: None,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn system_text<S: Into<String>>(text: S) -> Self {
|
||||
OpenaiChatMessage::System {
|
||||
content: ContentField::Array(vec![OpenaiContentPart::Text { text: text.into() }]),
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn developer_text<S: Into<String>>(text: S) -> Self {
|
||||
OpenaiChatMessage::Developer {
|
||||
content: ContentField::Array(vec![OpenaiContentPart::Text { text: text.into() }]),
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tool_result<S: Into<String>>(tool_call_id: String, content: S) -> Self {
|
||||
OpenaiChatMessage::Tool {
|
||||
content: ContentField::Array(vec![OpenaiContentPart::Text {
|
||||
text: content.into(),
|
||||
}]),
|
||||
tool_call_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,9 @@ pub struct StreamOptions {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Default)]
|
||||
pub enum ToolChoice {
|
||||
#[default]
|
||||
None,
|
||||
Auto,
|
||||
Required,
|
||||
@@ -20,6 +22,7 @@ pub enum ToolChoice {
|
||||
AllowedTools { tool_names: Vec<String> },
|
||||
}
|
||||
|
||||
|
||||
impl Serialize for ToolChoice {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
@@ -139,7 +142,7 @@ pub struct WebSearchOptions {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct OpenaiChatRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<crate::llm::types::message::OpenaiChatMessage>,
|
||||
pub messages: Vec<crate::llm::types::openai_message::OpenaiChatMessage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub frequency_penalty: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
//! IR 层 MessageRequest 类型 —— 跨 Provider 统一的请求模型。
|
||||
//!
|
||||
//! 设计目标见 `docs/10-llm-provider-refinement.md` §2.2 Decision-02 + `docs/9b-ir-type-system.md` §3.6。
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::llm::types::message::Message;
|
||||
use crate::llm::types::request::ToolChoice;
|
||||
use crate::llm::types::tool::OpenaiToolDefinition;
|
||||
|
||||
/// Provider 无关的请求类型。
|
||||
///
|
||||
/// 设计要点:
|
||||
/// - `system` 字段不存在;system 提示由调用方通过 `Message::System` 在 `messages` 中表达。
|
||||
/// - `tools` / `tool_choice` 直接复用现有 `OpenaiToolDefinition` / `ToolChoice`
|
||||
/// (10a §251 决策:先复用旧类型,Phase 2 切换为新 `ToolDefinition` 后再调整)。
|
||||
/// - `extra` 作为逃生舱:Provider 特定字段(`web_search_options`、`previous_response_id` 等)
|
||||
/// 通过 `extra.set_extra / get_extra` 传递,避免持续膨胀本结构体。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct MessageRequest {
|
||||
/// 模型名称。
|
||||
pub model: String,
|
||||
/// 消息列表(包含 system / user / assistant / tool_result 等所有变体)。
|
||||
pub messages: Vec<Message>,
|
||||
/// 工具定义列表。
|
||||
pub tools: Vec<OpenaiToolDefinition>,
|
||||
/// 工具选择策略。
|
||||
pub tool_choice: ToolChoice,
|
||||
/// 最大输出 token 数。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens: Option<u32>,
|
||||
/// 采样温度。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
/// nucleus 采样 top-p。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_p: Option<f32>,
|
||||
/// 终止序列。
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub stop_sequences: Vec<String>,
|
||||
/// 是否流式响应(Provider 实现层读取)。
|
||||
#[serde(default)]
|
||||
pub stream: bool,
|
||||
/// 思考 / 推理配置(如 Anthropic thinking budget)。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thinking: Option<ThinkingConfig>,
|
||||
/// Provider 特定扩展字段。
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// 思考 / 推理配置。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThinkingConfig {
|
||||
/// 思考预算 token 数。
|
||||
pub budget_tokens: u32,
|
||||
}
|
||||
|
||||
/// `extra` 字段访问错误。
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ExtraError {
|
||||
/// 反序列化到目标类型失败。
|
||||
#[error("extra 字段 `{key}` 反序列化失败: {details}")]
|
||||
TypeMismatch { key: String, details: String },
|
||||
/// 字段值反序列化为 JSON 失败。
|
||||
#[error("extra 反序列化失败: {0}")]
|
||||
Deserialize(String),
|
||||
}
|
||||
|
||||
impl MessageRequest {
|
||||
/// 读取一个 `extra` 字段,缺失返回 `Ok(None)`,类型不匹配返回 `Err(ExtraError::TypeMismatch)`。
|
||||
pub fn get_extra<T: for<'de> Deserialize<'de>>(
|
||||
&self,
|
||||
key: &str,
|
||||
) -> Result<Option<T>, ExtraError> {
|
||||
match self.extra.get(key) {
|
||||
None => Ok(None),
|
||||
Some(value) => serde_json::from_value(value.clone())
|
||||
.map(Some)
|
||||
.map_err(|e| ExtraError::TypeMismatch {
|
||||
key: key.to_string(),
|
||||
details: e.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取 `extra` 字段但用 `Option<T>`,跳过错误(缺失或反序列化失败均返回 `None`)。
|
||||
pub fn get_extra_opt<T: for<'de> Deserialize<'de>>(&self, key: &str) -> Option<T> {
|
||||
self.get_extra::<T>(key).ok().flatten()
|
||||
}
|
||||
|
||||
/// 把整个 `extra` 整体反序列化为类型 `T`(用于"我有一个完整 options 结构"的场景)。
|
||||
pub fn get_extra_as<T: for<'de> Deserialize<'de>>(&self) -> Result<T, ExtraError> {
|
||||
let value = Value::Object(
|
||||
self.extra
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect::<serde_json::Map<String, Value>>(),
|
||||
);
|
||||
serde_json::from_value(value).map_err(|e| ExtraError::Deserialize(e.to_string()))
|
||||
}
|
||||
|
||||
/// 设置一个 `extra` 字段。
|
||||
pub fn set_extra(&mut self, key: impl Into<String>, value: impl Into<Value>) {
|
||||
self.extra.insert(key.into(), value.into());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn message_request_default_is_constructible() {
|
||||
let req = MessageRequest::default();
|
||||
assert_eq!(req.model, "");
|
||||
assert!(req.messages.is_empty());
|
||||
assert!(!req.stream);
|
||||
assert!(req.max_tokens.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_set_and_get_roundtrip() {
|
||||
let mut req = MessageRequest::default();
|
||||
req.set_extra(
|
||||
"previous_response_id",
|
||||
"resp_abc123",
|
||||
);
|
||||
|
||||
let v: Option<String> = req
|
||||
.get_extra("previous_response_id")
|
||||
.expect("get_extra ok");
|
||||
assert_eq!(v.as_deref(), Some("resp_abc123"));
|
||||
|
||||
let missing: Option<String> = req.get_extra("missing").expect("missing ok");
|
||||
assert!(missing.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_type_mismatch_returns_err() {
|
||||
let mut req = MessageRequest::default();
|
||||
req.set_extra("count", "not a number");
|
||||
let result: Result<Option<u32>, ExtraError> = req.get_extra("count");
|
||||
assert!(matches!(result, Err(ExtraError::TypeMismatch { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_opt_swallows_errors() {
|
||||
let mut req = MessageRequest::default();
|
||||
req.set_extra("count", "not a number");
|
||||
let v: Option<u32> = req.get_extra_opt("count");
|
||||
assert!(v.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_extra_as_deserializes_whole_extra() {
|
||||
let mut req = MessageRequest::default();
|
||||
req.set_extra("web_search_options", json!({"search_context_size": "high"}));
|
||||
req.set_extra("user", json!("u_123"));
|
||||
|
||||
#[derive(Deserialize, Debug, PartialEq)]
|
||||
struct Options {
|
||||
web_search_options: WebSearch,
|
||||
user: Option<String>,
|
||||
}
|
||||
#[derive(Deserialize, Debug, PartialEq)]
|
||||
struct WebSearch {
|
||||
search_context_size: String,
|
||||
}
|
||||
|
||||
let opts: Options = req.get_extra_as().expect("get_extra_as ok");
|
||||
assert_eq!(
|
||||
opts.web_search_options.search_context_size,
|
||||
"high"
|
||||
);
|
||||
assert_eq!(opts.user.as_deref(), Some("u_123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_request_json_roundtrip() {
|
||||
let mut req = MessageRequest {
|
||||
model: "gpt-4o".into(),
|
||||
messages: vec![Message::user_text("hello"), Message::assistant("hi")],
|
||||
tools: vec![],
|
||||
tool_choice: ToolChoice::Auto,
|
||||
max_tokens: Some(1024),
|
||||
temperature: Some(0.7),
|
||||
top_p: Some(0.9),
|
||||
stop_sequences: vec!["STOP".into()],
|
||||
stream: true,
|
||||
thinking: None,
|
||||
extra: HashMap::new(),
|
||||
};
|
||||
req.set_extra("trace_id", json!("t-1"));
|
||||
|
||||
let json = serde_json::to_string(&req).expect("serialize");
|
||||
let decoded: MessageRequest = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(decoded.model, req.model);
|
||||
assert_eq!(decoded.messages.len(), req.messages.len());
|
||||
assert_eq!(decoded.max_tokens, req.max_tokens);
|
||||
assert_eq!(decoded.stream, req.stream);
|
||||
assert_eq!(decoded.extra.get("trace_id"), Some(&json!("t-1")));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::llm::types::message::OpenaiChatMessage;
|
||||
use crate::llm::types::openai_message::OpenaiChatMessage;
|
||||
use crate::llm::types::shared::{FinishReason, ServiceTier};
|
||||
use crate::llm::types::tool::OpenaiToolCall;
|
||||
use crate::llm::types::usage::Usage;
|
||||
|
||||
@@ -0,0 +1,869 @@
|
||||
//! IR 层 MessageResponse / StreamEvent / PartialMessageResponse —— 流式响应模型。
|
||||
//!
|
||||
//! 设计目标见 `docs/10-llm-provider-refinement.md` §2.3 Decision-03 +
|
||||
//! `docs/9c-llm-provider-trait.md` §4.3 / §4.4。
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::types::message::{ContentBlock, ContentBlockType, Message};
|
||||
use crate::llm::types::shared::FinishReason;
|
||||
use crate::llm::types::usage::{CompletionTokensDetails, PromptTokensDetails, Usage};
|
||||
|
||||
/// Provider 终止原因(统一枚举,对应各 Provider 的 finish_reason)。
|
||||
///
|
||||
/// 注意:与现有 `FinishReason` 在 `openai_message::shared` 模块中并存,
|
||||
/// Phase 2 完成时统一收敛。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StopReason {
|
||||
/// 自然停止。
|
||||
Stop,
|
||||
/// 达到长度上限。
|
||||
Length,
|
||||
/// 触发了工具调用。
|
||||
ToolUse,
|
||||
/// 内容安全过滤。
|
||||
ContentFilter,
|
||||
/// 达到 max_tokens 限制。
|
||||
MaxTokens,
|
||||
/// 命中停止序列。
|
||||
StopSequence,
|
||||
/// 其他 / 未知。
|
||||
Other,
|
||||
}
|
||||
|
||||
impl From<FinishReason> for StopReason {
|
||||
fn from(r: FinishReason) -> Self {
|
||||
match r {
|
||||
FinishReason::Stop => StopReason::Stop,
|
||||
FinishReason::Length => StopReason::Length,
|
||||
FinishReason::ToolCalls => StopReason::ToolUse,
|
||||
FinishReason::ContentFilter => StopReason::ContentFilter,
|
||||
FinishReason::FunctionCall => StopReason::ToolUse,
|
||||
FinishReason::Other => StopReason::Other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 完整响应。
|
||||
///
|
||||
/// `extra` 作为 Provider 特定字段(`system_fingerprint`、`service_tier` 等)逃生舱。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageResponse {
|
||||
/// 响应唯一 ID(如 OpenAI 的 `chatcmpl-xxx`)。
|
||||
pub id: String,
|
||||
/// 实际使用的模型名。
|
||||
pub model: String,
|
||||
/// Assistant 回复的消息。
|
||||
pub message: Message,
|
||||
/// Token 用量。
|
||||
pub usage: Usage,
|
||||
/// 终止原因。
|
||||
pub stop_reason: StopReason,
|
||||
/// Provider 特定扩展字段。
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
impl MessageResponse {
|
||||
/// 提取 Assistant 回复中的全部纯文本(按出现顺序拼接)。
|
||||
///
|
||||
/// 仅在 `message` 是 `Assistant` 时有意义,其它变体返回空串。
|
||||
pub fn text(&self) -> String {
|
||||
match &self.message {
|
||||
Message::Assistant { content } => content
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
ContentBlock::Text { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 流式用量 —— 字段为 `Option` 是因为流式场景中各字段可能分多次到达
|
||||
///(如 Anthropic 的 `message_delta` 事件可多次下发 usage 增量)。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct PartialUsage {
|
||||
/// 输入 token 数。
|
||||
pub prompt_tokens: Option<u32>,
|
||||
/// 输出 token 数。
|
||||
pub completion_tokens: Option<u32>,
|
||||
/// 总 token 数。
|
||||
pub total_tokens: Option<u32>,
|
||||
/// 输出 token 明细(含 reasoning_tokens 等)。
|
||||
pub completion_tokens_details: Option<CompletionTokensDetails>,
|
||||
/// 输入 token 明细(含 cached_tokens 等)。
|
||||
pub prompt_tokens_details: Option<PromptTokensDetails>,
|
||||
}
|
||||
|
||||
impl PartialUsage {
|
||||
/// 字段级合并 `other` 到 `self`:`other` 中 `Some` 字段覆盖 `self`。
|
||||
pub fn merge_from(&mut self, other: &PartialUsage) {
|
||||
if let Some(v) = other.prompt_tokens {
|
||||
self.prompt_tokens = Some(v);
|
||||
}
|
||||
if let Some(v) = other.completion_tokens {
|
||||
self.completion_tokens = Some(v);
|
||||
}
|
||||
if let Some(v) = other.total_tokens {
|
||||
self.total_tokens = Some(v);
|
||||
}
|
||||
if let Some(v) = other.completion_tokens_details {
|
||||
self.completion_tokens_details = Some(v);
|
||||
}
|
||||
if let Some(v) = other.prompt_tokens_details {
|
||||
self.prompt_tokens_details = Some(v);
|
||||
}
|
||||
}
|
||||
|
||||
/// 转换为完整的 `Usage`,缺失字段默认为 0。
|
||||
pub fn into_usage(self) -> Usage {
|
||||
Usage {
|
||||
prompt_tokens: self.prompt_tokens.unwrap_or(0),
|
||||
completion_tokens: self.completion_tokens.unwrap_or(0),
|
||||
total_tokens: self.total_tokens.unwrap_or(0),
|
||||
completion_tokens_details: self.completion_tokens_details,
|
||||
prompt_tokens_details: self.prompt_tokens_details,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 内容块构建器 —— 在流式累积阶段持有单 block 的原始拼接状态。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentBlockBuilder {
|
||||
/// 文本块(按增量拼接)。
|
||||
Text(String),
|
||||
/// 思考块(拼接中可携带 signature)。
|
||||
Thinking {
|
||||
buffer: String,
|
||||
signature: Option<String>,
|
||||
},
|
||||
/// 拒绝 / 政策阻断(OpenAI refusal)。
|
||||
Refusal(String),
|
||||
/// 工具调用块(id / name 来自 `ContentBlockStart`,arguments 按增量拼接)。
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
arguments: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// 流式事件 —— LLM 流式响应的语义化事件。
|
||||
///
|
||||
/// 设计变更(参考 `docs/10-llm-provider-refinement.md` §2.3):
|
||||
/// `MessageComplete` 仅携带 `full_response: MessageResponse`,`stop_reason` 和
|
||||
/// `thinking_signature` 不再作为顶层冗余字段。Provider 在内部维护
|
||||
/// `thinking_signature`,最终通过 `finalize()` 回填到 `full_response` 的 `Thinking` block 中。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StreamEvent {
|
||||
/// 消息开始(元信息)。
|
||||
MessageStart { id: String, model: String },
|
||||
/// 内容块开始(告知块类型,携带 id/name for ToolUse)。
|
||||
ContentBlockStart { index: u32, block_type: ContentBlockType },
|
||||
/// 内容块结束标记。
|
||||
ContentBlockEnd { index: u32 },
|
||||
/// 文本增量。
|
||||
TextDelta { text: String },
|
||||
/// 思考 / 推理增量。
|
||||
ThinkingDelta { text: String },
|
||||
/// 拒绝 / 政策阻断增量。
|
||||
RefusalDelta { text: String },
|
||||
/// 工具调用参数增量(按 index 区分并行调用)。
|
||||
ToolCallArgumentsDelta { index: u32, arguments: String },
|
||||
/// 工具调用结束标记。
|
||||
ToolCallEnd { index: u32 },
|
||||
/// 用量更新(字段级合并)。
|
||||
CostUpdate { usage: PartialUsage },
|
||||
/// 消息完成 —— 唯一可靠的完整响应来源。
|
||||
MessageComplete { full_response: MessageResponse },
|
||||
/// 错误事件。
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
/// 流式响应累积状态。
|
||||
///
|
||||
/// Provider 的流处理循环逐个消费 `StreamEvent`、调用 `apply_to` 更新此结构,
|
||||
/// 在收到 `MessageComplete` 后调用 `finalize()` 得到完整 `MessageResponse`。
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct PartialMessageResponse {
|
||||
/// 响应 ID(来自 `MessageStart`)。
|
||||
pub id: Option<String>,
|
||||
/// 模型名(来自 `MessageStart`)。
|
||||
pub model: Option<String>,
|
||||
/// 按 index 索引的内容块构建器。
|
||||
pub blocks: BTreeMap<u32, ContentBlockBuilder>,
|
||||
/// 已标记"完成"(收到 `ContentBlockEnd` 或 `ToolCallEnd`)的 block index。
|
||||
pub block_completion: HashSet<u32>,
|
||||
/// 当前最后一个未结束的 block index(用于 TextDelta / ThinkingDelta 等定位目标 block)。
|
||||
pub last_open_index: Option<u32>,
|
||||
/// 流式累积用量。
|
||||
pub usage: PartialUsage,
|
||||
/// 终止原因(从 Provider 的终止事件推断)。
|
||||
pub stop_reason: Option<StopReason>,
|
||||
/// 思考签名(Anthropic provider 直接通过 `set_thinking_signature` 设置,不经过事件层)。
|
||||
pub thinking_signature: Option<String>,
|
||||
/// 是否遇到错误事件。
|
||||
pub is_errored: bool,
|
||||
/// 是否收到完成事件。
|
||||
pub is_complete: bool,
|
||||
}
|
||||
|
||||
// ponytail: Provider 可能在 `finalize()` 时希望保留 partial 快照(例如 chat_stream
|
||||
// 流关闭时需要把最终构造结果暴露为 `MessageComplete.full_response`,但同时 partial
|
||||
// 仍要为消费方后续 apply 提供访问)。当前所有内部字段派生 `Clone` —— 单独 derive 即可。
|
||||
impl Clone for PartialMessageResponse {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
id: self.id.clone(),
|
||||
model: self.model.clone(),
|
||||
blocks: self.blocks.clone(),
|
||||
block_completion: self.block_completion.clone(),
|
||||
last_open_index: self.last_open_index,
|
||||
usage: self.usage.clone(),
|
||||
stop_reason: self.stop_reason,
|
||||
thinking_signature: self.thinking_signature.clone(),
|
||||
is_errored: self.is_errored,
|
||||
is_complete: self.is_complete,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialMessageResponse {
|
||||
/// 创建一个新的空累积状态。
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 由 Provider 直接写入 Anthropic 的 thinking signature。
|
||||
///
|
||||
/// 详见 `docs/10-llm-provider-refinement.md` §2.3 — `thinking_signature`
|
||||
/// 不经过事件层,由 Provider 流循环直接写入此内部状态。
|
||||
pub fn set_thinking_signature(&mut self, signature: impl Into<String>) {
|
||||
self.thinking_signature = Some(signature.into());
|
||||
}
|
||||
|
||||
/// 应用一个流事件到累积状态。
|
||||
///
|
||||
/// 返回 `false` 表示遇到 `Error` 事件,调用方应终止流处理。
|
||||
pub fn apply_to(&mut self, event: &StreamEvent) -> bool {
|
||||
match event {
|
||||
StreamEvent::MessageStart { id, model } => {
|
||||
self.id = Some(id.clone());
|
||||
self.model = Some(model.clone());
|
||||
true
|
||||
}
|
||||
StreamEvent::ContentBlockStart { index, block_type } => {
|
||||
let builder = match block_type {
|
||||
ContentBlockType::Text => ContentBlockBuilder::Text(String::new()),
|
||||
ContentBlockType::Thinking => ContentBlockBuilder::Thinking {
|
||||
buffer: String::new(),
|
||||
signature: None,
|
||||
},
|
||||
ContentBlockType::Refusal => ContentBlockBuilder::Refusal(String::new()),
|
||||
ContentBlockType::ToolUse { id, name } => ContentBlockBuilder::ToolUse {
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
arguments: String::new(),
|
||||
},
|
||||
};
|
||||
self.blocks.insert(*index, builder);
|
||||
self.last_open_index = Some(*index);
|
||||
true
|
||||
}
|
||||
StreamEvent::ContentBlockEnd { index } => {
|
||||
self.block_completion.insert(*index);
|
||||
if self.last_open_index == Some(*index) {
|
||||
self.last_open_index = None;
|
||||
}
|
||||
true
|
||||
}
|
||||
StreamEvent::TextDelta { text } => {
|
||||
if let Some(idx) = self.last_open_index
|
||||
&& let Some(builder) = self.blocks.get_mut(&idx)
|
||||
{
|
||||
match builder {
|
||||
ContentBlockBuilder::Text(buf) => buf.push_str(text),
|
||||
ContentBlockBuilder::Thinking { buffer, .. } => buffer.push_str(text),
|
||||
ContentBlockBuilder::Refusal(buf) => buf.push_str(text),
|
||||
ContentBlockBuilder::ToolUse { arguments, .. } => {
|
||||
arguments.push_str(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
StreamEvent::ThinkingDelta { text } => {
|
||||
if let Some(idx) = self.last_open_index
|
||||
&& let Some(builder) = self.blocks.get_mut(&idx)
|
||||
&& let ContentBlockBuilder::Thinking { buffer, .. } = builder
|
||||
{
|
||||
buffer.push_str(text);
|
||||
}
|
||||
true
|
||||
}
|
||||
StreamEvent::RefusalDelta { text } => {
|
||||
if let Some(idx) = self.last_open_index
|
||||
&& let Some(builder) = self.blocks.get_mut(&idx)
|
||||
&& let ContentBlockBuilder::Refusal(buf) = builder
|
||||
{
|
||||
buf.push_str(text);
|
||||
}
|
||||
true
|
||||
}
|
||||
StreamEvent::ToolCallArgumentsDelta { index, arguments } => {
|
||||
if let Some(ContentBlockBuilder::ToolUse {
|
||||
arguments: buf, ..
|
||||
}) = self.blocks.get_mut(index)
|
||||
{
|
||||
buf.push_str(arguments);
|
||||
}
|
||||
true
|
||||
}
|
||||
StreamEvent::ToolCallEnd { index } => {
|
||||
self.block_completion.insert(*index);
|
||||
if self.last_open_index == Some(*index) {
|
||||
self.last_open_index = None;
|
||||
}
|
||||
true
|
||||
}
|
||||
StreamEvent::CostUpdate { usage } => {
|
||||
self.usage.merge_from(usage);
|
||||
true
|
||||
}
|
||||
StreamEvent::MessageComplete { .. } => {
|
||||
self.is_complete = true;
|
||||
true
|
||||
}
|
||||
StreamEvent::Error { message } => {
|
||||
tracing::warn!(error = %message, "partial response received Error event");
|
||||
self.is_errored = true;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 完成累积,构造完整 `MessageResponse`。
|
||||
///
|
||||
/// - 按 index 升序遍历 `blocks` 转换为 `ContentBlock`
|
||||
/// - 缺失字段用 `self.thinking_signature` 回填
|
||||
/// - 终止原因默认 `Stop`,除非上层显式设置
|
||||
pub fn finalize(self) -> Result<MessageResponse, LlmError> {
|
||||
let mut content_blocks = Vec::with_capacity(self.blocks.len());
|
||||
for (idx, builder) in self.blocks {
|
||||
let block = Self::builder_to_block(idx, builder, self.thinking_signature.as_deref())
|
||||
.map_err(|e| LlmError::Other(format!(
|
||||
"partial 块 #{idx} finalize 失败: {e}"
|
||||
)))?;
|
||||
content_blocks.push(block);
|
||||
}
|
||||
|
||||
let usage = self.usage.into_usage();
|
||||
let stop_reason = self.stop_reason.unwrap_or(StopReason::Stop);
|
||||
|
||||
let message = Message::Assistant {
|
||||
content: content_blocks,
|
||||
};
|
||||
|
||||
Ok(MessageResponse {
|
||||
id: self.id.unwrap_or_default(),
|
||||
model: self.model.unwrap_or_default(),
|
||||
message,
|
||||
usage,
|
||||
stop_reason,
|
||||
extra: HashMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn builder_to_block(
|
||||
_idx: u32,
|
||||
builder: ContentBlockBuilder,
|
||||
fallback_signature: Option<&str>,
|
||||
) -> Result<ContentBlock, String> {
|
||||
match builder {
|
||||
ContentBlockBuilder::Text(buf) => Ok(ContentBlock::Text { text: buf }),
|
||||
ContentBlockBuilder::Thinking { buffer, signature } => {
|
||||
let signature = signature.or_else(|| fallback_signature.map(str::to_string));
|
||||
Ok(ContentBlock::Thinking {
|
||||
text: buffer,
|
||||
signature,
|
||||
})
|
||||
}
|
||||
ContentBlockBuilder::Refusal(buf) => Ok(ContentBlock::Text { text: buf }),
|
||||
ContentBlockBuilder::ToolUse {
|
||||
id,
|
||||
name,
|
||||
arguments,
|
||||
} => {
|
||||
let input: Value = serde_json::from_str(&arguments).unwrap_or(Value::Null);
|
||||
Ok(ContentBlock::ToolUse { id, name, input })
|
||||
}
|
||||
}
|
||||
.map_err(|_: std::convert::Infallible| unreachable!("builder_to_block is total"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn empty_response() -> MessageResponse {
|
||||
MessageResponse {
|
||||
id: "resp-1".into(),
|
||||
model: "m".into(),
|
||||
message: Message::Assistant { content: vec![] },
|
||||
usage: Usage::default(),
|
||||
stop_reason: StopReason::Stop,
|
||||
extra: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_reason_from_finish_reason_mapping() {
|
||||
assert_eq!(StopReason::from(FinishReason::Stop), StopReason::Stop);
|
||||
assert_eq!(
|
||||
StopReason::from(FinishReason::ToolCalls),
|
||||
StopReason::ToolUse
|
||||
);
|
||||
assert_eq!(
|
||||
StopReason::from(FinishReason::FunctionCall),
|
||||
StopReason::ToolUse
|
||||
);
|
||||
assert_eq!(StopReason::from(FinishReason::Length), StopReason::Length);
|
||||
assert_eq!(StopReason::from(FinishReason::Other), StopReason::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_response_text_extracts_assistant_text() {
|
||||
let resp = MessageResponse {
|
||||
id: "r".into(),
|
||||
model: "m".into(),
|
||||
message: Message::Assistant {
|
||||
content: vec![
|
||||
ContentBlock::Thinking {
|
||||
text: "reasoning".into(),
|
||||
signature: None,
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "hello ".into(),
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "world".into(),
|
||||
},
|
||||
ContentBlock::ToolUse {
|
||||
id: "c".into(),
|
||||
name: "search".into(),
|
||||
input: json!({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
usage: Usage::default(),
|
||||
stop_reason: StopReason::ToolUse,
|
||||
extra: HashMap::new(),
|
||||
};
|
||||
assert_eq!(resp.text(), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_response_text_empty_when_not_assistant() {
|
||||
let resp = MessageResponse {
|
||||
id: "r".into(),
|
||||
model: "m".into(),
|
||||
message: Message::user_text("hi"),
|
||||
usage: Usage::default(),
|
||||
stop_reason: StopReason::Stop,
|
||||
extra: HashMap::new(),
|
||||
};
|
||||
assert_eq!(resp.text(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_usage_merge_picks_some_fields() {
|
||||
let mut a = PartialUsage {
|
||||
prompt_tokens: Some(10),
|
||||
completion_tokens: None,
|
||||
..Default::default()
|
||||
};
|
||||
let b = PartialUsage {
|
||||
prompt_tokens: Some(99),
|
||||
completion_tokens: Some(20),
|
||||
total_tokens: Some(30),
|
||||
..Default::default()
|
||||
};
|
||||
a.merge_from(&b);
|
||||
assert_eq!(a.prompt_tokens, Some(99));
|
||||
assert_eq!(a.completion_tokens, Some(20));
|
||||
assert_eq!(a.total_tokens, Some(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_usage_into_usage_defaults_missing() {
|
||||
let u = PartialUsage {
|
||||
completion_tokens: Some(7),
|
||||
..Default::default()
|
||||
}
|
||||
.into_usage();
|
||||
assert_eq!(u.prompt_tokens, 0);
|
||||
assert_eq!(u.completion_tokens, 7);
|
||||
assert_eq!(u.total_tokens, 0);
|
||||
}
|
||||
|
||||
// ===== apply_to / finalize 整合测试 =====
|
||||
|
||||
fn apply_seq(state: &mut PartialMessageResponse, events: &[StreamEvent]) -> bool {
|
||||
let mut cont = true;
|
||||
for ev in events {
|
||||
cont = state.apply_to(ev);
|
||||
if !cont {
|
||||
break;
|
||||
}
|
||||
}
|
||||
cont
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_to_message_start_then_finalize() {
|
||||
let mut state = PartialMessageResponse::new();
|
||||
assert!(apply_seq(
|
||||
&mut state,
|
||||
&[StreamEvent::MessageStart {
|
||||
id: "r1".into(),
|
||||
model: "gpt".into(),
|
||||
}],
|
||||
));
|
||||
let resp = state.finalize().expect("ok");
|
||||
assert_eq!(resp.id, "r1");
|
||||
assert_eq!(resp.model, "gpt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_to_text_block_then_finalize() {
|
||||
let mut state = PartialMessageResponse::new();
|
||||
assert!(apply_seq(
|
||||
&mut state,
|
||||
&[
|
||||
StreamEvent::MessageStart {
|
||||
id: "r".into(),
|
||||
model: "m".into(),
|
||||
},
|
||||
StreamEvent::ContentBlockStart {
|
||||
index: 0,
|
||||
block_type: ContentBlockType::Text,
|
||||
},
|
||||
StreamEvent::TextDelta {
|
||||
text: "hello".into(),
|
||||
},
|
||||
StreamEvent::TextDelta {
|
||||
text: " world".into(),
|
||||
},
|
||||
StreamEvent::ContentBlockEnd { index: 0 },
|
||||
StreamEvent::MessageComplete {
|
||||
full_response: empty_response(),
|
||||
},
|
||||
],
|
||||
));
|
||||
assert!(state.is_complete);
|
||||
let resp = state.finalize().expect("ok");
|
||||
match &resp.message {
|
||||
Message::Assistant { content } => {
|
||||
assert_eq!(content.len(), 1);
|
||||
match &content[0] {
|
||||
ContentBlock::Text { text } => assert_eq!(text, "hello world"),
|
||||
_ => panic!("expected Text block"),
|
||||
}
|
||||
}
|
||||
_ => panic!("expected Assistant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_to_tool_use_block_then_finalize() {
|
||||
let mut state = PartialMessageResponse::new();
|
||||
assert!(apply_seq(
|
||||
&mut state,
|
||||
&[
|
||||
StreamEvent::MessageStart {
|
||||
id: "r".into(),
|
||||
model: "m".into(),
|
||||
},
|
||||
StreamEvent::ContentBlockStart {
|
||||
index: 0,
|
||||
block_type: ContentBlockType::ToolUse {
|
||||
id: "call_x".into(),
|
||||
name: "search".into(),
|
||||
},
|
||||
},
|
||||
StreamEvent::ToolCallArgumentsDelta {
|
||||
index: 0,
|
||||
arguments: r#"{"q":"rust"}"#.into(),
|
||||
},
|
||||
StreamEvent::ToolCallEnd { index: 0 },
|
||||
StreamEvent::MessageComplete {
|
||||
full_response: empty_response(),
|
||||
},
|
||||
],
|
||||
));
|
||||
let resp = state.finalize().expect("ok");
|
||||
match &resp.message {
|
||||
Message::Assistant { content } => match &content[0] {
|
||||
ContentBlock::ToolUse { id, name, input } => {
|
||||
assert_eq!(id, "call_x");
|
||||
assert_eq!(name, "search");
|
||||
assert_eq!(input, &json!({"q": "rust"}));
|
||||
}
|
||||
_ => panic!("expected ToolUse"),
|
||||
},
|
||||
_ => panic!("expected Assistant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_to_thinking_block_uses_fallback_signature() {
|
||||
let mut state = PartialMessageResponse::new();
|
||||
state.set_thinking_signature("global-sig");
|
||||
assert!(apply_seq(
|
||||
&mut state,
|
||||
&[
|
||||
StreamEvent::MessageStart {
|
||||
id: "r".into(),
|
||||
model: "m".into(),
|
||||
},
|
||||
StreamEvent::ContentBlockStart {
|
||||
index: 0,
|
||||
block_type: ContentBlockType::Thinking,
|
||||
},
|
||||
StreamEvent::ThinkingDelta {
|
||||
text: "thinking...".into(),
|
||||
},
|
||||
StreamEvent::ContentBlockEnd { index: 0 },
|
||||
StreamEvent::MessageComplete {
|
||||
full_response: empty_response(),
|
||||
},
|
||||
],
|
||||
));
|
||||
let resp = state.finalize().unwrap();
|
||||
match &resp.message {
|
||||
Message::Assistant { content } => match &content[0] {
|
||||
ContentBlock::Thinking { text, signature } => {
|
||||
assert_eq!(text, "thinking...");
|
||||
assert_eq!(signature.as_deref(), Some("global-sig"));
|
||||
}
|
||||
_ => panic!("expected Thinking"),
|
||||
},
|
||||
_ => panic!("expected Assistant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_to_cost_update_merges_across_events() {
|
||||
let mut state = PartialMessageResponse::new();
|
||||
assert!(apply_seq(
|
||||
&mut state,
|
||||
&[
|
||||
StreamEvent::MessageStart {
|
||||
id: "r".into(),
|
||||
model: "m".into(),
|
||||
},
|
||||
StreamEvent::CostUpdate {
|
||||
usage: PartialUsage {
|
||||
prompt_tokens: Some(100),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
StreamEvent::CostUpdate {
|
||||
usage: PartialUsage {
|
||||
completion_tokens: Some(50),
|
||||
total_tokens: Some(150),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
StreamEvent::MessageComplete {
|
||||
full_response: empty_response(),
|
||||
},
|
||||
],
|
||||
));
|
||||
assert_eq!(state.usage.prompt_tokens, Some(100));
|
||||
assert_eq!(state.usage.completion_tokens, Some(50));
|
||||
assert_eq!(state.usage.total_tokens, Some(150));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_to_error_event_returns_false_and_marks_errored() {
|
||||
let mut state = PartialMessageResponse::new();
|
||||
let cont = state.apply_to(&StreamEvent::Error {
|
||||
message: "boom".into(),
|
||||
});
|
||||
assert!(!cont);
|
||||
assert!(state.is_errored);
|
||||
// finalize 仍可调用,错误信息透传由 Provider 流循环处理
|
||||
let resp = state.finalize().expect("ok even after error");
|
||||
assert_eq!(resp.stop_reason, StopReason::Stop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_block_ordering_by_index() {
|
||||
let mut state = PartialMessageResponse::new();
|
||||
// 故意以 index 3 优先于 index 0 提供,验证 finalize 按 index 升序输出
|
||||
assert!(apply_seq(
|
||||
&mut state,
|
||||
&[
|
||||
StreamEvent::MessageStart {
|
||||
id: "r".into(),
|
||||
model: "m".into(),
|
||||
},
|
||||
StreamEvent::ContentBlockStart {
|
||||
index: 3,
|
||||
block_type: ContentBlockType::Text,
|
||||
},
|
||||
StreamEvent::TextDelta {
|
||||
text: "second".into(),
|
||||
},
|
||||
StreamEvent::ContentBlockEnd { index: 3 },
|
||||
StreamEvent::ContentBlockStart {
|
||||
index: 0,
|
||||
block_type: ContentBlockType::Text,
|
||||
},
|
||||
StreamEvent::TextDelta {
|
||||
text: "first".into(),
|
||||
},
|
||||
StreamEvent::ContentBlockEnd { index: 0 },
|
||||
],
|
||||
));
|
||||
let resp = state.finalize().unwrap();
|
||||
match &resp.message {
|
||||
Message::Assistant { content } => {
|
||||
assert_eq!(content.len(), 2);
|
||||
match (&content[0], &content[1]) {
|
||||
(
|
||||
ContentBlock::Text { text: t1 },
|
||||
ContentBlock::Text { text: t2 },
|
||||
) => {
|
||||
assert_eq!(t1, "first");
|
||||
assert_eq!(t2, "second");
|
||||
}
|
||||
_ => panic!("expected Text blocks"),
|
||||
}
|
||||
}
|
||||
_ => panic!("expected Assistant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_complete_does_not_double_emit_in_finalize() {
|
||||
// MessageComplete 仅作完成标记,不修改 blocks
|
||||
let mut state = PartialMessageResponse::new();
|
||||
assert!(apply_seq(
|
||||
&mut state,
|
||||
&[
|
||||
StreamEvent::MessageStart {
|
||||
id: "r".into(),
|
||||
model: "m".into(),
|
||||
},
|
||||
StreamEvent::ContentBlockStart {
|
||||
index: 0,
|
||||
block_type: ContentBlockType::Text,
|
||||
},
|
||||
StreamEvent::TextDelta {
|
||||
text: "x".into(),
|
||||
},
|
||||
StreamEvent::ContentBlockEnd { index: 0 },
|
||||
StreamEvent::MessageComplete {
|
||||
full_response: empty_response(),
|
||||
},
|
||||
],
|
||||
));
|
||||
assert!(state.is_complete);
|
||||
let resp = state.finalize().unwrap();
|
||||
match &resp.message {
|
||||
Message::Assistant { content } => assert_eq!(content.len(), 1),
|
||||
_ => panic!("expected Assistant"),
|
||||
}
|
||||
}
|
||||
|
||||
// ===== JSON roundtrip 测试 =====
|
||||
|
||||
#[test]
|
||||
fn message_response_json_roundtrip() {
|
||||
let resp = MessageResponse {
|
||||
id: "resp-99".into(),
|
||||
model: "gpt-4o".into(),
|
||||
message: Message::Assistant {
|
||||
content: vec![
|
||||
ContentBlock::Text {
|
||||
text: "hello".into(),
|
||||
},
|
||||
ContentBlock::ToolUse {
|
||||
id: "c".into(),
|
||||
name: "f".into(),
|
||||
input: json!({"a": 1}),
|
||||
},
|
||||
],
|
||||
},
|
||||
usage: Usage::from_input_output(10, 20),
|
||||
stop_reason: StopReason::ToolUse,
|
||||
extra: HashMap::from([("trace".to_string(), json!("t-1"))]),
|
||||
};
|
||||
let json = serde_json::to_string(&resp).unwrap();
|
||||
let decoded: MessageResponse = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(decoded.id, resp.id);
|
||||
assert_eq!(decoded.stop_reason, resp.stop_reason);
|
||||
assert_eq!(decoded.usage.prompt_tokens, 10);
|
||||
assert_eq!(decoded.usage.completion_tokens, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_event_json_roundtrip_each_variant() {
|
||||
let events = vec![
|
||||
StreamEvent::MessageStart {
|
||||
id: "r".into(),
|
||||
model: "m".into(),
|
||||
},
|
||||
StreamEvent::ContentBlockStart {
|
||||
index: 0,
|
||||
block_type: ContentBlockType::Text,
|
||||
},
|
||||
StreamEvent::ContentBlockEnd { index: 0 },
|
||||
StreamEvent::TextDelta {
|
||||
text: "t".into(),
|
||||
},
|
||||
StreamEvent::ThinkingDelta {
|
||||
text: "p".into(),
|
||||
},
|
||||
StreamEvent::RefusalDelta {
|
||||
text: "r".into(),
|
||||
},
|
||||
StreamEvent::ToolCallArgumentsDelta {
|
||||
index: 1,
|
||||
arguments: "{\"x\":1}".into(),
|
||||
},
|
||||
StreamEvent::ToolCallEnd { index: 1 },
|
||||
StreamEvent::CostUpdate {
|
||||
usage: PartialUsage {
|
||||
prompt_tokens: Some(1),
|
||||
completion_tokens: Some(2),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
StreamEvent::MessageComplete {
|
||||
full_response: empty_response(),
|
||||
},
|
||||
StreamEvent::Error {
|
||||
message: "boom".into(),
|
||||
},
|
||||
];
|
||||
for original in events {
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let decoded: StreamEvent = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(format!("{:?}", original), format!("{:?}", decoded));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct OpenaiToolDefinition {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub parameters: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub strict: Option<bool>,
|
||||
|
||||
+54
-42
@@ -2,17 +2,16 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::llm::compact::{CompactConfig, CompactState, microcompact, should_compact};
|
||||
use crate::llm::types::OpenaiChatMessage;
|
||||
use crate::llm::types::message::Message;
|
||||
use crate::memory::error::MemoryError;
|
||||
use crate::memory::store::MemoryStore;
|
||||
use crate::memory::types::MemoryItem;
|
||||
|
||||
/// 对话消息管理策略。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum MemoryStrategy {
|
||||
/// 滑动窗口:达到上限时删除最旧消息。
|
||||
SlidingWindow,
|
||||
@@ -40,22 +39,30 @@ impl Default for ConversationMemoryConfig {
|
||||
|
||||
/// 对话记忆 —— 按 session 管理多轮对话消息历史。
|
||||
///
|
||||
/// 内部维护 `Vec<OpenaiChatMessage>` 热缓存(供 `llm::compact` 直接操作),
|
||||
/// 内部维护 `Vec<Message>`(Phase 2 IR 类型)作为热缓存,
|
||||
/// `MemoryStore` 用作冷持久化层。
|
||||
///
|
||||
/// ponytail: Phase 2 切换消息存储从 `OpenaiChatMessage` 到 `Message`。
|
||||
/// `Message` 已实现 `Serialize` / `Deserialize`(Phase 0 FIX-B 引入),
|
||||
/// 序列化格式采用 `#[serde(tag = "type", rename_all = "snake_case")]`,
|
||||
/// 例如:
|
||||
/// ```json
|
||||
/// {"type": "user", "content": [{"type": "text", "text": "hi"}]}
|
||||
/// {"type": "tool_result", "tool_call_id": "c1", "content": [...], "is_error": false}
|
||||
/// ```
|
||||
pub struct ConversationMemory {
|
||||
store: Arc<dyn MemoryStore>,
|
||||
session_id: String,
|
||||
config: ConversationMemoryConfig,
|
||||
/// 热缓存:消息列表,供 `llm::compact` 直接操作。
|
||||
messages: Vec<OpenaiChatMessage>,
|
||||
/// 与 `messages` 一一对应的存储 ID(保持稳定以便淘汰时精准删除)。
|
||||
messages: Vec<Message>,
|
||||
/// 与 `messages` 一一对应的存储 ID。
|
||||
message_ids: Vec<String>,
|
||||
/// 压缩断路器状态。
|
||||
compact_state: CompactState,
|
||||
}
|
||||
|
||||
impl ConversationMemory {
|
||||
/// 创建一个新的 ConversationMemory。
|
||||
pub fn new(
|
||||
store: Arc<dyn MemoryStore>,
|
||||
session_id: impl Into<String>,
|
||||
@@ -71,26 +78,23 @@ impl ConversationMemory {
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 session id。
|
||||
pub fn session_id(&self) -> &str {
|
||||
&self.session_id
|
||||
}
|
||||
|
||||
/// 获取配置。
|
||||
pub fn config(&self) -> &ConversationMemoryConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// 从 MemoryStore 加载历史消息到热缓存。
|
||||
pub async fn load(&mut self) -> Result<(), MemoryError> {
|
||||
let filter = crate::memory::types::MemoryFilter {
|
||||
prefix: Some(self.session_prefix()),
|
||||
..Default::default()
|
||||
};
|
||||
let items = self.store.list(&filter).await?;
|
||||
let mut pairs: Vec<(String, OpenaiChatMessage, OffsetDateTime)> = Vec::with_capacity(items.len());
|
||||
let mut pairs: Vec<(String, Message, OffsetDateTime)> = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
match serde_json::from_str::<OpenaiChatMessage>(&item.content) {
|
||||
match serde_json::from_str::<Message>(&item.content) {
|
||||
Ok(msg) => pairs.push((item.id, msg, item.created_at)),
|
||||
Err(e) => {
|
||||
return Err(MemoryError::Serialization(format!(
|
||||
@@ -100,17 +104,13 @@ impl ConversationMemory {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 按 created_at 升序排列
|
||||
pairs.sort_by_key(|p| p.2);
|
||||
self.message_ids = pairs.iter().map(|p| p.0.clone()).collect();
|
||||
self.messages = pairs.into_iter().map(|p| p.1).collect();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 添加一条消息。
|
||||
///
|
||||
/// 写入热缓存并通过 `MemoryStore` 持久化。如有需要,触发淘汰和压缩。
|
||||
pub async fn add_message(&mut self, msg: OpenaiChatMessage) -> Result<(), MemoryError> {
|
||||
pub async fn add_message(&mut self, msg: Message) -> Result<(), MemoryError> {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let index = self.messages.len();
|
||||
let id = self.make_message_id(index, &now);
|
||||
@@ -119,7 +119,7 @@ impl ConversationMemory {
|
||||
self.messages.push(msg);
|
||||
self.message_ids.push(id.clone());
|
||||
|
||||
// 同步到冷存储
|
||||
// ponytail: 通过 `Message` 的 Serialize 派生实现持久化
|
||||
let item = MemoryItem {
|
||||
id: id.clone(),
|
||||
content: serde_json::to_string(self.messages.last().unwrap())
|
||||
@@ -129,17 +129,14 @@ impl ConversationMemory {
|
||||
};
|
||||
self.store.save(item).await?;
|
||||
|
||||
// 触发淘汰和压缩
|
||||
self.maybe_evict_and_compact().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取完整消息历史。
|
||||
pub fn get_history(&self) -> &[OpenaiChatMessage] {
|
||||
pub fn get_history(&self) -> &[Message] {
|
||||
&self.messages
|
||||
}
|
||||
|
||||
/// 清空所有消息。
|
||||
pub async fn clear(&mut self) -> Result<(), MemoryError> {
|
||||
let to_delete = std::mem::take(&mut self.message_ids);
|
||||
self.messages.clear();
|
||||
@@ -150,18 +147,16 @@ impl ConversationMemory {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 当前消息数量。
|
||||
pub fn len(&self) -> usize {
|
||||
self.messages.len()
|
||||
}
|
||||
|
||||
/// 是否为空。
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.messages.is_empty()
|
||||
}
|
||||
|
||||
fn session_prefix(&self) -> String {
|
||||
format!("conv:{self}:", self = self.session_id)
|
||||
format!("conv:{}:", self.session_id)
|
||||
}
|
||||
|
||||
fn make_message_id(&self, index: usize, now: &OffsetDateTime) -> String {
|
||||
@@ -169,7 +164,6 @@ impl ConversationMemory {
|
||||
}
|
||||
|
||||
async fn maybe_evict_and_compact(&mut self) {
|
||||
// 1. Sliding window 淘汰:删除最旧消息
|
||||
if self.config.strategy == MemoryStrategy::SlidingWindow {
|
||||
while self.messages.len() > self.config.max_turns {
|
||||
if let Some(removed_id) = self.message_ids.first().cloned() {
|
||||
@@ -180,43 +174,58 @@ impl ConversationMemory {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 压缩(复用 llm::compact)
|
||||
if let Some(ref compact_config) = self.config.compact_config {
|
||||
if should_compact(&self.messages, compact_config, &self.compact_state) {
|
||||
if let Some(ref compact_config) = self.config.compact_config
|
||||
&& should_compact(&self.messages, compact_config, &self.compact_state) {
|
||||
let keep_recent = compact_config.keep_recent;
|
||||
let freed = microcompact(&mut self.messages, keep_recent);
|
||||
if freed > 0 {
|
||||
self.compact_state.record_success();
|
||||
} else {
|
||||
// 没有 token 被释放(可能没找到可压缩的 tool result)
|
||||
let _ = self.compact_state.record_failure();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm::types::OpenaiChatMessage;
|
||||
use crate::memory::InMemoryStore;
|
||||
use crate::memory::MemoryStore;
|
||||
|
||||
fn user_text(s: &str) -> OpenaiChatMessage {
|
||||
OpenaiChatMessage::user_text(s)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_and_get_history() {
|
||||
let store = Arc::new(InMemoryStore::new()) as Arc<dyn MemoryStore>;
|
||||
let mut conv = ConversationMemory::new(store, "session1", ConversationMemoryConfig::default());
|
||||
conv.add_message(user_text("hello")).await.unwrap();
|
||||
conv.add_message(user_text("world")).await.unwrap();
|
||||
conv.add_message(Message::user_text("hello")).await.unwrap();
|
||||
conv.add_message(Message::user_text("world")).await.unwrap();
|
||||
assert_eq!(conv.len(), 2);
|
||||
assert_eq!(conv.get_history().len(), 2);
|
||||
}
|
||||
|
||||
/// 验证 Message → JSON → Message 往返(包含 ToolResult 等完整信息)。
|
||||
#[tokio::test]
|
||||
async fn json_roundtrip_preserves_tool_result() {
|
||||
let store = Arc::new(InMemoryStore::new()) as Arc<dyn MemoryStore>;
|
||||
let mut conv = ConversationMemory::new(store, "s1", ConversationMemoryConfig::default());
|
||||
conv.add_message(Message::tool_result("call_1", "ok", false))
|
||||
.await
|
||||
.unwrap();
|
||||
conv.add_message(Message::assistant("done"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let original = conv.get_history().to_vec();
|
||||
assert_eq!(original.len(), 2);
|
||||
|
||||
// 各变体可序列化 + 反序列化
|
||||
for msg in &original {
|
||||
let json = serde_json::to_string(msg).unwrap();
|
||||
let decoded: Message = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(format!("{:?}", decoded), format!("{:?}", msg));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sliding_window_evicts_oldest() {
|
||||
let store = Arc::new(InMemoryStore::new()) as Arc<dyn MemoryStore>;
|
||||
@@ -227,7 +236,9 @@ mod tests {
|
||||
};
|
||||
let mut conv = ConversationMemory::new(store, "s1", config);
|
||||
for i in 0..5 {
|
||||
conv.add_message(user_text(&format!("msg-{i}"))).await.unwrap();
|
||||
conv.add_message(Message::user_text(format!("msg-{i}")))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(conv.len(), 3);
|
||||
}
|
||||
@@ -242,9 +253,10 @@ mod tests {
|
||||
};
|
||||
let mut conv = ConversationMemory::new(store, "s1", config);
|
||||
for i in 0..5 {
|
||||
conv.add_message(user_text(&format!("msg-{i}"))).await.unwrap();
|
||||
conv.add_message(Message::user_text(format!("msg-{i}")))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
// Full 策略不删除消息
|
||||
assert_eq!(conv.len(), 5);
|
||||
}
|
||||
|
||||
@@ -252,7 +264,7 @@ mod tests {
|
||||
async fn clear_empties_messages() {
|
||||
let store = Arc::new(InMemoryStore::new()) as Arc<dyn MemoryStore>;
|
||||
let mut conv = ConversationMemory::new(store.clone(), "s1", ConversationMemoryConfig::default());
|
||||
conv.add_message(user_text("hello")).await.unwrap();
|
||||
conv.add_message(Message::user_text("hello")).await.unwrap();
|
||||
assert!(!conv.is_empty());
|
||||
conv.clear().await.unwrap();
|
||||
assert!(conv.is_empty());
|
||||
|
||||
+13
-6
@@ -3,21 +3,28 @@
|
||||
use thiserror::Error;
|
||||
|
||||
/// 记忆系统错误枚举。
|
||||
///
|
||||
/// 错误消息面向最终用户(中文),并尽量附带可操作的修复建议(如检查环境变量、重试)。
|
||||
#[derive(Debug, Error)]
|
||||
pub enum MemoryError {
|
||||
#[error("Item not found: {0}")]
|
||||
/// 按 ID 未找到指定记忆条目。可重试——通常是 namespace 拼写错误或条目已被淘汰。
|
||||
#[error("未找到记忆条目 '{0}',请检查 ID 或 namespace 是否正确")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Storage error: {0}")]
|
||||
/// 底层存储失败(磁盘满、连接断开等)。一般不可恢复,建议上层记录并告警。
|
||||
#[error("存储失败: {0},请检查后端存储是否可用(磁盘 / 网络 / 权限)")]
|
||||
Storage(String),
|
||||
|
||||
#[error("Serialization error: {0}")]
|
||||
/// 序列化 / 反序列化失败(数据结构变更、JSON 字段缺失等)。一般不可恢复。
|
||||
#[error("序列化失败: {0},请检查数据结构兼容性或升级记忆格式")]
|
||||
Serialization(String),
|
||||
|
||||
#[error("Invalid input: {0}")]
|
||||
/// 调用方传入的参数不合法(空 ID、负数容量等)。可重试——修正参数即可。
|
||||
#[error("参数无效: {0}")]
|
||||
InvalidInput(String),
|
||||
|
||||
#[error("Retrieval error: {0}")]
|
||||
/// 检索过程出错(索引重建失败、评分异常等)。可重试。
|
||||
#[error("检索失败: {0},可重试或调整 query")]
|
||||
RetrievalError(String),
|
||||
}
|
||||
|
||||
@@ -26,4 +33,4 @@ impl MemoryError {
|
||||
pub fn is_recoverable(&self) -> bool {
|
||||
matches!(self, Self::NotFound(_) | Self::RetrievalError(_))
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
-21
@@ -19,7 +19,7 @@ pub const KNOWLEDGE_PREFIX: &str = "knowledge_";
|
||||
/// 同时维护一个 `Vec<PageIndexEntry>` 索引以加速列表遍历。
|
||||
pub struct KnowledgeStore {
|
||||
store: Arc<dyn MemoryStore>,
|
||||
index: std::sync::Mutex<Vec<PageIndexEntry>>,
|
||||
index: tokio::sync::Mutex<Vec<PageIndexEntry>>,
|
||||
}
|
||||
|
||||
impl KnowledgeStore {
|
||||
@@ -27,7 +27,7 @@ impl KnowledgeStore {
|
||||
pub fn new(store: Arc<dyn MemoryStore>) -> Self {
|
||||
Self {
|
||||
store,
|
||||
index: std::sync::Mutex::new(Vec::new()),
|
||||
index: tokio::sync::Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ impl KnowledgeStore {
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let mut index = self.index.lock().unwrap();
|
||||
let mut index = self.index.lock().await;
|
||||
index.clear();
|
||||
for item in items {
|
||||
let page: KnowledgePage = serde_json::from_str(&item.content)
|
||||
@@ -66,7 +66,7 @@ impl KnowledgeStore {
|
||||
created_at: now,
|
||||
};
|
||||
self.store.save(item).await?;
|
||||
let mut index = self.index.lock().unwrap();
|
||||
let mut index = self.index.lock().await;
|
||||
// 替换或追加
|
||||
if let Some(existing) = index.iter_mut().find(|e| e.id == page.id) {
|
||||
*existing = PageIndexEntry::from(&page);
|
||||
@@ -106,7 +106,7 @@ impl KnowledgeStore {
|
||||
pub async fn delete_page(&self, id: &str) -> Result<(), MemoryError> {
|
||||
let full_id = format!("{KNOWLEDGE_PREFIX}{id}");
|
||||
self.store.delete(&full_id).await?;
|
||||
let mut index = self.index.lock().unwrap();
|
||||
let mut index = self.index.lock().await;
|
||||
index.retain(|e| e.id != id);
|
||||
Ok(())
|
||||
}
|
||||
@@ -120,24 +120,31 @@ impl KnowledgeStore {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let needle = query.to_lowercase();
|
||||
// 锁内仅 clone 匹配的 entry id,避免异步 get_page() 持有 index 锁。
|
||||
let ids: Vec<String> = {
|
||||
let index = self.index.lock().await;
|
||||
index
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
entry.title.to_lowercase().contains(&needle)
|
||||
|| entry.summary.to_lowercase().contains(&needle)
|
||||
|| entry.tags.iter().any(|t| t.to_lowercase().contains(&needle))
|
||||
})
|
||||
.map(|entry| entry.id.clone())
|
||||
.collect()
|
||||
};
|
||||
let mut results = Vec::new();
|
||||
let index = self.index.lock().unwrap();
|
||||
for entry in index.iter() {
|
||||
if entry.title.to_lowercase().contains(&needle)
|
||||
|| entry.summary.to_lowercase().contains(&needle)
|
||||
|| entry.tags.iter().any(|t| t.to_lowercase().contains(&needle))
|
||||
{
|
||||
if let Some(page) = self.get_page(&entry.id).await? {
|
||||
results.push(page);
|
||||
}
|
||||
for id in &ids {
|
||||
if let Some(page) = self.get_page(id).await? {
|
||||
results.push(page);
|
||||
}
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// 获取内容目录(所有页面的轻量级索引条目)。
|
||||
pub fn get_index(&self) -> Vec<PageIndexEntry> {
|
||||
self.index.lock().unwrap().clone()
|
||||
pub async fn get_index(&self) -> Vec<PageIndexEntry> {
|
||||
self.index.lock().await.clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +231,7 @@ mod tests {
|
||||
let ks = KnowledgeStore::new(store);
|
||||
ks.add_page(make_page("p1", "A", &[])).await.unwrap();
|
||||
ks.add_page(make_page("p2", "B", &[])).await.unwrap();
|
||||
let index = ks.get_index();
|
||||
let index = ks.get_index().await;
|
||||
assert_eq!(index.len(), 2);
|
||||
}
|
||||
|
||||
@@ -235,13 +242,13 @@ mod tests {
|
||||
// 添加页面
|
||||
ks.add_page(make_page("p1", "A", &[])).await.unwrap();
|
||||
ks.add_page(make_page("p2", "B", &[])).await.unwrap();
|
||||
assert_eq!(ks.get_index().len(), 2);
|
||||
assert_eq!(ks.get_index().await.len(), 2);
|
||||
|
||||
// 模拟 index 漂移:清空后重建
|
||||
ks.index.lock().unwrap().clear();
|
||||
assert_eq!(ks.get_index().len(), 0);
|
||||
ks.index.lock().await.clear();
|
||||
assert_eq!(ks.get_index().await.len(), 0);
|
||||
|
||||
ks.rebuild_index().await.unwrap();
|
||||
assert_eq!(ks.get_index().len(), 2);
|
||||
assert_eq!(ks.get_index().await.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,9 +113,7 @@ fn extract_keywords(query: &str, stop_words: &HashSet<String>) -> Vec<String> {
|
||||
.split(|c: char| !c.is_alphanumeric())
|
||||
.filter_map(|s| {
|
||||
let lower = s.to_lowercase();
|
||||
if lower.is_empty() || lower.chars().count() < 2 {
|
||||
None
|
||||
} else if stop_words.contains(&lower) {
|
||||
if lower.is_empty() || lower.chars().count() < 2 || stop_words.contains(&lower) {
|
||||
None
|
||||
} else {
|
||||
Some(lower)
|
||||
|
||||
+1
-1
@@ -4,4 +4,4 @@ pub mod composer;
|
||||
|
||||
pub use error::PromptError;
|
||||
pub use template::{PromptTemplate, PromptTemplateRegistry, TemplateContext, TemplateValue};
|
||||
pub use composer::PromptComposer;
|
||||
pub use composer::{validate_messages, PromptComposer};
|
||||
|
||||
+63
-128
@@ -1,12 +1,11 @@
|
||||
use crate::llm::types::message::{ContentField, OpenaiChatMessage, OpenaiContentPart};
|
||||
use crate::llm::types::request::OpenaiChatRequest;
|
||||
use crate::llm::types::message::{ContentBlock, Message};
|
||||
use crate::prompt::error::PromptError;
|
||||
use crate::prompt::template::{PromptTemplate, TemplateContext};
|
||||
|
||||
/// 提示词组合器——构建多角色消息序列。
|
||||
#[derive(Default)]
|
||||
pub struct PromptComposer {
|
||||
messages: Vec<OpenaiChatMessage>,
|
||||
messages: Vec<Message>,
|
||||
}
|
||||
|
||||
impl PromptComposer {
|
||||
@@ -16,7 +15,7 @@ impl PromptComposer {
|
||||
}
|
||||
|
||||
/// 从已有的消息列表初始化。
|
||||
pub fn from_messages(messages: Vec<OpenaiChatMessage>) -> Self {
|
||||
pub fn from_messages(messages: Vec<Message>) -> Self {
|
||||
Self { messages }
|
||||
}
|
||||
|
||||
@@ -24,34 +23,32 @@ impl PromptComposer {
|
||||
|
||||
/// 添加一条纯文本 system 消息。
|
||||
pub fn system(mut self, text: impl Into<String>) -> Self {
|
||||
self.push_message(OpenaiChatMessage::system_text(text.into()));
|
||||
self.push_message(Message::system(text.into()));
|
||||
self
|
||||
}
|
||||
|
||||
/// 添加一条纯文本 user 消息。
|
||||
pub fn user(mut self, text: impl Into<String>) -> Self {
|
||||
self.push_message(OpenaiChatMessage::user_text(text.into()));
|
||||
self.push_message(Message::user_text(text.into()));
|
||||
self
|
||||
}
|
||||
|
||||
/// 添加一条纯文本 assistant 消息。
|
||||
pub fn assistant(mut self, text: impl Into<String>) -> Self {
|
||||
self.push_message(OpenaiChatMessage::assistant_text(text.into()));
|
||||
self.push_message(Message::assistant(text.into()));
|
||||
self
|
||||
}
|
||||
|
||||
/// 添加一条纯文本 developer 消息(o1 系列模型使用)。
|
||||
/// IR 层统一映射为 `Message::System`,由 Provider 在发送时按目标模型决定 `role`。
|
||||
pub fn developer(mut self, text: impl Into<String>) -> Self {
|
||||
self.push_message(OpenaiChatMessage::developer_text(text.into()));
|
||||
self.push_message(Message::system(text.into()));
|
||||
self
|
||||
}
|
||||
|
||||
/// 添加一条 Tool 消息(工具执行结果回传)。
|
||||
pub fn tool(mut self, tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
|
||||
self.push_message(OpenaiChatMessage::tool_result(
|
||||
tool_call_id.into(),
|
||||
content.into(),
|
||||
));
|
||||
self.push_message(Message::tool_result(tool_call_id.into(), content.into(), false));
|
||||
self
|
||||
}
|
||||
|
||||
@@ -64,7 +61,7 @@ impl PromptComposer {
|
||||
ctx: &TemplateContext,
|
||||
) -> Result<Self, PromptError> {
|
||||
let text = template.render(ctx)?;
|
||||
self.push_message(OpenaiChatMessage::user_text(text));
|
||||
self.push_message(Message::user_text(text));
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
@@ -75,7 +72,7 @@ impl PromptComposer {
|
||||
ctx: &TemplateContext,
|
||||
) -> Result<Self, PromptError> {
|
||||
let text = template.render(ctx)?;
|
||||
self.push_message(OpenaiChatMessage::system_text(text));
|
||||
self.push_message(Message::system(text));
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
@@ -86,7 +83,7 @@ impl PromptComposer {
|
||||
ctx: &TemplateContext,
|
||||
) -> Result<Self, PromptError> {
|
||||
let text = template.render(ctx)?;
|
||||
self.push_message(OpenaiChatMessage::assistant_text(text));
|
||||
self.push_message(Message::assistant(text));
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
@@ -97,150 +94,98 @@ impl PromptComposer {
|
||||
ctx: &TemplateContext,
|
||||
) -> Result<Self, PromptError> {
|
||||
let text = template.render(ctx)?;
|
||||
self.push_message(OpenaiChatMessage::developer_text(text));
|
||||
self.push_message(Message::system(text));
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
// ===== 多模态 ContentPart =====
|
||||
// ===== 多模态 ContentBlock =====
|
||||
|
||||
/// 添加一条含指定 ContentPart 的 system 消息。
|
||||
pub fn system_content(mut self, part: OpenaiContentPart) -> Self {
|
||||
self.push_message(OpenaiChatMessage::System {
|
||||
content: ContentField::Array(vec![part]),
|
||||
name: None,
|
||||
/// 添加一条含指定 ContentBlock 的 system 消息。
|
||||
pub fn system_content(mut self, block: ContentBlock) -> Self {
|
||||
self.push_message(Message::System {
|
||||
content: vec![block],
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// 添加一条含指定 ContentPart 的 user 消息。
|
||||
pub fn user_content(mut self, part: OpenaiContentPart) -> Self {
|
||||
self.push_message(OpenaiChatMessage::User {
|
||||
content: ContentField::Array(vec![part]),
|
||||
name: None,
|
||||
/// 添加一条含指定 ContentBlock 的 user 消息。
|
||||
pub fn user_content(mut self, block: ContentBlock) -> Self {
|
||||
self.push_message(Message::User {
|
||||
content: vec![block],
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// 添加一条含指定 ContentPart 的 assistant 消息。
|
||||
pub fn assistant_content(mut self, part: OpenaiContentPart) -> Self {
|
||||
self.push_message(OpenaiChatMessage::Assistant {
|
||||
content: ContentField::Array(vec![part]),
|
||||
refusal: None,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
/// 添加一条含指定 ContentBlock 的 assistant 消息。
|
||||
pub fn assistant_content(mut self, block: ContentBlock) -> Self {
|
||||
self.push_message(Message::Assistant {
|
||||
content: vec![block],
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// 添加一条含指定 ContentPart 的 developer 消息。
|
||||
pub fn developer_content(mut self, part: OpenaiContentPart) -> Self {
|
||||
self.push_message(OpenaiChatMessage::Developer {
|
||||
content: ContentField::Array(vec![part]),
|
||||
name: None,
|
||||
/// 添加一条含指定 ContentBlock 的 developer 消息。
|
||||
pub fn developer_content(mut self, block: ContentBlock) -> Self {
|
||||
self.push_message(Message::System {
|
||||
content: vec![block],
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// 添加一条含指定 ContentPart 的 Tool 消息。
|
||||
/// 添加一条含指定 ContentBlock 的 Tool 消息。
|
||||
pub fn tool_content(
|
||||
mut self,
|
||||
tool_call_id: impl Into<String>,
|
||||
part: OpenaiContentPart,
|
||||
block: ContentBlock,
|
||||
) -> Self {
|
||||
self.push_message(OpenaiChatMessage::Tool {
|
||||
content: ContentField::Array(vec![part]),
|
||||
self.push_message(Message::ToolResult {
|
||||
tool_call_id: tool_call_id.into(),
|
||||
content: vec![block],
|
||||
is_error: false,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// 批量添加 ContentPart 作为 user 消息。
|
||||
pub fn user_contents(mut self, parts: Vec<OpenaiContentPart>) -> Self {
|
||||
self.push_message(OpenaiChatMessage::User {
|
||||
content: ContentField::Array(parts),
|
||||
name: None,
|
||||
});
|
||||
/// 批量添加 ContentBlock 作为 user 消息。
|
||||
pub fn user_contents(mut self, blocks: Vec<ContentBlock>) -> Self {
|
||||
self.push_message(Message::User { content: blocks });
|
||||
self
|
||||
}
|
||||
|
||||
/// 批量添加 ContentPart 作为 system 消息。
|
||||
pub fn system_contents(mut self, parts: Vec<OpenaiContentPart>) -> Self {
|
||||
self.push_message(OpenaiChatMessage::System {
|
||||
content: ContentField::Array(parts),
|
||||
name: None,
|
||||
});
|
||||
/// 批量添加 ContentBlock 作为 system 消息。
|
||||
pub fn system_contents(mut self, blocks: Vec<ContentBlock>) -> Self {
|
||||
self.push_message(Message::System { content: blocks });
|
||||
self
|
||||
}
|
||||
|
||||
/// 批量添加 ContentPart 作为 assistant 消息。
|
||||
pub fn assistant_contents(mut self, parts: Vec<OpenaiContentPart>) -> Self {
|
||||
self.push_message(OpenaiChatMessage::Assistant {
|
||||
content: ContentField::Array(parts),
|
||||
refusal: None,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
});
|
||||
/// 批量添加 ContentBlock 作为 assistant 消息。
|
||||
pub fn assistant_contents(mut self, blocks: Vec<ContentBlock>) -> Self {
|
||||
self.push_message(Message::Assistant { content: blocks });
|
||||
self
|
||||
}
|
||||
|
||||
/// 批量添加 ContentPart 作为 developer 消息。
|
||||
pub fn developer_contents(mut self, parts: Vec<OpenaiContentPart>) -> Self {
|
||||
self.push_message(OpenaiChatMessage::Developer {
|
||||
content: ContentField::Array(parts),
|
||||
name: None,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
// ===== 角色标识 =====
|
||||
|
||||
/// 为上一条添加的消息设置 `name` 字段。
|
||||
pub fn with_name(mut self, name: impl Into<String>) -> Self {
|
||||
let name = name.into();
|
||||
if let Some(msg) = self.messages.last_mut() {
|
||||
set_message_name(msg, name);
|
||||
}
|
||||
/// 批量添加 ContentBlock 作为 developer 消息。
|
||||
pub fn developer_contents(mut self, blocks: Vec<ContentBlock>) -> Self {
|
||||
self.push_message(Message::System { content: blocks });
|
||||
self
|
||||
}
|
||||
|
||||
// ===== 构建 =====
|
||||
|
||||
/// 构建最终的消息列表。
|
||||
pub fn build(self) -> Vec<OpenaiChatMessage> {
|
||||
pub fn build(self) -> Vec<Message> {
|
||||
self.messages
|
||||
}
|
||||
|
||||
/// 构建并直接创建 ChatRequest(需搭配 model 参数)。
|
||||
/// 返回的 `OpenaiChatRequest` 中 `tools`、`temperature`、`max_tokens` 等字段均为 `None`,
|
||||
/// 可通过结构体更新语法补全:`OpenaiChatRequest { tools: Some(...), ..req }`。
|
||||
pub fn build_request(self, model: impl Into<String>) -> OpenaiChatRequest {
|
||||
OpenaiChatRequest {
|
||||
model: model.into(),
|
||||
messages: self.messages,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 内部方法 =====
|
||||
|
||||
fn push_message(&mut self, msg: OpenaiChatMessage) {
|
||||
fn push_message(&mut self, msg: Message) {
|
||||
self.messages.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_message_name(msg: &mut OpenaiChatMessage, name: String) {
|
||||
match msg {
|
||||
OpenaiChatMessage::Developer { name: n, .. } => *n = Some(name),
|
||||
OpenaiChatMessage::System { name: n, .. } => *n = Some(name),
|
||||
OpenaiChatMessage::User { name: n, .. } => *n = Some(name),
|
||||
OpenaiChatMessage::Assistant { name: n, .. } => *n = Some(name),
|
||||
OpenaiChatMessage::Tool { .. } => {}
|
||||
OpenaiChatMessage::Function { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证消息序列是否符合 OpenAI API 要求。
|
||||
pub fn validate_messages(messages: &[OpenaiChatMessage]) -> Result<(), PromptError> {
|
||||
/// 验证消息序列是否符合 LLM API 要求(Tool 消息必须紧跟含 tool_calls 的 Assistant)。
|
||||
pub fn validate_messages(messages: &[Message]) -> Result<(), PromptError> {
|
||||
if messages.is_empty() {
|
||||
return Err(PromptError::InvalidSequence(
|
||||
"消息列表不能为空".to_string(),
|
||||
@@ -251,10 +196,7 @@ pub fn validate_messages(messages: &[OpenaiChatMessage]) -> Result<(), PromptErr
|
||||
|
||||
for (i, msg) in messages.iter().enumerate() {
|
||||
match msg {
|
||||
OpenaiChatMessage::Tool {
|
||||
tool_call_id,
|
||||
..
|
||||
} => {
|
||||
Message::ToolResult { tool_call_id, .. } => {
|
||||
if last_tool_call_ids.is_empty() {
|
||||
return Err(PromptError::InvalidSequence(format!(
|
||||
"消息[{i}] Tool 消息前必须有 Assistant 消息且含 tool_calls"
|
||||
@@ -267,21 +209,14 @@ pub fn validate_messages(messages: &[OpenaiChatMessage]) -> Result<(), PromptErr
|
||||
)));
|
||||
}
|
||||
}
|
||||
OpenaiChatMessage::Assistant {
|
||||
tool_calls: Some(calls),
|
||||
..
|
||||
} => {
|
||||
Message::Assistant { content } => {
|
||||
last_tool_call_ids.clear();
|
||||
for call in calls {
|
||||
let crate::llm::types::OpenaiToolCall::Function { id, .. } = call;
|
||||
last_tool_call_ids.push(id.clone());
|
||||
for block in content {
|
||||
if let ContentBlock::ToolUse { id, .. } = block {
|
||||
last_tool_call_ids.push(id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
OpenaiChatMessage::Assistant {
|
||||
tool_calls: None, ..
|
||||
} => {
|
||||
last_tool_call_ids.clear();
|
||||
}
|
||||
_ => {
|
||||
last_tool_call_ids.clear();
|
||||
}
|
||||
@@ -318,18 +253,18 @@ mod tests {
|
||||
|
||||
assert_eq!(msgs.len(), 4);
|
||||
match &msgs[3] {
|
||||
OpenaiChatMessage::Tool {
|
||||
Message::ToolResult {
|
||||
tool_call_id,
|
||||
content,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(tool_call_id, "call_123");
|
||||
match content {
|
||||
ContentField::String(s) => assert_eq!(s, "Sunny, 25°C"),
|
||||
_ => {}
|
||||
match &content[0] {
|
||||
ContentBlock::Text { text } => assert_eq!(text, "Sunny, 25°C"),
|
||||
_ => panic!("Expected Text block"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected Tool message"),
|
||||
_ => panic!("Expected ToolResult message"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,7 +280,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_validate_messages_empty() {
|
||||
let msgs: Vec<OpenaiChatMessage> = vec![];
|
||||
let msgs: Vec<Message> = vec![];
|
||||
assert!(validate_messages(&msgs).is_err());
|
||||
}
|
||||
|
||||
|
||||
+7
-7
@@ -2,27 +2,27 @@ use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum PromptError {
|
||||
#[error("模板解析错误: {0}")]
|
||||
#[error("模板解析错误: {0}。请检查模板语法({{var}} / {{#if}} / {{#each}})")]
|
||||
Parse(String),
|
||||
|
||||
#[error("渲染错误: 变量 '{0}' 未找到")]
|
||||
#[error("渲染错误: 变量 '{0}' 未找到。请在 TemplateContext 中插入该变量")]
|
||||
VariableNotFound(String),
|
||||
|
||||
#[error("渲染错误: 引用的子模板 '{0}' 未注册")]
|
||||
#[error("渲染错误: 引用的子模板 '{0}' 未注册。请先用 PromptTemplateRegistry::register 注册该子模板")]
|
||||
PartialNotFound(String),
|
||||
|
||||
#[error("渲染错误: '{0}' 不是数组,无法遍历")]
|
||||
#[error("渲染错误: '{0}' 不是数组,无法遍历。请确认传入的是数组或先判空")]
|
||||
NotAnArray(String),
|
||||
|
||||
#[error("渲染递归超过最大深度限制 ({0})")]
|
||||
#[error("渲染递归超过最大深度限制 ({0})。请检查是否有循环 include,或调大 MAX_DEPTH")]
|
||||
MaxDepthReached(u8),
|
||||
|
||||
#[error("渲染错误: {0}")]
|
||||
Render(String),
|
||||
|
||||
#[error("消息序列校验失败: {0}")]
|
||||
#[error("消息序列校验失败: {0}。请检查消息角色顺序(例如 tool 必须在 assistant tool_call 之后)")]
|
||||
InvalidSequence(String),
|
||||
|
||||
#[error("文件读取错误: {0}")]
|
||||
#[error("文件读取错误: {0}。请检查模板文件路径与权限")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
+13
-13
@@ -5,44 +5,44 @@ use std::sync::Arc;
|
||||
/// 工具调用过程中可能发生的所有错误。
|
||||
#[derive(thiserror::Error, Debug, Clone)]
|
||||
pub enum ToolError {
|
||||
/// 工具未注册。
|
||||
#[error("工具 '{0}' 未注册")]
|
||||
/// 工具未注册。不可恢复——需调用方先 `registry.register(...)`。
|
||||
#[error("工具 '{0}' 未注册。请先用 ToolRegistry::register(...) 注册该工具,或检查 LLM 输出的工具名拼写")]
|
||||
NotFound(String),
|
||||
|
||||
/// 工具执行失败(可恢复——文本回传 LLM)。
|
||||
#[error("工具 '{0}' 执行失败: {1}")]
|
||||
/// 工具执行失败(可恢复——文本回传 LLM 由其决定重试或放弃)。
|
||||
#[error("工具 '{0}' 执行失败: {1}。可让 LLM 调整参数后重试,或切换到备选工具")]
|
||||
ExecutionFailed(String, String),
|
||||
|
||||
/// 工具参数无效(可恢复——文本回传 LLM)。
|
||||
#[error("工具 '{0}' 参数无效: {1}")]
|
||||
#[error("工具 '{0}' 参数无效: {1}。请检查 LLM 输出的参数是否符合 BaseTool::parameters() 声明的 JSON Schema")]
|
||||
InvalidArguments(String, String),
|
||||
|
||||
/// 权限被拒绝(不可恢复——终止循环)。
|
||||
#[error("权限被拒绝: 工具 '{0}' 需要 {1} 权限")]
|
||||
#[error("权限被拒绝: 工具 '{0}' 需要 {1} 权限。请在 PermissionConfig 中显式允许,或人工确认后绕过")]
|
||||
PermissionDenied(String, String),
|
||||
|
||||
/// MCP 协议错误(不可恢复)。
|
||||
#[error("MCP 协议错误: {0}")]
|
||||
#[error("MCP 协议错误: {0}。请检查 MCP server 版本与本库兼容,或查看 server 日志")]
|
||||
McpError(String),
|
||||
|
||||
/// MCP 未初始化(不可恢复)。
|
||||
#[error("MCP 未初始化: {0}")]
|
||||
#[error("MCP 未初始化: {0}。请先调用 McpClient::initialize() 完成握手")]
|
||||
McpNotInitialized(String),
|
||||
|
||||
/// MCP 超时(不可恢复)。
|
||||
#[error("MCP 超时: {0}")]
|
||||
#[error("MCP 超时: {0}。请检查 MCP server 是否存活,或调大超时配置")]
|
||||
McpTimeout(String),
|
||||
|
||||
/// IO 错误(不可恢复)。
|
||||
#[error("IO 错误: {0}")]
|
||||
#[error("IO 错误: {0}。请检查文件路径、权限或磁盘空间")]
|
||||
Io(Arc<std::io::Error>),
|
||||
|
||||
/// 取消。
|
||||
/// 工具执行被取消。
|
||||
#[error("工具执行已取消: {0}")]
|
||||
Cancelled(String),
|
||||
|
||||
/// 其他未分类错误。
|
||||
#[error("其他错误: {0}")]
|
||||
#[error("工具调用错误: {0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_io_is_not_recoverable() {
|
||||
let io_err = std::io::Error::new(std::io::ErrorKind::Other, "disk");
|
||||
let io_err = std::io::Error::other("disk");
|
||||
let err = ToolError::from(io_err);
|
||||
assert!(!err.is_recoverable());
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
|
||||
use tokio::sync::{oneshot, Mutex};
|
||||
|
||||
#[allow(deprecated)]
|
||||
use crate::llm::types::ToolDefinition;
|
||||
use crate::tools::base::{BaseTool, ToolContext, ToolRef};
|
||||
use crate::tools::error::ToolError;
|
||||
@@ -135,6 +136,7 @@ impl std::fmt::Debug for McpClient {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
impl McpClient {
|
||||
/// 创建一个 MCP 客户端。
|
||||
pub fn new(server_name: impl Into<String>, transport: McpTransport) -> Self {
|
||||
@@ -540,6 +542,7 @@ enum McpClientHandle {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
#[allow(deprecated)]
|
||||
impl BaseTool for McpToolAdapter {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
|
||||
+57
-16
@@ -7,6 +7,7 @@ use std::time::Duration;
|
||||
use futures::future::join_all;
|
||||
use serde_json::Value;
|
||||
|
||||
#[allow(deprecated)]
|
||||
use crate::llm::types::ToolDefinition;
|
||||
use crate::tools::base::{ToolContext, ToolRef};
|
||||
use crate::tools::error::ToolError;
|
||||
@@ -15,6 +16,12 @@ use crate::tools::permission::PermissionChecker;
|
||||
/// 工具调用记录 —— 用于追踪和调试。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolInvocation {
|
||||
/// LLM 返回的 tool_call_id —— 用于回传 `Message::ToolResult` 时关联原始调用。
|
||||
///
|
||||
/// ponytail: Phase 2 引入。老的循环用 `tool_name` 冒充 tool_call_id,
|
||||
/// 对 OpenAI 碰巧可用,对 Anthropic 必然失败。Anthropic 协议要求
|
||||
/// `tool_result.tool_use_id` 与上一轮 `tool_use.id` 严格一致。
|
||||
pub tool_call_id: String,
|
||||
/// 被调用的工具名。
|
||||
pub tool_name: String,
|
||||
/// 工具的入参。
|
||||
@@ -25,8 +32,14 @@ pub struct ToolInvocation {
|
||||
|
||||
impl ToolInvocation {
|
||||
/// 创建一个新的工具调用记录。
|
||||
pub fn new(tool_name: String, input: Value, output: Result<Value, ToolError>) -> Self {
|
||||
pub fn new(
|
||||
tool_call_id: String,
|
||||
tool_name: String,
|
||||
input: Value,
|
||||
output: Result<Value, ToolError>,
|
||||
) -> Self {
|
||||
Self {
|
||||
tool_call_id,
|
||||
tool_name,
|
||||
input,
|
||||
output,
|
||||
@@ -58,6 +71,7 @@ impl std::fmt::Debug for ToolRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
impl ToolRegistry {
|
||||
/// 创建一个新的工具注册表。
|
||||
pub fn new() -> Self {
|
||||
@@ -128,7 +142,15 @@ impl ToolRegistry {
|
||||
}
|
||||
|
||||
/// 调用单个工具(含权限检查)。
|
||||
pub async fn invoke(&self, name: &str, args: Value) -> Result<ToolInvocation, ToolError> {
|
||||
///
|
||||
/// `tool_call_id` 来源于 LLM 流式响应中的 `tool_calls[i].id`,用于回传
|
||||
/// 工具结果时与原始 `tool_use` block 关联。
|
||||
pub async fn invoke(
|
||||
&self,
|
||||
tool_call_id: &str,
|
||||
name: &str,
|
||||
args: Value,
|
||||
) -> Result<ToolInvocation, ToolError> {
|
||||
let tool = self
|
||||
.get(name)
|
||||
.ok_or_else(|| ToolError::NotFound(name.to_string()))?;
|
||||
@@ -139,35 +161,48 @@ impl ToolRegistry {
|
||||
|
||||
let ctx = ToolContext::new(name, "");
|
||||
let output = tool.execute(args.clone(), &ctx).await;
|
||||
Ok(ToolInvocation::new(name.to_string(), args, output))
|
||||
Ok(ToolInvocation::new(
|
||||
tool_call_id.to_string(),
|
||||
name.to_string(),
|
||||
args,
|
||||
output,
|
||||
))
|
||||
}
|
||||
|
||||
/// 并行执行多个工具调用(互不依赖的工具)。
|
||||
///
|
||||
/// 每个工具独立超时(`timeout_per_call_secs`,0 表示不超时)。
|
||||
/// 单个工具超时不会影响其他工具的返回。
|
||||
///
|
||||
/// 入参元组为 `(tool_call_id, tool_name, args)` —— `tool_call_id` 来自 LLM 响应。
|
||||
pub async fn invoke_all(
|
||||
&self,
|
||||
calls: Vec<(String, Value)>,
|
||||
calls: Vec<(String, String, Value)>,
|
||||
timeout_per_call_secs: u64,
|
||||
) -> Vec<ToolInvocation> {
|
||||
let this = self.clone();
|
||||
let futures = calls.into_iter().map(|(name, args)| {
|
||||
let futures = calls.into_iter().map(|(tool_call_id, name, args)| {
|
||||
let this = this.clone();
|
||||
async move {
|
||||
match if timeout_per_call_secs == 0 {
|
||||
Ok(this.invoke(&name, args.clone()).await)
|
||||
Ok(this.invoke(&tool_call_id, &name, args.clone()).await)
|
||||
} else {
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(timeout_per_call_secs),
|
||||
this.invoke(&name, args.clone()),
|
||||
this.invoke(&tool_call_id, &name, args.clone()),
|
||||
)
|
||||
.await
|
||||
} {
|
||||
Ok(result) => result.unwrap_or_else(|e| {
|
||||
ToolInvocation::new(name.clone(), args.clone(), Err(e))
|
||||
ToolInvocation::new(
|
||||
tool_call_id.clone(),
|
||||
name.clone(),
|
||||
args.clone(),
|
||||
Err(e),
|
||||
)
|
||||
}),
|
||||
Err(_) => ToolInvocation::new(
|
||||
tool_call_id,
|
||||
name,
|
||||
args,
|
||||
Err(ToolError::McpTimeout("timeout".into())),
|
||||
@@ -313,15 +348,17 @@ mod tests {
|
||||
async fn test_invoke_success() {
|
||||
let mut reg = ToolRegistry::new();
|
||||
reg.register(Arc::new(AddTool { base: 100 })).unwrap();
|
||||
let result = reg.invoke("add", json!({ "n": 5 })).await.unwrap();
|
||||
let result = reg.invoke("call_1", "add", json!({ "n": 5 })).await.unwrap();
|
||||
let value = result.output.unwrap();
|
||||
assert_eq!(value["result"], 105);
|
||||
assert_eq!(result.tool_call_id, "call_1");
|
||||
assert_eq!(result.tool_name, "add");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_invoke_not_found() {
|
||||
let reg = ToolRegistry::new();
|
||||
let result = reg.invoke("nope", json!({})).await;
|
||||
let result = reg.invoke("call_x", "nope", json!({})).await;
|
||||
assert!(matches!(result, Err(ToolError::NotFound(_))));
|
||||
}
|
||||
|
||||
@@ -329,7 +366,7 @@ mod tests {
|
||||
async fn test_invoke_execution_error() {
|
||||
let mut reg = ToolRegistry::new();
|
||||
reg.register(Arc::new(FailTool)).unwrap();
|
||||
let result = reg.invoke("fail", json!({})).await.unwrap();
|
||||
let result = reg.invoke("call_y", "fail", json!({})).await.unwrap();
|
||||
assert!(result.output.is_err());
|
||||
}
|
||||
|
||||
@@ -338,7 +375,7 @@ mod tests {
|
||||
let mut reg = ToolRegistry::new()
|
||||
.with_permission_checker(PermissionChecker::new(Default::default()));
|
||||
reg.register(Arc::new(ShellTool)).unwrap();
|
||||
let result = reg.invoke("shell", json!({})).await;
|
||||
let result = reg.invoke("call_z", "shell", json!({})).await;
|
||||
assert!(matches!(result, Err(ToolError::PermissionDenied(_, _))));
|
||||
}
|
||||
|
||||
@@ -348,12 +385,15 @@ mod tests {
|
||||
reg.register(Arc::new(AddTool { base: 1 })).unwrap();
|
||||
reg.register(Arc::new(FailTool)).unwrap();
|
||||
let calls = vec![
|
||||
("add".into(), json!({ "n": 1 })),
|
||||
("add".into(), json!({ "n": 2 })),
|
||||
("fail".into(), json!({})),
|
||||
("c1".into(), "add".into(), json!({ "n": 1 })),
|
||||
("c2".into(), "add".into(), json!({ "n": 2 })),
|
||||
("c3".into(), "fail".into(), json!({})),
|
||||
];
|
||||
let results = reg.invoke_all(calls, 0).await;
|
||||
assert_eq!(results.len(), 3);
|
||||
assert_eq!(results[0].tool_call_id, "c1");
|
||||
assert_eq!(results[1].tool_call_id, "c2");
|
||||
assert_eq!(results[2].tool_call_id, "c3");
|
||||
assert!(results[0].output.is_ok());
|
||||
assert!(results[1].output.is_ok());
|
||||
assert!(results[2].output.is_err());
|
||||
@@ -363,9 +403,10 @@ mod tests {
|
||||
async fn test_invoke_all_with_timeout() {
|
||||
let mut reg = ToolRegistry::new();
|
||||
reg.register(Arc::new(AddTool { base: 0 })).unwrap();
|
||||
let calls = vec![("add".into(), json!({ "n": 1 }))];
|
||||
let calls = vec![("c1".into(), "add".into(), json!({ "n": 1 }))];
|
||||
let results = reg.invoke_all(calls, 5).await;
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].tool_call_id, "c1");
|
||||
assert!(results[0].output.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user