28 Commits
Author SHA1 Message Date
徐涛 3c1a3ee62e docs: 修正 v0.1 release URL 到实际 remote
CHANGELOG.md:91 的对比链接从 github.com/anomalyco/agcore
改为实际 remote git.archgrid.xyz/xt/agcore(Gitea 1.26.1),
避免 tag 发布后 CHANGELOG 链接 404
2026-07-04 07:07:01 +08:00
徐涛 8dcd1f2482 docs(v0.1): 同步 roadmap 并初始化 CHANGELOG
- docs/roadmap.md:更新顶部状态至 v0.1 发布完成(182 测试 / 0 警告 / 7 离线示例),末尾追加 v0.1 发布里程碑章节
- CHANGELOG.md:按 Keep a Changelog 规范记录 0.1.0 的 Added / Changed / Deprecated / Fixed / Removed 及版本基线
2026-07-04 06:54:13 +08:00
徐涛 602bd43fce fix(llm,docs): 修复 Phase C 审查发现的两个警告
- LlmError::Request 变体补可操作建议:请检查 Provider 端点地址(base_url)和网络连通性
- README.md Mermaid 依赖图删除 Agent --> Prompt 边(编译级不存在)
- README.md 依赖规则文本同步:明确 agent 实际编译依赖为 llm/tools/memory,与 prompt 无直接依赖(system prompt 以 &str 传入)

参考审查:PM Director + SA Director 对 Phase C 的联合审查结论
2026-07-04 06:35:21 +08:00
徐涛 a52588cd0f docs: 完善 README 完整版
- 8 节结构:定位 / 快速上手 / 核心模块 / 架构图 / 依赖关系 / 环境变量 / 参考项目 / 许可证
- 快速上手代码段(MockProvider)已验证在外部 crate 离线可编译运行
- 核心模块表覆盖五大模块(llm / prompt / tools / memory / agent)
- ASCII 架构关系图 + Mermaid 模块依赖关系图 + 依赖规则说明
- 环境变量表列出 simple_visit 示例所需变量与推荐值
- 参考项目章节引用 4 个公开 Agent Harness 项目(OpenClaw / Hermes / OpenHuman / OpenHarness)
- 许可证章节同步改为 Apache-2.0 单协议,附标准 boilerplate 并指向 LICENSE 文件
2026-07-04 06:21:36 +08:00
徐涛 4fdc62754c feat(errors): 错误消息面向最终用户友好化
- memory::MemoryError 英文 → 中文 + 操作建议(namespace 拼写 / 后端存储 / 数据兼容性)
- llm::LlmError 各变体补充修复建议(API key / 超时 / 上下文压缩)
- tools::ToolError 各变体补充修复指引(注册工具 / 检查 Schema / 调权限 / MCP 握手)
- prompt::PromptError 各变体补充修复指引(注册子模板 / 检查语法 / 调深度限制)
- agent::AgentError::Config 中文化 + 提示需调用的 builder 方法名
2026-07-04 06:21:31 +08:00
徐涛 9f5e8702a2 chore(license): 添加 Apache-2.0 标准 LICENSE 文件
- 新增 LICENSE(Apache License 2.0 全文,Copyright 2026 AG Core Contributors)
- 项目由双协议改为 Apache-2.0 单协议
2026-07-04 06:21:24 +08:00
徐涛 2d0d5c1592 feat(examples): 新增 Phase B 7 个离线可运行示例
新增 7 个示例覆盖全 Phase 公共 API(不依赖 API key 即可运行):
- agent_session_demo:AgentBuilder → AgentSession → SessionMemory
- custom_tool:BaseTool 注册 + invoke/invoke_all + 权限检查
- prompt_composer:PromptTemplate + PromptComposer + validate_messages
- task_agent_demo:JsonPlanParser + Step 状态机 + 错误路径
- conversation_memory_demo:滑动窗口 + 多角色 + 隔离
- knowledge_search_demo:KnowledgeStore + 关键词检索 + 停用词过滤
- streaming_events_demo:submit_stream 事件消费 + 队列耗尽错误路径
2026-07-03 15:50:51 +08:00
徐涛 7b2d2db322 feat(llm): 公开 MockProvider 并修复测试与 API 一致性
- 新增 src/llm/mock.rs:公开 MockProvider,实现 chat 与 chat_stream
  (chat_stream 从 MessageResponse 拆解为 StreamEvent 序列),含 5 个内联测试
- src/llm.rs 注册 pub mod mock,供示例与外部 crate 引用
- src/agent/session.rs 测试改用 crate::llm::mock::MockProvider,
  删除内嵌私有副本,统一公开路径
- src/prompt.rs re-export validate_messages,与 PromptComposer 对齐公开路径
2026-07-03 15:50:39 +08:00
徐涛 c084c57e2c refactor(core): 扫清 v0.1 Phase A 技术债
- 修复测试编译回归:补全 session.rs/cycle.rs 测试模块导入;
convert.rs 2 处 irrefutable if let 改为 let
- composer.rs 迁移至 IR:OpenaiChatMessage → Message,
ContentField/OpenaiContentPart → ContentBlock;删除 set_message_name
和 build_request;developer 消息映射为 Message::System
- knowledge.rs 锁修复:std::sync::Mutex → tokio::sync::Mutex;
search() 优化锁粒度(锁内仅 clone IDs,避免锁内异步 IO)
- 标记 ChatResponse / ToolDefinition 为废弃(#[deprecated(since = "0.1.0")]),
内部使用点加 #[allow(deprecated)] 抑制警告
- clippy 清零:合并冗余 if、手动 strip_prefix 改 strip_prefix、
多处 dead_code 抑制、测试代码清理
2026-07-03 15:19:37 +08:00
徐涛 c2c0d498ee docs(v0.1): 添加发布实施计划文档 2026-07-03 14:13:30 +08:00
徐涛 e54edbc037 feat(agent): 移除未使用的导入并简化条件表达式 2026-07-03 13:55:24 +08:00
徐涛 9e4f50c955 feat(llm): LlmCycle 切换 IR 消息类型,移除 Phase 0 桥接层
核心改动:
- LlmCycle.messages 从 Vec<OpenaiChatMessage> 切换为 Vec<Message>,
  移除 Phase 0 引入的 chat_message_to_message / message_to_chat_message
  转换函数
- 移除 LlmCycle.system_prompt 字段(FIX-D),调用方通过
  Message::system_text() + with_messages() 管理系统提示;
  with_system_prompt() 标记 #[deprecated] 保留为过渡期 shim
- submit_stream 简化(FIX-E):Item = StreamEvent 不再带 Result 包装,
  错误用 StreamEvent::Error 传出;self.messages 不再自动 push_message,
  调用方在收到 MessageComplete 后手动调用 cycle.push_message()
- compact.rs 适配 Message 类型 + ContentBlock 估算;microcompact
  跳过 is_error:true 的 ToolResult(FIX-F),保留 LLM 错误诊断
- ConversationMemory 持久化从 OpenaiChatMessage 切换为 Message
- ToolInvocation 增加 tool_call_id 字段(FIX-A),
  invoke/invoke_all 签名增加 tool_call_id 参数

调用方迁移:
- AgentSession::submit_turn 用 Message::system_text() + with_messages()
- simple_visit example 同步迁移

测试 177 passed / 0 failed(compact 新增 5 个单元测试覆盖 FIX-F + 估计全变体)。
2026-07-02 22:48:00 +08:00
徐涛 a74c24b6fe feat(llm): 重写 Provider 适配层,支持 OpenAI Chat / Anthropic / DeepSeek / Qwen
重写 OpenaiChatProvider:移除 Phase 0 临时桥接,实现真实流式状态机
(ContentBlockStart / *Delta / ContentBlockEnd / ToolCallEnd),
MessageComplete.full_response 由 PartialMessageResponse::finalize 产出。

新增 AnthropicProvider:实现 Messages API + SSE 事件序列
(message_start → content_block_start → content_block_delta →
content_block_stop → message_delta → message_stop),
529(overloaded)映射为 RateLimit;thinking signature 由
partial.set_thinking_signature 内部写入。

新增 DeepSeekProvider / QwenProvider:OpenAI-compatible 协议的
newtype 包装,共享 GenericOpenaiProvider 的 HTTP / SSE / 转换逻辑;
Qwen 通过 extra_headers 注入 X-DashScope-SSE: enable。

新增 convert.rs 公共转换模块:从 Phase 0 cycle.rs / Phase 0
OpenaiProvider 桥接层提取 from_openai / to_openai /
content_to_blocks / blocks_to_content,避免跨 Provider 重复逻辑。

新增 wiremock dev-dependency + 14 个集成测试:
- OpenaiChatProvider:基础文本 / 401 / 500 / 流式 MessageComplete
- AnthropicProvider:基础 / 401 / 529 / 流式 SSE 序列 / max_tokens 默认值
- DeepSeek / Qwen:基础文本响应

171 个测试全部通过(之前 157,新增 14)。
2026-07-02 22:24:30 +08:00
徐涛 7c299f1cfd feat(llm): 新增 IR 类型层 + 切换 LlmProvider trait 签名
引入跨 Provider 统一的新类型系统:
- Message 扁平大枚举(System/User/UserImage/Assistant/ToolResult)
- MessageRequest + MessageResponse + StreamEvent(高精度 IR)
- PartialMessageResponse 含 apply_to/finalize 汇聚算法
- ProviderType enum 扩展 + ProviderCapabilities

切换 LlmProvider trait 签名为 chat(MessageRequest) → MessageResponse,
chat_stream 返回 Stream<Item = Result<StreamEvent, LlmError>>,新增 capabilities()。

StreamEvent 命名冲突处理:旧变体迁移至 old_stream::LegacyStreamEvent,
stream.rs 通过 pub use 重新导出新高精度事件。

OpenaiProvider 添加 Phase 0 临时桥接(MessageRequest ↔ OpenaiChatRequest 转换),
标注 ponytail: Phase 0 临时桥接 标记,Phase 1 重写时移除。

测试 147 passed / 0 failed(新增 31 个新类型测试)。
2026-07-02 21:58:07 +08:00
徐涛 925c8f9729 docs: 更新方案文档,补充 StreamEvent 迁移和 OpenaiProvider 桥接方案 2026-06-30 22:59:05 +08:00
徐涛 832ebf2665 docs(phase0): 新增 Phase 0 实施计划文档
定义类型层落地计划,涵盖新类型定义、Trait 签名切换及上游适配方案
2026-06-30 22:22:12 +08:00
徐涛 000cd2022d feat(llm): 实现 Provider 重构方案并更新文档
根据复核反馈修正以下内容:
- 补充 §2.4 中扁平大枚举对 9e 依赖验证的说明
- 消除 §2.3 MessageComplete 冗余字段
- 明确 Phase 0 add-only 策略和 trait 签名切换时点
- 补充 HTTP mock 策略和兼容性验证标准
2026-06-26 08:12:12 +08:00
徐涛 ac7fca8b40 feat(docs): 新增 LLM Provider 重构方案文档 2026-06-25 23:07:22 +08:00
徐涛 f639229d09 docs: 添加 commit body 规则,禁止逐文件列举变更 2026-06-24 17:00:43 +08:00
徐涛 4423024974 docs: 更新 compact 主题状态为已推演 2026-06-22 08:43:48 +08:00
徐涛 3babc5567f enhance(docs): 补全 compact 在 IR 上的改法推演结论 2026-06-22 06:52:04 +08:00
徐涛 3ddd0b6d80 enhance(docs): 更新 system prompt 双重表达冲突推演结论为方案 D 2026-06-22 06:34:11 +08:00
徐涛 80d44ee687 docs(AGENTS): 添加 Commit 提交流程说明 2026-06-19 08:23:00 +08:00
徐涛 ee7fcab71c docs(9): 完善 Anthropic 流式状态机设计推演结论 2026-06-18 06:51:13 +08:00
徐涛 8c9324350b docs(9d): 补全 OpenAI 流式转换推演结论 2026-06-18 06:34:20 +08:00
徐涛 c0eae92b10 docs: 更新方案9推演结论并调整StreamEvent设计 2026-06-17 22:55:47 +08:00
徐涛 8686a2e1d6 docs(llm): 新增 LLM Provider 统一接口设计文档 2026-06-15 23:07:33 +08:00
徐涛 829be90d19 docs: 新增示例程序方案文档 2026-06-11 22:49:27 +08:00
64 changed files with 13912 additions and 895 deletions
+13
View File
@@ -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
+91
View File
@@ -0,0 +1,91 @@
# Changelog
本项目所有重要变更均记录于此文件。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
## [0.1.0] - 2026-07-04
首个公开版本。涵盖 Phase 0-4c 的全部核心能力、Provider IR 重构、LlmCycle 简化,以及面向用户的 7 个离线示例。
### Added
**LLM 调用周期(Phase 0**
- 统一 IR 类型层:`Message``ContentBlock``MessageRequest``MessageResponse``ToolDefinition``StopReason`
- `LlmProvider` trait + 4 个 Provider 实现:OpenAI Chat Completions、Anthropic Messages、DeepSeekOpenAI 兼容)、QwenOpenAI 兼容)
- `ProviderRegistry`:多 Provider 注册与发现
- `LlmCycle`:重试策略 + 用量追踪 + 自动 tool 循环 + Auto-compaction
- `HookExecutor`OnTurnStart / OnTurnEnd / OnPlanStepComplete 等生命周期钩子
- `StreamEvents`AssistantTextDelta / ToolExecutionStarted / MessageComplete 等流式事件
**提示词工程(Phase 1**
- `PromptTemplate`:变量插值 + 条件渲染
- `PromptComposer`system/user/assistant/tool_result 消息链组合
**工具系统(Phase 2**
- `ToolRegistry`:注册、发现、并行调用、超时控制
- `BaseTool` trait(含 `ToolContext` 执行上下文)
- `McpClient`stdio transportStreamableHttp 已预留接口)
- `PermissionChecker`:白名单 / 黑名单 / 自定义权限
**记忆系统(Phase 3**
- `MemoryStore` trait + `InMemoryStore` 默认实现
- `ConversationMemory`sliding window / 全量模式,集成 `llm::compact`
- `KnowledgeStore`:知识页面存储
- `MemoryRetriever`TextOverlap Dice 系数评分
- `EvictionPolicy`None / Ttl / Capacity 三种淘汰策略
**Agent 运行时(Phase 4a/b/c**
- `Agent` traitname / system_prompt / tool_definitions
- `AgentSession` + `RuntimeBundle` + `AgentBuilder`:链式构造 + 依赖注入
- `AgentError`Llm / Tool / Memory / HookBlocked / LimitExceeded / Config / PlanParse / Other 8 个变体
- `TaskAgent` trait + `JsonPlanParser`:自主执行 + 外部驱动
- `Plan` / `Step` / `StepStatus`:纯数据结构
- `SessionMemory`:基于 `MemoryStore` 的会话级 key-value 桥接
**面向用户的资产**
- `agcore::llm::mock::MockProvider`:公开的 Mock Provider,支持 `chat` + `chat_stream`,无需 API key 即可运行示例
- 7 个离线可运行示例(`cargo run --example ...`):
- `prompt_composer` — 模板变量插值 + 消息链构建
- `custom_tool` — 工具注册 + 权限检查
- `agent_session_demo` — AgentBuilder → AgentSession → submit_turn 完整链路
- `task_agent_demo` — JsonPlanParser → Plan → Step 状态机
- `conversation_memory_demo` — SessionMemory 命名空间隔离
- `knowledge_search_demo` — KnowledgeStore + 停用词过滤
- `streaming_events_demo` — 流式事件消费
### Changed
- **`LlmCycle` 切换到 IR 消息类型**:内部消息从 `OpenaiChatMessage` 切到统一 `Message` 类型,移除 Phase 0 的桥接转换层(方案:`docs/10c-phase2-llm-cycle-simplify.md`
- **Provider IR 重构**`LlmProvider` trait 签名同步切到 `MessageRequest` / `MessageResponse`;新增 Anthropic / DeepSeek / Qwen Provider(方案:`docs/10-llm-provider-refinement.md``docs/10b-phase1-provider-adaptation.md`
- **`AgentBuilder` 扩展 `session_memory_backend`**Phase 4c 接入会话级记忆
- **错误消息面向最终用户友好化**`AgentError` / `LlmError` / `ToolError` / `MemoryError` / `PromptError` 全部改写为带可操作建议的友好消息
- **`composer.rs` IR 迁移**`PromptComposer` 内部从 `OpenaiChatMessage` 切到 `Message` / `ContentBlock`,与新类型系统保持一致
- **`knowledge.rs` 锁修复**`std::sync::Mutex``tokio::sync::Mutex`,避免 MutexGuard 跨 `.await` 持有
### Deprecated
- `ChatResponse` — 自 0.1.0 起标记为 deprecated,请改用 `MessageResponse`
- `ToolDefinition` — 自 0.1.0 起标记为 deprecated(仍直接对应 OpenAI wire-format);v0.2 将引入 IR 工具类型
### Fixed
- 修复 Provider IR 重构后 `session.rs` / `cycle.rs` 测试模块的导入缺失回归
- 修复 `knowledge.rs::search()` 中 MutexGuard 跨 `.await` 持有的潜在阻塞
- 清零全部 clippy 警告(`#[allow(dead_code)]` 已在最低必要范围使用)
### Removed
- 移除 Phase 0 的 `OpenaiChatMessage ↔ Message` 桥接层(LlmCycle 简化后已无外部调用方)
---
## 版本基线
| 指标 | 数值 |
|------|------|
| `cargo build --all-targets` | ✅ 通过 |
| `cargo test --all-targets` | ✅ 182 passed / 0 failed |
| `cargo clippy --all-targets -- -D warnings` | ✅ 0 警告 |
| 离线示例 | ✅ 7 个全部 `cargo run` 退出码 0 |
| 许可证 | Apache-2.0 |
[0.1.0]: https://git.archgrid.xyz/xt/agcore/releases/tag/v0.1.0
+1
View File
@@ -23,3 +23,4 @@ time = { version = "0.3", features = ["serde"] }
[dev-dependencies]
dotenvy = "0.15.7"
wiremock = "0.6"
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 AG Core Contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+243 -1
View File
@@ -1,3 +1,245 @@
# AG Core
AG Core是一个用于提供构建智能体的底层工具箱,其中提供的功能主要包括基础的大模型调用的完整周期,提示词的组合与优化,记忆检索与管理、MCP与工具的调用等
> AG Core 是一个用于构建 AI 智能体(Agent)的 Rust 工具箱,提供 LLM 调用、提示词工程、工具系统、记忆检索、Agent 运行时等核心能力,所有模块通过 trait 抽象、可插拔、可离线测试
## 这是什么?
AG Core 不是 Agent 产品,而是 Agent 的**底层依赖库**:上层应用(TUI / Bot 网关 / 业务服务)基于 AG Core 装配出符合自身场景的 Agent。设计原则:
- **模块化** —— 五大功能领域(LLM / Prompt / Tool / Memory / Agent)独立 crate-internal 模块,通过 trait 解耦
- **可插拔** —— LLM Provider、记忆存储、工具注册全部通过 trait 抽象,可替换为自有实现
- **可离线** —— 公开 `MockProvider`,所有示例与测试无需 API key 即可运行
- **异步优先** —— 所有 IO API 均为 `async`,基于 tokio 运行时
适合用来:
- 搭建支持多轮对话 + 工具调用的智能体服务
- 接入多家 LLMOpenAI / Anthropic / DeepSeek / Qwen 等)
- 在 Agent 中组合提示词模板、知识检索、MCP 工具
- 在 Rust 中复用一套"业务无关"的 Agent 基础设施
## 快速上手
5 分钟上手,无需 API key(使用 `MockProvider` 预设响应)。
**1. 添加依赖**
```toml
[dependencies]
agcore = "0.1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
```
**2. 第一个 Agent**
```rust,no_run
use std::sync::Arc;
use agcore::agent::{Agent, AgentBuilder, AgentSession};
use agcore::llm::hooks::HookExecutor;
use agcore::llm::mock::MockProvider;
use agcore::llm::provider::LlmProvider;
use agcore::llm::types::message::{ContentBlock, Message};
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
use agcore::llm::types::Usage;
use agcore::tools::ToolRegistry;
struct Greeter;
impl Agent for Greeter {
fn name(&self) -> &str { "greeter" }
fn system_prompt(&self) -> Option<&str> { Some("你是一个中文助手,回答简短。") }
}
fn text_response(text: &str) -> MessageResponse {
MessageResponse {
id: "r".into(),
model: "mock".into(),
message: Message::Assistant {
content: vec![ContentBlock::Text { text: text.into() }],
},
usage: Usage::from_input_output(2, text.chars().count() as u32),
stop_reason: StopReason::Stop,
extra: Default::default(),
}
}
#[tokio::main]
async fn main() {
// 1. MockProvider:预设响应,无须 API key
let provider: Arc<dyn LlmProvider> =
Arc::new(MockProvider::new(vec![text_response("你好,我是 agcore。")]));
// 2. 装配 RuntimeBundle(必填:provider / tool_registry / hook_executor
let bundle = Arc::new(
AgentBuilder::new()
.provider(provider)
.tool_registry(Arc::new(ToolRegistry::new()))
.hook_executor(Arc::new(HookExecutor::new()))
.build()
.expect("装配 RuntimeBundle 失败"),
);
// 3. 创建会话并提交首轮
let agent: Arc<dyn Agent> = Arc::new(Greeter);
let mut session = AgentSession::new(agent, "demo", bundle);
let resp = session.submit_turn("你好").await.expect("submit_turn 失败");
println!("LLM: {}", resp.text());
}
```
跑起来:
```bash
cargo run
# LLM: 你好,我是 agcore。
```
接真实 Provider(如 OpenAI)只需把 `MockProvider` 换成:
```rust,no_run
use agcore::llm::provider::{create_provider, ProviderConfig, ProviderType};
let provider = create_provider(
ProviderType::OpenaiChat,
ProviderConfig {
base_url: "https://api.openai.com/v1".into(),
api_key: std::env::var("OPENAI_API_KEY").expect("未设置 OPENAI_API_KEY"),
model: "gpt-4o-mini".into(),
},
).expect("创建 Provider 失败");
```
更多端到端示例见 [`examples/`](./examples/) 目录(共 7 个,全部可 `cargo run --example <name>`):
| 示例 | 说明 |
|------|------|
| `agent_session_demo` | Agent + 会话 + SessionMemory 完整链路(MockProvider 离线) |
| `custom_tool` | 自定义工具注册、单次 / 并行调用、权限检查 |
| `prompt_composer` | 提示词模板与组合器(纯离线) |
| `task_agent_demo` | Plan 解析、Step 状态机、错误路径 |
| `conversation_memory_demo` | 对话记忆滑动窗口与隔离 |
| `knowledge_search_demo` | 知识页面关键词检索 |
| `streaming_events_demo` | LLM 流式响应事件消费(含错误路径) |
## 核心模块
| 模块 | 一句话说明 |
|------|----------|
| `agcore::llm` | LLM 调用周期(`LlmProvider` trait + `LlmCycle` 重试/用量 + 流式事件 + auto-compaction + Hook + 公开 `MockProvider` |
| `agcore::prompt` | 提示词工程(`PromptTemplate` 变量插值 + `PromptTemplateRegistry` + `PromptComposer` 多角色消息构造 + `validate_messages` |
| `agcore::tools` | 工具系统(`BaseTool` trait + `ToolRegistry` 注册/调用 + `PermissionChecker` 黑白名单 + MCP stdio 客户端) |
| `agcore::memory` | 记忆系统(`MemoryStore` trait + `InMemoryStore` 默认实现 + `ConversationMemory` 滑动窗口 + `KnowledgeStore` + `MemoryRetriever` |
| `agcore::agent` | Agent 运行时(`Agent` trait 角色定义 + `AgentBuilder` + `RuntimeBundle` 依赖注入 + `AgentSession` 会话 + `SessionMemory` + `Plan`/`Step` 任务编排) |
## 架构关系图
```
┌─────────────────────────────────────────────────────────────┐
│ 应用层 (TUI / Bot 网关 / 业务服务) │
└───────────────────────────┬─────────────────────────────────┘
│ 使用
┌───────────────────────────▼─────────────────────────────────┐
│ Agent Runtime (agcore::agent) │
│ Agent / AgentBuilder / RuntimeBundle / AgentSession / │
│ SessionMemory / Plan / Step │
└─────┬───────────────┬───────────────┬───────────────┬───────┘
│ │ │ │
┌─────▼─────┐ ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ LLM │ │ Prompt │ │ Tool │ │ Memory │
│ agcore:: │ │ agcore:: │ │ agcore:: │ │ agcore:: │
│ llm │ │ prompt │ │ tools │ │ memory │
└─────┬─────┘ └─────────────┘ └─────┬───────┘ └──────┬──────┘
│ │ │
└──────────────┬────────────────┘ │
▼ │
┌─────────────────┐ │
│ Mock Provider │◄──────────────────────┘
│ 公开 API │ 离线测试 / 示例
└─────────────────┘
```
## 模块依赖关系
```mermaid
graph BT
LLM["<b>llm</b><br/>Provider / Cycle /<br/>Hooks / Stream /<br/>Compact / Mock"]:::core
Prompt["<b>prompt</b><br/>Template / Composer"]:::core
Tool["<b>tools</b><br/>BaseTool / Registry /<br/>Permission / MCP"]:::core
Memory["<b>memory</b><br/>Store / Conversation /<br/>Knowledge / Retriever"]:::core
Agent["<b>agent</b><br/>Agent / Builder /<br/>Session / Plan"]:::core
Prompt --> LLM
Tool --> LLM
Memory --> LLM
Agent --> LLM
Agent --> Tool
Agent --> Memory
classDef core fill:#60a5fa,stroke:#2563eb,color:#fff
```
**依赖规则**
- `llm` 是叶子,被其他四个模块使用
- `prompt` / `tools` / `memory` 互相不依赖,可独立使用
- `agent` 编译期依赖 `llm` / `tools` / `memory`,与 `prompt` 无直接编译依赖(system prompt 以 `&str` 形式传入)
- 上层应用只应依赖 `agent` + 必要的子模块,不应跨层直接 `use`
## 环境变量
> AG Core 库本身不读环境变量。下表是 [`examples/simple_visit.rs`](./examples/simple_visit.rs) 这一真实调用示例所使用的环境变量,以及通常推荐的取值。
| 变量 | 必填 | 推荐值 | 说明 |
|------|------|-------|------|
| `OPENAI_API_KEY` | 用 OpenAI 时 | — | OpenAI / 兼容 ProviderDeepSeek / Qwen)的 API key |
| `OPENAI_BASE_URL` | 是 | `https://api.openai.com/v1` | OpenAI 兼容端点 base URLDeepSeek/Qwen 用对应地址) |
| `OPENAI_MODEL` | 是 | `gpt-4o-mini` | OpenAI 模型名 |
| `ANTHROPIC_API_KEY` | 用 Anthropic 时 | — | Anthropic Claude API key |
| `ANTHROPIC_BASE_URL` | 是 | `https://api.anthropic.com` | Anthropic 兼容端点 base URL |
| `ANTHROPIC_MODEL` | 是 | `claude-3-5-sonnet-latest` | Claude 模型名 |
| `PROVIDER` | 否 | `openai` | Provider 类型:`openai` / `openai-response` / `anthropic` / `deepseek` / `qwen` |
| `RUST_LOG` | 否 | `agcore=info` | tracing 日志级别(其他 crate 可加 `=debug` |
示例(运行 `examples/simple_visit.rs` 真实调用 OpenAI):
```bash
export OPENAI_API_KEY=sk-...
export OPENAI_BASE_URL=https://api.openai.com/v1
export OPENAI_MODEL=gpt-4o-mini
export PROVIDER=openai
export RUST_LOG=agcore=debug
cargo run --example simple_visit
```
## 参考项目
AG Core 在 Phase 4 设计阶段调研了 4 个 2026 年公开的 AI Agent 项目(详见 [`docs/note-agent-harness-references.md`](./docs/note-agent-harness-references.md)):
| 项目 | 类型 | 语言 | 借鉴点 |
|------|------|------|-------|
| [OpenClaw](https://github.com/openclaw/openclaw) | 消息网关 | TypeScript | 多渠道适配模式 |
| [Hermes Agent](https://github.com/NousResearch/hermes-agent) | 自主学习智能体 | Python | 实体与会话解耦 |
| [OpenHuman](https://github.com/tinyhumansai/openhuman) | 桌面助手 | Rust + Tauri | 记忆树与 Token 压缩 |
| [OpenHarness](https://github.com/HKUDS/OpenHarness) | Agent Harness 框架 | Python | 显式依赖注入容器 + 三级权限 |
AG Core 不"抄代码",只参考架构模式。当前实现已经采纳:
- **OpenHarness 风格** —— 显式 `RuntimeBundle` 依赖注入容器
- **Hermes 风格** —— `Agent` trait(角色)与 `AgentSession`(会话)解耦
后续 v0.2+ 计划借鉴 OpenHuman 的 Memory Tree / TokenJuice 模式。
## 许可证
本项目基于 **Apache License 2.0** 授权。完整文本见 [`LICENSE`](./LICENSE)。
```
Copyright 2026 AG Core Contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
```
+515
View File
@@ -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-01Message 采用扁平大枚举
#### 定义
```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-02LlmProvider 感知消息类型
沿用 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-03StreamEvent 高精度 + 终端事件携带完整响应
沿用 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 signaturemessage_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-04LlmCycle 简化
沿用 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-05Provider 发现使用 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 1Provider 适配
> **前置条件**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 2LlmCycle 简化(逻辑重构)
> **说明**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 1Provider 适配) | 某个 Provider 实现不合理 | 将该 Provider 回退为 `unimplemented!()`(当前状态),不影响其他 Provider |
| Phase 1 完成时 | Provider 测试全部通过 | 打 tag `providers-v2-prototype` |
| Phase 2LlmCycle 简化) | 循环逻辑或 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` roletool_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
+340
View File
@@ -0,0 +1,340 @@
# v0.1 发布实施计划
> 状态:待实施
> 关联文档:`docs/roadmap.md`、`docs/8-examples-plan.md`、`docs/10c-phase2-llm-cycle-simplify.md`
## 背景与目标
AG Core 已完成 Phase 0-4c 全部 7 个 Phase 的核心功能,以及 Provider IR 重构(新类型系统 + Anthropic/DeepSeek/Qwen Provider+ LlmCycle 简化(IR 消息类型切换 + 桥接层移除)。
**当前基线**
- `cargo build` — ✅ 通过(5 个 warning
- `cargo test` — ❌ 编译失败(4 个 errorsession.rs/cycle.rs 测试模块中 `ContentBlock`/`ProviderCapabilities`/`ProviderFeatures` 导入缺失,均为 Provider IR 重构后未同步的回归)
- LlmCycle 简化合并前全量测试为 177 通过,合并后尚未跑通过过全量测试
v0.1 发布的阻塞项不是"功能不足",而是"已有功能不能被用户快速看见和使用"。本计划聚焦于:
1. **扫清技术债** — composer.rs 迁移、锁修复、clippy 清零
2. **提供可离线运行的示例** — 让用户 5 分钟内上手
3. **补充面向用户的文档** — README、MockProvider、错误消息友好化
4. **完成发布前准备** — CI 验证、Roadmap 同步、版本标记
---
## 总体时间线
预计 8-10 个工作日(2 周),分为两条并行线:
```
Week 1 ────┬── 测试修复 + 技术债扫清
│ ├ T0 测试编译回归修复(~0.5d)← ⚠️ 任何改动前的前置步骤
│ ├ T1.1 composer.rs IR 迁移(~1d
│ ├ T1.2 knowledge.rs 锁修复(~0.5d
│ ├ T1.3 旧类型废弃标记(~0.5d)
│ └ T1.4 clippy 清零(~0.5d
├── 示例 + 开发者体验
│ ├ T2.0 MockProvider 公开化(~0.5d
│ ├ T2.1 4个🥇示例(~2-3d,可并行)
│ └ T3.1 README 初稿(~1d,并行)
Week 2 ────┬── 示例继续
│ ├ T2.2 3个🥈示例(~2d
│ └ T3.2 错误消息 review~0.5d
└── 发布准备
├ T3.3 README 定稿(~1d
├ T4.1 CI 示例验证(~0.5d
├ T4.2 Roadmap 更新(~0.5d
├ T4.3 CHANGELOG 初始化(~0.5d
└ T4.4 v0.1 tag~0.5d
```
---
## 实施步骤
### Phase A — 测试修复 + 技术债扫清(Week 1 前半)
> **重要**:Task A0 是后续所有任务的前提——不修复测试编译,所有 Task 的验收条件(`cargo test` 全通过)都不可执行。
#### Task A0:修复测试编译回归(~0.5d)
**问题**Provider IR 重构后,`ContentBlock`/`ProviderCapabilities`/`ProviderFeatures` 被移到新位置,但 2 个测试模块的导入未同步,导致 `cargo test` 编译失败。
**涉及文件**
- `src/agent/session.rs` — 测试模块缺少 `ContentBlock` 导入
- `src/llm/cycle.rs` — 测试模块缺少 `ProviderCapabilities`/`ProviderFeatures` 导入
**修复方案**:在相应测试模块内补全 `use` 语句(每处加一行即可)。
**额外收益**:修复后 `convert.rs` 中 2 处 `irrefutable if let` 警告也一并修正(改为 `let`),减少 clippy 基数。
**前置依赖**:无
**验收条件**
- `cargo test` 全量编译通过
- `cargo test` 全量运行通过
---
#### Task A1`composer.rs` IR 迁移(~1d
**前置依赖**Task A0(否则无法通过 `cargo test` 验证)
**涉及文件**
- `src/prompt/composer.rs` — 主要改动
- `src/prompt/mod.rs` — 类型重导出
**改动范围**:约 60 处 `OpenaiChatMessage``Message``ContentField`/`OpenaiContentPart``ContentBlock`
**具体清单**
1. `PromptComposer` 内部 `messages` 字段类型 `Vec<OpenaiChatMessage>``Vec<Message>`
2. 所有 `.system_text()`/`.user_text()`/`.assistant_text()`/`.developer_text()`/`.tool_result()` 构造方法 — 新 `Message` 已有同名方法,直接替换调用
3. 所有 `xx_content()`/`xx_contents()` 方法 — `ContentBlock` 替代 `ContentField`/`OpenaiContentPart`
4. `set_message_name()` — 新 `Message` 是扁平 enum,无 `name` 字段,移除或忽略
5. `build()` 返回类型 `Vec<OpenaiChatMessage>``Vec<Message>`
6. `validate_messages()` 消息校验函数 — `OpenaiChatMessage::Tool { .. }``Message::ToolResult { .. }``tool_calls``Assistant` 变体的 `ContentBlock::ToolUse` 中提取
**测试**:内联测试中 `OpenaiChatMessage::Tool { .. }` pattern match 需同步更新
**验收条件**
- `cargo build` 无错误
- `cargo test` 中 prompt 模块测试全通过
- 无新增 clippy 警告
---
#### Task A2`knowledge.rs` 锁修复(~0.5d
**涉及文件**`src/memory/knowledge.rs`
**问题**`std::sync::Mutex` guard 在 `search()` 方法中跨 `.await` 持有,可能在高并发下阻塞 tokio 工作线程
**修复方案**`std::sync::Mutex<Vec<PageIndexEntry>>``tokio::sync::Mutex<Vec<PageIndexEntry>>`
**影响范围**5 处 `.lock().unwrap()` 调用(`rebuild_index``add_page``delete_page``search``get_index`
**验收条件**
- `cargo build` 无错误
- `cargo test` 中 memory 模块测试全通过
- clippy 不再报 `await_holding_lock` 警告
---
#### Task A3:旧类型公开 API 废弃标记(~0.5d)
**涉及文件**`src/llm/types/mod.rs`
**改动**
- `ChatResponse` struct 加 `#[deprecated(since = "0.1.0", note = "请改用 MessageResponse")]`
- `ToolDefinition` 类型别名加 `#[deprecated]`(如果仍公开)
- 保留结构体定义(OpenAI `chat_inner()` 内部转换层仍然在用),不删除
**验收条件**
- `cargo build` 产生废弃警告(期望行为)但不产生编译错误
- 外部调用方能看到有用提示
---
#### Task A4clippy 警告清零(~0.5d
当前剩余 8 个:
| # | 警告类型 | 文件 | 修复方式 |
|---|---------|------|---------|
| 1 | irrefutable `if let` | — | 改为直接 `let` |
| 2 | `with_plan_step_index` 未用 | `src/llm/hooks.rs` | 加 `#[allow(dead_code)]` |
| 3-5 | 字段未读(api_key, stop_sequence, next_block_index | `anthropic.rs`, `response_v2.rs` | 加 `#[allow(dead_code)]` |
| 6 | 手动前缀剥离 | — | 用 `strip_prefix()` |
| 7 | MutexGuard 跨 await | knowledge.rs | Task A2 修复后自动消失 |
| 8 | `if` 相同分支 | retriever.rs | 合并条件 |
**验收条件**`cargo clippy --lib -p agcore` 0 警告
---
### Phase B — 示例实现(Week 1 后半 ~ Week 2 前半)
#### Task B0MockProvider 公开化(~0.5d
**涉及文件**
- `src/llm/mock.rs` — 新建,公开 `MockProvider` struct
- `src/llm/mod.rs` — 加 `pub mod mock;`
- `src/agent/session.rs` — 测试中 `MockProvider` 改为引用 `crate::llm::mock::MockProvider`
**MockProvider 接口**
```rust
pub struct MockProvider { .. }
impl MockProvider {
pub fn new(responses: Vec<MessageResponse>) -> Self;
pub fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError>;
pub fn chat_stream(&self, request: MessageRequest)
-> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>;
}
```
**决策**:同时实现 `chat_stream`,从 `MessageResponse` 拆解为 `StreamEvent::ContentBlockStart + ContentBlockDelta * N + MessageComplete` 序列,使流式示例也能不依赖 API key。
**验收条件**
- 公开 `MockProvider` 可被外部 crate 引用
- `cargo test` 中所有 session 测试通过
- `agent_session_demo.rs` 示例可引用 `MockProvider` 并运行
---
#### Task B1:🥇 示例 × 4~2-3d,可并行)
4 个示例相互无依赖,按技术债消除进度安排:
| # | 示例 | 代码量 | 前置依赖 | 说明 |
|---|------|--------|---------|------|
| B1a | `agent_session_demo.rs` | ~100 行 | Task B0 | AgentBuilder → AgentSession → submit_turn → SessionMemory 完整链路 |
| B1b | `custom_tool.rs` | ~80 行 | 无 | 实现模拟工具 → ToolRegistry 注册 → invoke/invoke_all → PermissionChecker |
| B1c | `prompt_composer.rs` | ~60 行 | Task A1 | 模板变量插值 → PromptComposer 构建消息链 → 断言验证,纯离线 |
| B1d | `task_agent_demo.rs` | ~70 行 | 无 | 构造 JSON → JsonPlanParser → Plan → Step 状态机 → Hook 事件 |
每个示例的详细设计见 `docs/8-examples-plan.md`,按该文档直接实现。
**验收条件**
- `cargo run --example prompt_composer` → 成功退出
- `cargo run --example custom_tool` → 成功退出
- `cargo run --example agent_session_demo` → 成功退出
- `cargo run --example task_agent_demo` → 成功退出
---
#### Task B2:🥈 示例 × 3~2d
| # | 示例 | 代码量 | 前置依赖 |
|---|------|--------|---------|
| B2a | `conversation_memory_demo.rs` | ~70 行 | 无 |
| B2b | `knowledge_search_demo.rs` | ~60 行 | 无 |
| B2c | `streaming_events_demo.rs` | ~80 行 | Task B0MockProvider 需支持 `chat_stream` |
**验收条件**:额外 3 个示例均可 `cargo run` 成功退出
---
### Phase C — 开发者体验 + 文档(Week 2 后半,与 Phase B 后段并行)
#### Task C1README 完整版(~1.5d
**涉及文件**`README.md`
**内容结构**
```
# AG Core
## 这是什么? ← 一句话定位
## 快速上手 ← cargo add + MockProvider 示例
## 核心模块 ← 7 个模块一句话说明
## 架构关系图 ← ASCII 或 Mermaid
## 模块依赖关系 ← 依赖关系图 + 说明
## 环境变量 ← 当前支持的环境变量表
## 参考项目 ← OpenClaw / Hermes / OpenHuman / OpenHarness
## 许可证 ← MIT / Apache-2.0
```
**要求**:快速上手代码段在提交前必须真实编译通过。
---
#### Task C2:错误消息友好化 review~0.5d
**涉及文件**
- `src/agent/error.rs` — AgentError 消息
- `src/llm/error.rs` — LlmError 消息
- `src/tools/error.rs` — ToolError 消息
- `src/memory/error.rs` — MemoryError 消息
- `src/prompt/error.rs` — PromptError 消息
**检查标准**
- 用户能理解"哪里错了"
- 有可操作的建议("请检查 API key"、"请配置环境变量 LLM_API_KEY"
- 无英文残留的工程师视角消息
---
### Phase D — 发布准备(Week 2 末)
#### Task D1CI 示例验证集成(~0.5d
- 确认 `cargo build` 无新增警告
- 确认 `cargo test` 全部通过(退出码 0
- 确认 `cargo test --examples` 全部通过
- 确认 `cargo clippy --lib -p agcore` 0 警告
#### Task D2Roadmap 更新(~0.5d
更新 `docs/roadmap.md`
- Phase 2 状态更新为 ✅ 全部交付物已完成(Provider IR 重构 + LlmCycle 简化)
- 测试计数更新为 D1 确认的最终实际数值
- 移除 clippy 警告计数
- 添加 v0.1 发布里程碑
#### Task D3:依赖裁剪(~0.5d,非阻塞,可跳过)
`tokio = { features = ["full"] }` → 按需 feature`rt``macros``sync``time``net`
**理由**:减少编译时间 + 减少安全面。时间不够可延后到 v0.2。
#### Task D4CHANGELOG 初始化(~0.5d
**涉及文件**`CHANGELOG.md`
**内容要求**
- 版本号:`v0.1.0`
- 发布日期:标记当天
- 变更摘要:Phase 0-4c(LLM 调用周期、提示词工程、工具系统、记忆系统、Agent 运行时、任务执行、会话级记忆)+ Provider IR 重构(统一类型系统 + Anthropic/DeepSeek/Qwen 适配)+ LlmCycle 简化
- 格式参考 [Keep a Changelog](https://keepachangelog.com/) 规范
**验收条件**
- `CHANGELOG.md` 文件存在,内容与当前版本一致
- 文件随 tag commit 一起提交
---
#### Task D5:版本标记(~0.5d
```bash
git tag v0.1.0 && git push --tags
```
---
## 并行机会
| 并行组 | 包含任务 | 说明 |
|--------|---------|------|
| 组 0 | A0 | 单独执行,后续所有 Task 的前提 |
| 组 1 | A1 + A2 + A3 | A0 完成后可并行修 |
| 组 2 | B1a + B1b + B1d | 三个示例无前置依赖(B1a 需等 B0,B1c 需等 A1 |
| 组 3 | C1 + C2 + D2 | README / 错误消息 / Roadmap 更新,纯文档工作 |
| 组 4 | D1 + D3 | CI 验证 + 依赖裁剪,可并行 |
---
## 风险与应对
| 风险 | 影响 | 概率 | 应对 |
|------|------|------|------|
| A0 修复后仍有未发现的测试回归 | 🔴 后续 Task 验收不可信 | 低 | `cargo test` 全量通过后才启动 A1 |
| composer.rs 迁移发现未预见的 API 依赖 | 🔴 A1 延期 | 低 | 保持每次 commit 可编译;分 2 次提交(先私有不影响构建,再改返回类型) |
| MockProvider stream 实现比预期复杂 | 🟡 B2c 延期 | 中 | 先简化实现(只支持基本 text delta),复杂 case 留给 v0.2 |
| 示例与库 API 不同步 | 🟡 持续风险 | 中 | 所有示例纳入 `cargo test --examples` 作为 CI gate |
| clippy `#[allow(dead_code)]` 积累过多 | 🟢 低 | 高 | v0.2 发布前专门 review 一次允许列表 |
| tokio feature 裁剪导致编译失败 | 🟡 D3 延期 | 低 | 裁剪前确认现有 feature 覆盖所有 `tokio::` 调用点 |
---
## 验收标准
1. **代码质量**`cargo build` 0 错误,`cargo clippy --lib -p agcore` 0 警告
2. **测试覆盖**`cargo test` 全量编译通过且全部运行通过,`cargo test --examples` 全通过
3. **示例可用**:4 个🥇 + 3 个🥈共 7 个示例均可离线 `cargo run` 成功退出
4. **文档完整**:README 包含快速上手 + 架构概览,错误消息全部友好化
5. **版本标记**`git tag v0.1.0`
6. **Roadmap 同步**`docs/roadmap.md` 测试计数和状态与代码一致
+434
View File
@@ -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 keyCI 会跳过 | 中 | 高 | 用 `#[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
- 每个示例在文件顶部有 `//!` 注释说明其演示目的
+113
View File
@@ -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 ChatDeepSeek、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-§8LlmCycle 改造(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--统一请求) | ✅ 已推演(方案 BResult-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) | ✅ 已推演(方案 ASseByteStream 通用层 + 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-的端到端流程) | ✅ 已推演(方案 CMessageComplete 兜底 + finalize 回填) |
+70
View File
@@ -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 ChatDeepSeek、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
+479
View File
@@ -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 blockAnthropic 风格)
/// - 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_idOpenAI 格式需要)
tool_call_id: String,
},
}
```
#### 与当前 OpenaiChatMessage 的映射
| 当前类型 | 新 IR 类型 | 说明 |
|---------|-----------|------|
| `OpenaiChatMessage::Developer { content, name }` | `Message::System { content }` | 合并到 SystemAnthropic 无 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::ToolUserefusal → 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 的本质是逃生舱——如果给逃生舱做全类型安全,就失去了逃生舱的灵活性。
> 方向 APer-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 到自定义 structProvider 内部使用)
> - `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` 类型可以直接复用,无需修改。
+446
View File
@@ -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_tokensmessage_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. #4ToolCallStart 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 | 安全降级 |
+459
View File
@@ -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 callfunction.name 有值)
> │ │ → ensure_block(ToolUse, id, name) → (不产参数事件,等后续 arguments)
> │ └─ 已有 tool callfunction.arguments 有值)
> │ → ToolCallArgumentsDelta(index, arguments)
>
> └─ Phase 2: 处理汇总(后收束)
> ├─ finish_reason 存在 → close_current_block() + MessageComplete
> ├─ usage 存在 → CostUpdate
> └─ 同时存在 → CostUpdate → close_block → MessageComplete
> ```
>
> **核心抽象 `ensure_block`** 当新 chunk 的 delta 类型与当前活跃 block 不同时,
> 自动关闭当前 blockemit `ContentBlockEnd`)并开启新 blockemit `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 发 TextDeltaPhase 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::Erroris_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 AnthropicProviderMessages 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 | 3PendingStart、Active、Terminated |
> | index 管理 | `HashMap<openai_idx, global_idx>` 映射 | 直接使用 Anthropic index1: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 index1: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 后梳理)
+435
View File
@@ -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 traitchat、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` 为 Nonemessages 中的 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 层叠加额外处理
// (当前 pipelinestream → 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 大小估算 |
>
> ---
>
> ### 议题 3Thinking 是否在压缩范围内
>
> **决策:方案 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, &registry).await?;
self.cost_so_far.add(&response.usage); // Usage 类型不变
// 新
let response: MessageResponse = cycle.submit_with_tools(input, &registry).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 中先保留旧类型别名以减少改动。
+121
View File
@@ -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。
+96
View File
@@ -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
View File
@@ -1,13 +1,13 @@
# AG Core Roadmap
> 定稿日期:2026-05-11
> 最后更新:2026-06-11Phase 4c 编码实施完成)
> 最后更新:2026-07-04v0.1 发布完成)
## 愿景
AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可插拔的架构,提供大模型调用、提示词工程、工具系统、记忆检索四大核心能力,支持快速组合出符合业务需求的智能体应用。
**当前状态**Phase 0 基础设施已全部完成,Phase 1 提示词工程已全部完成,Phase 2 工具系统已全部完成,Phase 3 记忆系统已全部完成,Phase 4a 核心胶水层已全部完成,Phase 4b 任务执行已全部完成,Phase 4c 会话级记忆已全部完成(116 个测试通过,0 警告)。
**当前状态**Phase 0-4c 全部完成;Provider IR 重构(统一类型系统 + OpenAI/Anthropic/DeepSeek/Qwen Provider)已完成;LlmCycle 简化(IR 消息类型切换 + 桥接层移除)已完成;v0.1 发布就绪(**182 个测试通过、0 clippy 警告、7 个离线示例可运行**)。
---
@@ -338,3 +338,27 @@ graph BT
- ✅ Phase 4a Core Glue — 全部交付物已完成
- ✅ Phase 4b Task Execution — 全部交付物已完成
- ✅ Phase 4c Session Memory — 全部交付物已完成
- ✅ Provider IR 重构 — 统一类型系统 + OpenAI/Anthropic/DeepSeek/Qwen 适配(方案:`docs/10-llm-provider-refinement.md``docs/10a-phase0-types-and-trait.md``docs/10b-phase1-provider-adaptation.md`
- ✅ LlmCycle 简化 — IR 消息类型切换 + Phase 0 桥接层移除(方案:`docs/10c-phase2-llm-cycle-simplify.md`
- ✅ v0.1 Release — 技术债扫清、MockProvider 公开化、7 个离线示例、README + 错误消息友好化、Roadmap 同步、CHANGELOG 初始化(计划:`docs/11-v0.1-release-plan.md`
---
## v0.1 发布里程碑(2026-07-04
**质量基线**
| 指标 | 数值 |
|------|------|
| `cargo build --all-targets` | ✅ 通过 |
| `cargo test --all-targets` | ✅ **182 passed / 0 failed** |
| `cargo clippy --all-targets -- -D warnings` | ✅ 0 警告 |
| 离线示例(`cargo run --example` | ✅ 7 个全部 exit 0 |
**关键交付**
1. **Provider IR 重构** — 统一 `Message` / `ContentBlock` / `MessageRequest` / `MessageResponse` 类型层;4 个 Provider 适配(OpenAI Chat / Anthropic Messages / DeepSeek / Qwen);`LlmProvider` trait 签名同步切换
2. **LlmCycle 简化**`LlmCycle` 内部消息类型切到 IR 层;移除 Phase 0 的 `OpenaiChatMessage ↔ Message` 桥接;测试从 116 → 182(含 provider 测试)
3. **`MockProvider` 公开化** — `agcore::llm::mock::MockProvider` 支持 `chat` + `chat_stream`,无需 API key 即可运行示例
4. **7 个离线示例**`prompt_composer` / `custom_tool` / `agent_session_demo` / `task_agent_demo` / `conversation_memory_demo` / `knowledge_search_demo` / `streaming_events_demo`
5. **错误消息友好化**`AgentError` / `LlmError` / `ToolError` / `MemoryError` / `PromptError` 全部面向最终用户改写(给出可操作的建议)
6. **文档完整** — README 完整版(快速上手 + 架构图 + 环境变量)、Apache-2.0 LICENSE
+130
View File
@@ -0,0 +1,130 @@
//! agent_session_demo —— Agent 装配 + 会话链路 + SessionMemory 桥接。
//!
//! 演示:
//! 1. 实现 `Agent` trait(定义角色 + system prompt
//! 2. 用 `MockProvider` 预设响应(离线可跑)
//! 3. `AgentBuilder` 装配 `RuntimeBundle`
//! 4. `AgentSession::submit_turn` 跑多轮对话
//! 5. `SessionMemory` 读写 + snapshot 输出
//! 6. 跨 session 数据隔离验证
//!
//! 运行:`cargo run --example agent_session_demo`
use std::sync::Arc;
use agcore::agent::{Agent, AgentBuilder, AgentSession};
use agcore::llm::hooks::HookExecutor;
use agcore::llm::mock::MockProvider;
use agcore::llm::types::message::{ContentBlock, Message};
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
use agcore::llm::types::Usage;
use agcore::tools::ToolRegistry;
/// 计算器角色 Agent。
struct CalculatorAgent;
impl Agent for CalculatorAgent {
fn name(&self) -> &str {
"calculator"
}
fn system_prompt(&self) -> Option<&str> {
Some("你是一个简洁的计算器助手,每轮回答一句话。")
}
}
/// 构造预设的纯文本 Assistant 响应。
fn assistant_text(text: &str) -> MessageResponse {
MessageResponse {
id: String::new(),
model: String::new(),
message: Message::Assistant {
content: vec![ContentBlock::Text { text: text.into() }],
},
usage: Usage::from_input_output(8, 4),
stop_reason: StopReason::Stop,
extra: Default::default(),
}
}
#[tokio::main]
async fn main() {
// 1. MockProvider:预设三轮响应(无须 API key 即可离线运行)
let provider = Arc::new(MockProvider::new(vec![
assistant_text("1 + 1 = 2"),
assistant_text("2 + 2 = 4"),
assistant_text("会话即将结束。"),
]));
// 2. AgentBuilder 装配 RuntimeBundle(必填:provider / tool_registry / hook_executor
let bundle = Arc::new(
AgentBuilder::new()
.provider(provider)
.tool_registry(Arc::new(ToolRegistry::new()))
.hook_executor(Arc::new(HookExecutor::new()))
.build()
.expect("RuntimeBundle 装配失败"),
);
// 3. 创建会话
let agent: Arc<dyn Agent> = Arc::new(CalculatorAgent);
let mut session = AgentSession::new(agent, "demo-session", bundle.clone());
assert_eq!(session.turn_index(), 0);
// 4. 提交第一轮
println!("=== 提交第 1 轮 ===");
let resp = session.submit_turn("1+1=?").await.expect("submit_turn 失败");
println!("LLM: {}", resp.text());
session
.set_session_data("last_q", "1+1=?")
.await
.expect("set_session_data 失败");
session
.set_session_data("last_a", resp.text())
.await
.expect("set_session_data 失败");
assert_eq!(session.turn_index(), 1);
// 5. 提交第二轮
println!("\n=== 提交第 2 轮 ===");
let resp = session.submit_turn("再加一次 2+2=?").await.unwrap();
println!("LLM: {}", resp.text());
assert_eq!(session.turn_index(), 2);
// 6. 验证 SessionMemory 读取
println!("\n=== Session Memory 读取 ===");
println!(
"last_q = {:?}",
session.get_session_data("last_q").await.unwrap()
);
println!(
"last_a = {:?}",
session.get_session_data("last_a").await.unwrap()
);
// 7. Snapshot 格式化输出
println!("\n=== Session Memory Snapshot ===");
println!("{}", session.session_memory().snapshot().await.unwrap());
// 8. 跨 session 数据隔离验证
println!("=== 数据隔离验证 ===");
let other = AgentSession::new(
Arc::new(CalculatorAgent),
"other-session",
bundle,
);
assert!(
other.get_session_data("last_q").await.unwrap().is_none(),
"新会话不应看到旧 session 的 last_q"
);
println!("新会话 last_q = None ✓");
// 9. 用量累计验证
println!("\n=== 用量累计 ===");
let total = session.usage().total();
println!(
"prompt={}, completion={}, total={}",
total.prompt_tokens, total.completion_tokens, total.total_tokens
);
println!("\n✓ agent_session_demo 完成");
}
+143
View File
@@ -0,0 +1,143 @@
//! conversation_memory_demo —— 对话记忆滑动窗口与隔离。
//!
//! 演示:
//! 1. `ConversationMemoryConfig` 构造(SlidingWindow / Full 策略)
//! 2. `add_message` 写入多角色消息(Message IR
//! 3. 滑动窗口自动淘汰旧消息
//! 4. Full 策略保留全部
//! 5. `get_history` / `len` / `clear`
//! 6. 跨 session 数据隔离(共用 MemoryStore
//!
//! 运行:`cargo run --example conversation_memory_demo`
use std::sync::Arc;
use agcore::llm::types::message::Message;
use agcore::memory::{ConversationMemory, ConversationMemoryConfig, InMemoryStore, MemoryStrategy};
fn message_text(msg: &Message) -> &str {
match msg {
Message::User { content }
| Message::System { content }
| Message::Assistant { content }
| Message::ToolResult { content, .. } => content
.iter()
.filter_map(|b| match b {
agcore::llm::types::message::ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.next()
.unwrap_or(""),
Message::UserImage { .. } => "[image]",
}
}
#[tokio::main]
async fn main() {
// 1. 滑动窗口策略:写入 5 条但只保留最近 3 条
println!("=== SlidingWindow 策略(max_turns=3===");
let store = Arc::new(InMemoryStore::new());
let config = ConversationMemoryConfig {
strategy: MemoryStrategy::SlidingWindow,
max_turns: 3,
compact_config: None,
};
let mut memory = ConversationMemory::new(store, "session-1", config);
for i in 0..5 {
memory
.add_message(Message::user_text(format!("消息 {i}")))
.await
.expect("写入失败");
}
println!("写入 5 条 → len = {} (期望 3)", memory.len());
let history = memory.get_history();
for (i, msg) in history.iter().enumerate() {
println!(" [{}] {}", i, message_text(msg));
}
assert_eq!(memory.len(), 3);
assert_eq!(message_text(&history[0]), "消息 2", "最旧应是消息 2");
assert_eq!(message_text(&history[2]), "消息 4", "最新应是消息 4");
// 2. Full 策略:保留全部
println!("\n=== Full 策略(max_turns=3===");
let store2 = Arc::new(InMemoryStore::new());
let config2 = ConversationMemoryConfig {
strategy: MemoryStrategy::Full,
max_turns: 3,
compact_config: None,
};
let mut memory2 = ConversationMemory::new(store2, "session-2", config2);
for i in 0..5 {
memory2
.add_message(Message::user_text(format!("Full {i}")))
.await
.unwrap();
}
println!("写入 5 条 → len = {} (期望 5)", memory2.len());
assert_eq!(memory2.len(), 5);
// 3. 多角色混合 + clear
println!("\n=== 多角色写入 + clear ===");
let store3 = Arc::new(InMemoryStore::new());
let mut memory3 = ConversationMemory::new(
store3,
"session-3",
ConversationMemoryConfig::default(),
);
memory3
.add_message(Message::user_text("你好"))
.await
.unwrap();
memory3
.add_message(Message::assistant("你好!有什么可以帮你的吗?"))
.await
.unwrap();
memory3
.add_message(Message::user_text("今天天气怎么样?"))
.await
.unwrap();
memory3
.add_message(Message::assistant("我无法查询实时天气,但你可以查看天气应用。"))
.await
.unwrap();
println!(
"写入 4 条多角色消息 → len = {}, 最后一条: {:?}",
memory3.len(),
message_text(memory3.get_history().last().unwrap())
);
assert_eq!(memory3.len(), 4);
memory3.clear().await.unwrap();
println!(
"clear 后 → len = {}, is_empty = {}",
memory3.len(),
memory3.is_empty()
);
assert!(memory3.is_empty());
// 4. Session 隔离
println!("\n=== Session 隔离(共用 InMemoryStore===");
let store4 = Arc::new(InMemoryStore::new());
let mut a = ConversationMemory::new(
store4.clone(),
"s-a",
ConversationMemoryConfig::default(),
);
let mut b = ConversationMemory::new(
store4.clone(),
"s-b",
ConversationMemoryConfig::default(),
);
a.add_message(Message::user_text("A 的消息")).await.unwrap();
b.add_message(Message::user_text("B 的消息")).await.unwrap();
println!(
"A.len = {}, B.len = {} (期望 1/1,互不污染)",
a.len(),
b.len()
);
assert_eq!(a.len(), 1);
assert_eq!(b.len(), 1);
println!("\n✓ conversation_memory_demo 完成");
}
+169
View File
@@ -0,0 +1,169 @@
//! custom_tool —— 自定义工具注册、单次 / 并行调用、权限检查。
//!
//! 演示:
//! 1. 实现 `BaseTool` traitWeatherTool + DeleteFileTool
//! 2. 注册到 `ToolRegistry`
//! 3. 单次 `invoke`(含 tool_call_id 关联)
//! 4. 并行 `invoke_all`
//! 5. 调用未注册工具 → `ToolError::NotFound`
//! 6. `PermissionChecker` 黑名单阻断 `DeleteFileTool`
//!
//! 运行:`cargo run --example custom_tool`
use std::sync::Arc;
use agcore::tools::{
BaseTool, Permission, PermissionChecker, PermissionConfig, ToolContext, ToolError, ToolRef,
ToolRegistry,
};
use async_trait::async_trait;
use serde_json::{json, Value};
/// 天气查询工具 —— 模拟根据城市返回天气数据。
struct WeatherTool;
#[async_trait]
impl BaseTool for WeatherTool {
fn name(&self) -> &str {
"get_weather"
}
fn description(&self) -> &str {
"查询指定城市的天气"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"city": { "type": "string", "description": "城市名" }
},
"required": ["city"]
})
}
fn required_permissions(&self) -> Vec<Permission> {
vec![Permission::Network]
}
async fn execute(
&self,
args: Value,
_ctx: &ToolContext<'_>,
) -> Result<Value, ToolError> {
let city = args["city"].as_str().unwrap_or("未知");
// 模拟查询:根据城市名给出不同温度
let (temperature, condition) = match city {
"北京" => (22_i32, ""),
"上海" => (25, "多云"),
"广州" => (28, "雷阵雨"),
_ => (20, ""),
};
Ok(json!({
"city": city,
"temperature": temperature,
"condition": condition
}))
}
}
/// 删除文件工具 —— 用于演示权限黑名单阻断。
struct DeleteFileTool;
#[async_trait]
impl BaseTool for DeleteFileTool {
fn name(&self) -> &str {
"delete_file"
}
fn description(&self) -> &str {
"删除指定路径的文件"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": { "path": { "type": "string" } },
"required": ["path"]
})
}
fn required_permissions(&self) -> Vec<Permission> {
vec![Permission::Delete]
}
async fn execute(
&self,
_args: Value,
_ctx: &ToolContext<'_>,
) -> Result<Value, ToolError> {
Ok(json!({"deleted": true}))
}
}
#[tokio::main]
async fn main() {
// 1. 注册工具
let mut registry = ToolRegistry::new();
let weather: ToolRef = Arc::new(WeatherTool);
let deleter: ToolRef = Arc::new(DeleteFileTool);
registry.register(weather).expect("注册 get_weather 失败");
registry.register(deleter).expect("注册 delete_file 失败");
println!("=== 已注册工具 ===");
println!("{:?}", registry.list_tools());
// 2. 单次 invoketool_call_id 用于回传时与原始调用关联
println!("\n=== 单次 invoke ===");
let result = registry
.invoke("call_1", "get_weather", json!({"city": "北京"}))
.await
.expect("invoke 失败");
println!("tool_call_id: {}", result.tool_call_id);
println!("tool_name: {}", result.tool_name);
println!("output: {}", result.output.unwrap());
// 3. 并行 invoke_all:三个并行天气查询
println!("\n=== 并行 invoke_all30s 超时)===");
let calls = vec![
("c1".into(), "get_weather".into(), json!({"city": "北京"})),
("c2".into(), "get_weather".into(), json!({"city": "上海"})),
("c3".into(), "get_weather".into(), json!({"city": "广州"})),
];
let results = registry.invoke_all(calls, 30).await;
assert_eq!(results.len(), 3);
for r in &results {
let output = r.output.as_ref().unwrap();
println!("[{}] {}", r.tool_call_id, output);
}
// 4. 调用未注册工具
println!("\n=== 未注册工具 ===");
let err = registry
.invoke("c_x", "nope_tool", json!({}))
.await
.unwrap_err();
println!("错误: {err}");
// 5. 权限检查:默认 PermissionConfig 黑名单含 Delete
println!("\n=== 权限检查(默认 PermissionConfigdenied = [Delete, Shell]===");
let mut registry_with_checker = ToolRegistry::new().with_permission_checker(PermissionChecker::new(
PermissionConfig::default(),
));
registry_with_checker
.register(Arc::new(WeatherTool) as ToolRef)
.unwrap();
registry_with_checker
.register(Arc::new(DeleteFileTool) as ToolRef)
.unwrap();
// get_weather 声明 Network → 在 allowed 列表 → 通过
let r = registry_with_checker
.invoke("c1", "get_weather", json!({"city": "北京"}))
.await
.unwrap();
println!(
"get_weather 权限检查: {}",
if r.output.is_ok() { "通过 ✓" } else { "阻断 ✗" }
);
// delete_file 声明 Delete → 在 denied 列表 → 阻断
let err = registry_with_checker
.invoke("c2", "delete_file", json!({"path": "/tmp/x"}))
.await
.unwrap_err();
println!("delete_file 权限检查: 阻断 ✗ ({err})");
println!("\n✓ custom_tool 完成");
}
+149
View File
@@ -0,0 +1,149 @@
//! knowledge_search_demo —— 知识页面存储与关键词检索。
//!
//! 演示:
//! 1. `KnowledgeStore` 存储多个 `KnowledgePage`
//! 2. `MemoryRetriever` 按关键词检索 + TextOverlap (Dice) 评分
//! 3. 评分 [0.0, 1.0] 范围校验
//! 4. `RetrieverConfig::min_score` 阈值过滤
//! 5. `RetrieverConfig::max_results` 截断
//! 6. 空 query 返回空结果
//!
//! 运行:`cargo run --example knowledge_search_demo`
use std::sync::Arc;
use agcore::memory::{
InMemoryStore, KnowledgePage, KnowledgeStore, MemoryRetriever, MemoryStore, RetrieverConfig,
};
use time::OffsetDateTime;
fn make_page(id: &str, title: &str, content: &str) -> KnowledgePage {
let now = OffsetDateTime::now_utc();
KnowledgePage {
id: id.to_string(),
title: title.to_string(),
summary: content.chars().take(30).collect(),
content: content.to_string(),
tags: Vec::new(),
references: Vec::new(),
created_at: now,
updated_at: now,
}
}
#[tokio::main]
async fn main() {
// 1. 创建知识库 + 批量存储页面
let store: Arc<dyn MemoryStore> = Arc::new(InMemoryStore::new());
let ks = KnowledgeStore::new(store);
let pages = vec![
make_page("rust-1", "Rust 入门", "Rust 是一门系统级编程语言,注重安全性与并发。"),
make_page("python-1", "Python 简介", "Python 是一门动态类型的高级编程语言。"),
make_page(
"langgraph-1",
"LangGraph 框架",
"LangGraph 是 LangChain 的状态图扩展,用于构建多步 Agent。",
),
make_page("rust-async", "Rust 异步编程", "Rust 异步基于 tokio 与 futures 抽象。"),
];
for p in &pages {
ks.add_page(p.clone()).await.expect("保存页面失败");
}
println!("=== 已存储 {} 个知识页面 ===", pages.len());
let index = ks.get_index().await;
for entry in &index {
println!(" - {} ({})", entry.title, entry.id);
}
// 2. 关键词检索 —— 期望命中 Rust 相关页面
println!("\n=== 关键词检索:'Rust 异步' ===");
let retriever = MemoryRetriever::new(ks, RetrieverConfig::default());
let result = retriever.retrieve("Rust 异步").await.unwrap();
println!("query: {}", result.query);
for item in &result.items {
println!(
" 命中: {} (score={:.3})",
item.page.title, item.score
);
assert!(
(0.0..=1.0).contains(&item.score),
"score 应在 [0, 1] 区间"
);
}
assert!(!result.items.is_empty(), "应至少命中一个页面");
// 3. min_score 阈值过滤
println!("\n=== min_score=0.5 阈值过滤(无关 query===");
let store2: Arc<dyn MemoryStore> = Arc::new(InMemoryStore::new());
let ks2 = KnowledgeStore::new(store2);
ks2.add_page(make_page("rust-1", "Rust 入门", "Rust 入门内容。"))
.await
.unwrap();
let cfg = RetrieverConfig {
max_results: 20,
min_score: 0.5,
};
let retriever2 = MemoryRetriever::new(ks2, cfg);
let result = retriever2
.retrieve("完全不相关的火锅配方")
.await
.unwrap();
println!(
"无关 query → items.len = {} (期望 0)",
result.items.len()
);
assert!(result.items.is_empty());
// 4. max_results 截断
println!("\n=== max_results=2 截断 ===");
let store3: Arc<dyn MemoryStore> = Arc::new(InMemoryStore::new());
let ks3 = KnowledgeStore::new(store3);
for i in 0..5 {
ks3.add_page(make_page(
&format!("rust-{i}"),
"Rust 主题",
&format!("{i} 篇关于 Rust 的内容"),
))
.await
.unwrap();
}
let cfg = RetrieverConfig {
max_results: 2,
min_score: 0.0,
};
let retriever3 = MemoryRetriever::new(ks3, cfg);
let result = retriever3.retrieve("Rust").await.unwrap();
println!(
"5 个相关页面 → 返回 items.len = {} (期望 2)",
result.items.len()
);
assert_eq!(result.items.len(), 2);
// 5. 空 query
println!("\n=== 空 query ===");
let empty = retriever3.retrieve("").await.unwrap();
println!("空 query → items.len = {}", empty.items.len());
assert!(empty.items.is_empty());
// 6. 停用词过滤:`extract_keywords` 在检索前过滤单字符词与停用词
println!("\n=== 停用词过滤 ===");
let mixed = retriever3.retrieve("the Rust is").await.unwrap();
println!(
"query='the Rust is' → 命中 {} 个 (停用词 'the'/'is' 被过滤,仅 'rust' 进入搜索)",
mixed.items.len()
);
assert!(
!mixed.items.is_empty(),
"非停用词 'rust' 应命中页面(即使 query 中含停用词)"
);
let only_stop = retriever3.retrieve("the is are").await.unwrap();
println!(
"纯停用词 query='the is are' → 命中 {} 个 (期望 0,所有 token 均被过滤)",
only_stop.items.len()
);
assert!(only_stop.items.is_empty(), "纯停用词 query 必须返回空结果");
println!("\n✓ knowledge_search_demo 完成");
}
+108
View File
@@ -0,0 +1,108 @@
//! prompt_composer —— 提示词模板与组合器离线示例。
//!
//! 演示:
//! 1. `PromptTemplate::compile` + `render` 变量插值(`{{var}}` 语法)
//! 2. 缺失变量返回 `PromptError`
//! 3. `PromptTemplateRegistry` 注册 + 按名渲染
//! 4. `PromptComposer` 构造多角色消息序列
//! 5. `validate_messages` 校验消息序列合法性
//!
//! 运行:`cargo run --example prompt_composer`
use agcore::llm::types::message::{ContentBlock, Message};
use agcore::prompt::{
validate_messages, PromptComposer, PromptTemplate, PromptTemplateRegistry, TemplateContext,
};
fn message_text(msg: &Message) -> String {
match msg {
Message::System { content }
| Message::User { content }
| Message::Assistant { content }
| Message::ToolResult { content, .. } => content
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect(),
Message::UserImage { .. } => "[image]".into(),
}
}
fn main() {
// 1. PromptTemplate::compile + render —— 直接构造模板
println!("=== PromptTemplate::compile + render ===");
let tpl = PromptTemplate::compile(
"今日 {{location}} 天气:{{condition}},温度 {{temperature}}",
)
.expect("编译失败");
let mut ctx = TemplateContext::new();
ctx.insert("location", "北京");
ctx.insert("condition", "");
ctx.insert("temperature", "25°C");
let rendered = tpl.render(&ctx).expect("渲染失败");
println!("渲染结果: {rendered}");
// 2. 缺失变量 → PromptError
println!("\n=== 缺失变量 ===");
match tpl.render(&TemplateContext::new()) {
Ok(_) => println!("意外成功"),
Err(e) => println!("按预期报错: {e}"),
}
// 3. PromptTemplateRegistry —— 按名注册 + 渲染(支持 #if 条件)
println!("\n=== PromptTemplateRegistry ===");
let mut registry = PromptTemplateRegistry::new();
registry
.register("weather", "今日 {{location}}{{condition}}")
.expect("注册失败");
registry
.register("greet", "你好 {{name}}{{#if formal}} 见到您很荣幸。{{/if}}")
.expect("注册失败");
let mut ctx = TemplateContext::new();
ctx.insert("name", "Alice");
println!(
"greet (formal=false): {}",
registry.render("greet", &ctx).unwrap()
);
ctx.insert("formal", true);
println!(
"greet (formal=true): {}",
registry.render("greet", &ctx).unwrap()
);
// 4. PromptComposer —— 构造多角色消息序列
println!("\n=== PromptComposer ===");
let messages = PromptComposer::new()
.system("你是一个天气助手")
.user("今天天气怎么样?")
.assistant("请告诉我城市名。")
.user(rendered)
.build();
println!("消息数: {}", messages.len());
for (i, m) in messages.iter().enumerate() {
let role = match m {
Message::System { .. } => "system",
Message::User { .. } | Message::UserImage { .. } => "user",
Message::Assistant { .. } => "assistant",
Message::ToolResult { .. } => "tool",
};
println!("[{i}] {role}: {}", message_text(m));
}
// 5. validate_messages —— 消息序列合法性校验
println!("\n=== validate_messages ===");
match validate_messages(&messages) {
Ok(()) => println!("消息序列合法 ✓"),
Err(e) => println!("消息序列非法: {e}"),
}
let empty: Vec<Message> = Vec::new();
match validate_messages(&empty) {
Ok(()) => println!("空消息合法"),
Err(e) => println!("空消息按预期报错: {e}"),
}
println!("\n✓ prompt_composer 完成");
}
+24 -14
View File
@@ -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 0MessageResponse.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!("发送请求...");
+117
View File
@@ -0,0 +1,117 @@
//! streaming_events_demo —— LLM 流式响应事件流消费(含错误路径)。
//!
//! 演示:
//! 1. `MockProvider::chat_stream` 输出标准 `StreamEvent` 流(离线可跑)
//! 2. `LlmCycle::submit_stream` 消费流
//! 3. match 各类 `StreamEvent`MessageStart / ContentBlockStart / TextDelta /
//! ContentBlockEnd / CostUpdate / MessageComplete
//! 4. 实时累计文本与解析事件计数
//! 5. 从 `MessageComplete` 拿到完整 `MessageResponse`
//! 6. **错误路径**:队列耗尽时 `chat_stream` 返回 `Err``submit_stream` 同样
//! 返回 `Err`(错误不进流,直接 fail-fast
//!
//! 运行:`cargo run --example streaming_events_demo`
use std::sync::Arc;
use agcore::llm::cycle::{CycleConfig, LlmCycle};
use agcore::llm::mock::MockProvider;
use agcore::llm::provider::LlmProvider;
use agcore::llm::types::message::{ContentBlock, Message};
use agcore::llm::types::response_v2::{MessageResponse, StopReason, StreamEvent};
use agcore::llm::types::Usage;
use futures_util::StreamExt;
/// 构造预设的纯文本响应。
fn text_response(id: &str, text: &str) -> MessageResponse {
MessageResponse {
id: id.to_string(),
model: "mock".into(),
message: Message::Assistant {
content: vec![ContentBlock::Text { text: text.into() }],
},
usage: Usage::from_input_output(3, text.chars().count() as u32),
stop_reason: StopReason::Stop,
extra: Default::default(),
}
}
/// 消费一个流直到终止事件,并打印事件轨迹。
async fn drain_stream(stream: &mut (impl futures_util::Stream<Item = StreamEvent> + Unpin)) {
while let Some(event) = stream.next().await {
match event {
StreamEvent::MessageStart { id, model } => {
println!("[MessageStart] id={id}, model={model}");
}
StreamEvent::ContentBlockStart { index, .. } => {
println!("[ContentBlockStart] index={index}");
}
StreamEvent::TextDelta { text } => {
print!("[TextDelta] {text}");
use std::io::Write;
std::io::stdout().flush().ok();
}
StreamEvent::ContentBlockEnd { index } => {
println!("\n[ContentBlockEnd] index={index}");
}
StreamEvent::CostUpdate { usage } => {
let p = usage.prompt_tokens.unwrap_or(0);
let c = usage.completion_tokens.unwrap_or(0);
println!("[CostUpdate] prompt={p}, completion={c}");
}
StreamEvent::MessageComplete { full_response } => {
println!(
"[MessageComplete] stop_reason={:?}",
full_response.stop_reason
);
break;
}
other => {
// 未匹配的事件变体(ThinkingDelta / RefusalDelta / ToolCall* 等)
// 在 MockProvider 当前实现下不可达;保留打印以便未来 Provider 扩展时易调试。
eprintln!("[unhandled event] {other:?}");
}
}
}
}
#[tokio::main]
async fn main() {
// 1. 准备 Mock provider(仅含一轮文本响应,第二次调用会触发错误)
let provider = Arc::new(MockProvider::new(vec![text_response(
"resp-001",
"今天天气晴朗,适合户外活动。",
)]));
let dyn_provider: Arc<dyn LlmProvider> = provider.clone();
// ===== 阶段 1:正常流式响应 =====
println!("=== 阶段 1:正常流式响应 ===");
let mut cycle = LlmCycle::new_with_arc(dyn_provider.clone(), CycleConfig::default());
let mut stream = cycle
.submit_stream("讲个笑话".to_string(), vec![])
.await
.expect("阶段 1 submit_stream 应成功");
drain_stream(&mut stream).await;
// ===== 阶段 2:错误路径(队列耗尽)=====
// 队列中已无响应 → `chat_stream` 返回 `Err(LlmError::Other)` →
// `submit_stream` 用 `?` 立即传播(错误不进流,fail-fast)。
// 上层 Agent 通过 `match` 或 `?` 处理 `AgentError::Llm(_)`。
println!("\n=== 阶段 2:错误路径(队列耗尽)===");
let mut cycle = LlmCycle::new_with_arc(dyn_provider, CycleConfig::default());
let result = cycle
.submit_stream("第二次提问".to_string(), vec![])
.await;
match result {
Ok(_) => panic!("阶段 2 必须失败(队列耗尽)"),
Err(e) => {
eprintln!("[expected error] {e}");
assert!(
e.to_string().contains("预设响应已用完"),
"应包含 MockProvider 的队列耗尽提示"
);
}
}
println!("\n✓ streaming_events_demo 完成");
}
+113
View File
@@ -0,0 +1,113 @@
//! task_agent_demo —— Plan 解析、Step 状态机、错误路径。
//!
//! 演示:
//! 1. `JsonPlanParser::parse` 解析合法 JSON 输入
//! 2. `Plan` / `Step` 数据结构遍历
//! 3. `StepStatus` 状态机:Pending → Running → Completed / Failed / Skipped
//! 4. `is_pending()` / `is_terminal()` 语义
//! 5. 错误路径:非法 JSON / 空 steps / 缺字段 → `AgentError::PlanParse`
//!
//! 运行:`cargo run --example task_agent_demo`
//!
//! ## 已知技术债(v0.2 迁移指南)
//!
//! 本示例使用 `#[deprecated]` 标记的旧 wire-format 类型:
//! - `ChatResponse`、`OpenaiChatMessage`、`FinishReason` —— `OpenaiChatProvider::chat_inner()`
//! 内部转换层仍在使用(参见 `docs/10a-phase0-types-and-trait.md` §2.5.1),
//! 故结构体定义保留。
//! - `StepStatus::Completed(ChatResponse)` —— 因为 `Step` 的"已完成"变体需携带
//! provider 响应,目前沿用旧的 `ChatResponse`。
//!
//! **触发迁移的条件**v0.2 引入 IR 层的 `StepResult` / 切换为 `MessageResponse`。
//! **迁移路径**:将本文件 `ChatResponse`/`OpenaiChatMessage`/`FinishReason` 替换为
//! `MessageResponse`/`Message`/`StopReason`,移除顶部 `#![allow(deprecated)]`。
//! 上层应用代码(`TaskAgent` 消费者)也可同步迁移。
#![allow(deprecated)]
use agcore::agent::{AgentError, JsonPlanParser, PlanParser, Step, StepStatus};
use agcore::llm::types::openai_message::OpenaiChatMessage;
use agcore::llm::types::shared::FinishReason;
use agcore::llm::types::{ChatResponse, Usage};
#[tokio::main]
async fn main() {
// 1. JsonPlanParser 解析合法 JSON
println!("=== JsonPlanParser 解析合法 JSON ===");
let parser = JsonPlanParser;
let input = r#"{
"steps": [
{"description": "查询北京天气"},
{"description": "根据天气计算穿衣建议"},
{"description": "生成最终回复"}
]
}"#;
let plan = parser
.parse(input, "为用户生成今日出行建议")
.await
.expect("合法 JSON 应解析成功");
println!("Plan goal: {}", plan.goal);
println!("Plan id: {}", plan.id);
println!("Steps: {}", plan.steps.len());
for (i, step) in plan.steps.iter().enumerate() {
println!(
" [{}] {} (pending={}, terminal={})",
i,
step.description,
step.status.is_pending(),
step.status.is_terminal()
);
}
assert!(plan.steps.iter().all(|s| s.status.is_pending()));
// 2. Step 状态机:Pending → Running → Completed
println!("\n=== Step 状态机:Pending → Running → Completed ===");
let mut step = plan.steps.into_iter().next().expect("应有 step");
println!("初始: pending={}", step.status.is_pending());
assert!(step.status.is_pending());
step.status = StepStatus::Running;
println!("Running: pending={}, terminal={}", step.status.is_pending(), step.status.is_terminal());
step.status = StepStatus::Completed(ChatResponse {
message: OpenaiChatMessage::assistant_text("天气:晴,22°C"),
usage: Usage::from_input_output(5, 10),
stop_reason: Some(FinishReason::Stop),
});
println!("Completed: pending={}, terminal={}", step.status.is_pending(), step.status.is_terminal());
assert!(step.status.is_terminal());
// 3. 失败路径
println!("\n=== Step 状态机:失败路径 ===");
let mut fail_step = Step::new(0, "调用天气 API");
fail_step.status = StepStatus::Failed(AgentError::Other("API 不可用".into()));
println!("Failed: pending={}, terminal={}", fail_step.status.is_pending(), fail_step.status.is_terminal());
assert!(fail_step.status.is_terminal());
// 4. 跳过路径
let mut skipped = Step::new(0, "可选步骤");
skipped.status = StepStatus::Skipped;
println!("Skipped: terminal={}", skipped.status.is_terminal());
assert!(skipped.status.is_terminal());
// 5. 错误路径 1:非法 JSON
println!("\n=== 错误路径:非法 JSON ===");
let err = parser.parse("not json", "goal").await.unwrap_err();
println!("错误: {err}");
assert!(matches!(err, AgentError::PlanParse(_)));
// 6. 错误路径 2:空 steps
println!("\n=== 错误路径:空 steps ===");
let err = parser.parse(r#"{"steps": []}"#, "goal").await.unwrap_err();
println!("错误: {err}");
assert!(matches!(err, AgentError::PlanParse(_)));
// 7. 错误路径 3:缺 description 字段
println!("\n=== 错误路径:缺 description 字段 ===");
let bad_input = r#"{"steps": [{"description": "ok"}, {"no_desc": true}]}"#;
let err = parser.parse(bad_input, "goal").await.unwrap_err();
println!("错误: {err}");
assert!(matches!(err, AgentError::PlanParse(_)));
println!("\n✓ task_agent_demo 完成");
}
+2
View File
@@ -7,12 +7,14 @@
//! - **不绑定业务循环**`submit_turn` 在 `AgentSession` 上,不在 trait 上
use crate::agent::runtime::RuntimeBundle;
#[allow(deprecated)]
use crate::llm::types::ToolDefinition;
/// Agent 角色抽象。
///
/// 实现此 trait 即可接入 Agent Runtime。典型实现是 struct 持有静态配置(name、system prompt 模板),
/// 也可以是基于配置动态生成的轻量实现。
#[allow(deprecated)]
pub trait Agent: Send + Sync {
/// 角色名(用于日志、调试、UI 展示)。
fn name(&self) -> &str;
+25 -7
View File
@@ -89,17 +89,18 @@ impl AgentBuilder {
/// 构造 `RuntimeBundle`,校验必填字段。
///
/// **错误**`provider` / `tool_registry` / `hook_executor` 任一缺失则返回
/// `AgentError::Config("missing <field>")`,不 panic。
/// `AgentError::Config(...)`,提示调用 `.provider(...)` / `.tool_registry(...)` /
/// `.hook_executor(...)` 补齐。不 panic。
pub fn build(self) -> Result<RuntimeBundle, AgentError> {
let provider = self
.provider
.ok_or_else(|| AgentError::Config("missing provider".into()))?;
.ok_or_else(|| AgentError::Config("缺少 LLM provider,请先调用 .provider(...)".into()))?;
let tool_registry = self
.tool_registry
.ok_or_else(|| AgentError::Config("missing tool_registry".into()))?;
.ok_or_else(|| AgentError::Config("缺少 tool_registry,请先调用 .tool_registry(...)(即使是空 ToolRegistry 也需要传入)".into()))?;
let hook_executor = self
.hook_executor
.ok_or_else(|| AgentError::Config("missing hook_executor".into()))?;
.ok_or_else(|| AgentError::Config("缺少 hook_executor,请先调用 .hook_executor(...)(空 HookExecutor 也可)".into()))?;
let config = self.config.unwrap_or_default();
@@ -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
View File
@@ -129,7 +129,7 @@ mod tests {
#[test]
fn config_not_recoverable() {
assert!(!AgentError::Config("missing provider".into()).is_recoverable());
assert!(!AgentError::Config("缺少 provider".into()).is_recoverable());
}
#[test]
+25 -48
View File
@@ -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::SystemFIX-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);
+2
View File
@@ -10,6 +10,7 @@
//! - 重试由上层新建 `Plan` 实现,`TaskAgent` 不做自动重试
use crate::agent::error::AgentError;
#[allow(deprecated)]
use crate::llm::types::ChatResponse;
use async_trait::async_trait;
@@ -55,6 +56,7 @@ impl Step {
/// 均未派生 `Clone`(保留原始错误信息,传递所有权而非克隆)。如需复制 `Plan`,
/// 只能 clone 处于 `Pending` / `Running` / `Completed` / `Skipped` 状态的步骤。
#[derive(Debug)]
#[allow(deprecated)]
pub enum StepStatus {
/// 初始状态 —— 等待执行。
Pending,
+2
View File
@@ -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
View File
@@ -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));
}
}
+449
View File
@@ -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
View File
@@ -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::ToolResultis_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(), &registry)
.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(), &registry)
.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(), &registry)
.await;
let result = cycle.submit_with_tools("test".to_string(), &registry).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(), &registry)
.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
View File
@@ -5,33 +5,35 @@ use std::time::Duration;
/// 错误按可重试性分为两类:
/// - **可重试**`RateLimit`、`Timeout`、状态码 >= 500
/// - **不可重试**`Authentication`、`ContextLength`、状态码 4xx(除 429
///
/// 错误消息面向最终用户(中文),并尽量附带可操作的修复建议(如检查 API key、减少上下文)。
#[derive(thiserror::Error, Debug)]
pub enum LlmError {
/// API 认证失败(API key 无效)。
#[error("认证失败: {0}")]
/// API 认证失败(API key 无效、过期或权限不足)。
#[error("LLM 认证失败: {0}。请检查环境变量中的 API key(如 OPENAI_API_KEY / ANTHROPIC_API_KEY)是否正确")]
Authentication(String),
/// 请求被限流,可选地附带重试等待时间。
#[error("限流(retry_after={retry_after:?})")]
/// 请求被限流,可选地附带重试等待时间。可重试。
#[error("LLM 限流(服务方),建议等待 {retry_after:?} 后重试")]
RateLimit { retry_after: Option<Duration> },
/// HTTP 请求失败,包含状态码响应体。
#[error("请求失败(status={status}): {body}")]
/// HTTP 请求失败(网络错误或非 2xx 状态码),包含状态码响应体。
#[error("LLM 请求失败(HTTP {status}: {body}。请检查 Provider 端点地址(base_url)和网络连通性")]
Request { status: u16, body: String },
/// 请求超时。
#[error("请求超时(duration={duration:?})")]
/// 请求超时。可重试。
#[error("LLM 请求超时({duration:?})。请检查网络连接,或调大 LlmCycle 超时配置")]
Timeout { duration: Duration },
/// 流式响应处理错误(预留)
#[error("流式响应错误: {0}")]
/// 流式响应处理错误(SSE 解析失败、流中断等)。可重试
#[error("LLM 流式响应错误: {0}。可重试或改用非流式接口")]
Stream(String),
/// 上下文长度超
#[error("上下文超限(actual={actual}, limit={limit})")]
/// 上下文长度超出模型窗口限制
#[error("LLM 上下文超限:当前 {actual} tokens > 模型上限 {limit} tokens。请减少消息历史、缩短 prompt,或启用 auto-compactionllm::compact")]
ContextLength { actual: u32, limit: u32 },
/// 其他未分类的 LLM 调用失败。
#[error("LLM 调用失败: {0}")]
Other(String),
}
}
+4 -3
View File
@@ -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
View File
@@ -0,0 +1,305 @@
//! 公开的 [`MockProvider`] —— 在示例与测试中按顺序返回预设响应。
//!
//! 提供与真实 LLM Provider 一致的 `chat` / `chat_stream` 行为,
//! 但不发起任何网络请求,因此适合做离线示例、回归测试和 CI gate。
//!
//! # 用法
//!
//! ```no_run
//! use std::sync::Arc;
//! use agcore::llm::mock::MockProvider;
//! use agcore::llm::provider::LlmProvider;
//! use agcore::llm::types::message::{ContentBlock, Message};
//! use agcore::llm::types::response_v2::{MessageResponse, StopReason};
//! use agcore::llm::types::Usage;
//!
//! let response = MessageResponse {
//! id: "resp-1".into(),
//! model: "mock".into(),
//! message: Message::Assistant {
//! content: vec![ContentBlock::Text { text: "hi".into() }],
//! },
//! usage: Usage::from_input_output(2, 1),
//! stop_reason: StopReason::Stop,
//! extra: Default::default(),
//! };
//!
//! let provider: Arc<dyn LlmProvider> = Arc::new(MockProvider::new(vec![response]));
//! ```
//!
//! # 流式行为
//!
//! `chat_stream` 把当前响应的 `ContentBlock` 序列拆解为标准流事件:
//!
//! - `Text` → `ContentBlockStart(Text)` + `TextDelta` + `ContentBlockEnd`
//! - `Thinking` → `ContentBlockStart(Thinking)` + `ThinkingDelta` + `ContentBlockEnd`
//! - `ToolUse` → `ContentBlockStart(ToolUse)` + `ToolCallArgumentsDelta` + `ToolCallEnd`
//! - 其他变体(Image/Audio/File/Extension)—— 不在流路径中模拟,跳过
use std::pin::Pin;
use std::sync::Mutex;
use async_stream::stream;
use futures_core::Stream;
use crate::llm::error::LlmError;
use crate::llm::provider::{LlmProvider, ProviderCapabilities, ProviderFeatures};
use crate::llm::types::message::{ContentBlock, ContentBlockType, Message};
use crate::llm::types::request_v2::MessageRequest;
use crate::llm::types::response_v2::{MessageResponse, PartialUsage, StreamEvent};
/// 按调用顺序返回预设响应的 [`LlmProvider`]。
///
/// 内部用 `Mutex<Vec<MessageResponse>>` 存储队列;`chat` / `chat_stream` 均弹出队首响应。
/// 当队列耗尽时返回 `LlmError::Other("MockProvider: 预设响应已用完")`。
pub struct MockProvider {
responses: Mutex<Vec<MessageResponse>>,
}
impl MockProvider {
/// 用预设响应列表创建。
pub fn new(responses: Vec<MessageResponse>) -> Self {
Self {
responses: Mutex::new(responses),
}
}
/// 创建空队列的 Provider(后续可用 `extend` 注入响应)。
pub fn empty() -> Self {
Self::new(Vec::new())
}
/// 追加更多响应到队列末尾。
pub fn extend(&self, responses: Vec<MessageResponse>) {
self.responses.lock().unwrap().extend(responses);
}
/// 队列中剩余响应数量。
pub fn remaining(&self) -> usize {
self.responses.lock().unwrap().len()
}
fn pop(&self) -> Result<MessageResponse, LlmError> {
let mut guard = self.responses.lock().unwrap();
if guard.is_empty() {
return Err(LlmError::Other("MockProvider: 预设响应已用完".into()));
}
Ok(guard.remove(0))
}
}
#[async_trait::async_trait]
impl LlmProvider for MockProvider {
async fn chat(&self, _request: MessageRequest) -> Result<MessageResponse, LlmError> {
self.pop()
}
async fn chat_stream(
&self,
_request: MessageRequest,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
{
let response = self.pop()?;
// 提前 clone 出在 stream 闭包中需要的字段;最后 yield 时 move response。
let id = response.id.clone();
let model = response.model.clone();
let usage = response.usage;
let blocks: Vec<ContentBlock> = match &response.message {
Message::Assistant { content } => content.clone(),
_ => Vec::new(),
};
let stream = stream! {
yield Ok(StreamEvent::MessageStart {
id: id.clone(),
model: model.clone(),
});
for (i, block) in blocks.iter().enumerate() {
let index = i as u32;
match block {
ContentBlock::Text { text } => {
yield Ok(StreamEvent::ContentBlockStart {
index,
block_type: ContentBlockType::Text,
});
if !text.is_empty() {
yield Ok(StreamEvent::TextDelta { text: text.clone() });
}
yield Ok(StreamEvent::ContentBlockEnd { index });
}
ContentBlock::Thinking { text, .. } => {
yield Ok(StreamEvent::ContentBlockStart {
index,
block_type: ContentBlockType::Thinking,
});
if !text.is_empty() {
yield Ok(StreamEvent::ThinkingDelta { text: text.clone() });
}
yield Ok(StreamEvent::ContentBlockEnd { index });
}
ContentBlock::ToolUse { id: tu_id, name, input } => {
yield Ok(StreamEvent::ContentBlockStart {
index,
block_type: ContentBlockType::ToolUse {
id: tu_id.clone(),
name: name.clone(),
},
});
yield Ok(StreamEvent::ToolCallArgumentsDelta {
index,
arguments: serde_json::to_string(input)
.unwrap_or_else(|_| "{}".into()),
});
yield Ok(StreamEvent::ToolCallEnd { index });
}
// 其他变体(Image / Audio / File / ToolResult / Extension / Refusal
// 在 mock 流路径下跳过:示例仅需 text/thinking/tool 三种主流路径。
_ => {}
}
}
yield Ok(StreamEvent::CostUpdate {
usage: PartialUsage {
prompt_tokens: Some(usage.prompt_tokens),
completion_tokens: Some(usage.completion_tokens),
total_tokens: Some(usage.total_tokens),
..Default::default()
},
});
yield Ok(StreamEvent::MessageComplete { full_response: response });
};
Ok(Box::pin(stream))
}
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
provider_name: "mock",
supported_models: None,
features: ProviderFeatures::default(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::types::{StopReason, Usage};
use futures_util::StreamExt;
use serde_json::json;
fn text_response(text: &str) -> MessageResponse {
MessageResponse {
id: "r".into(),
model: "mock".into(),
message: Message::Assistant {
content: vec![ContentBlock::Text { text: text.into() }],
},
usage: Usage::from_input_output(3, 7),
stop_reason: StopReason::Stop,
extra: Default::default(),
}
}
#[tokio::test]
async fn chat_returns_queued_response() {
let provider = MockProvider::new(vec![text_response("hello")]);
let resp = provider
.chat(MessageRequest::default())
.await
.unwrap();
assert_eq!(resp.text(), "hello");
assert_eq!(provider.remaining(), 0);
}
#[tokio::test]
async fn chat_errors_when_empty() {
let provider = MockProvider::empty();
let err = provider.chat(MessageRequest::default()).await.unwrap_err();
assert!(matches!(err, LlmError::Other(_)));
}
#[tokio::test]
async fn extend_appends_responses() {
let provider = MockProvider::new(vec![text_response("a")]);
provider.extend(vec![text_response("b"), text_response("c")]);
assert_eq!(provider.remaining(), 3);
}
#[tokio::test]
async fn chat_stream_emits_text_delta_sequence() {
let provider = MockProvider::new(vec![text_response("hi")]);
let mut stream = provider.chat_stream(MessageRequest::default()).await.unwrap();
let mut seen_start = false;
let mut seen_block_start = false;
let mut seen_delta = false;
let mut seen_block_end = false;
let mut seen_complete = false;
while let Some(event) = stream.next().await {
match event.unwrap() {
StreamEvent::MessageStart { .. } => seen_start = true,
StreamEvent::ContentBlockStart {
block_type: ContentBlockType::Text,
..
} => seen_block_start = true,
StreamEvent::TextDelta { text } => {
assert_eq!(text, "hi");
seen_delta = true;
}
StreamEvent::ContentBlockEnd { .. } => seen_block_end = true,
StreamEvent::MessageComplete { .. } => {
seen_complete = true;
break;
}
_ => {}
}
}
assert!(seen_start);
assert!(seen_block_start);
assert!(seen_delta);
assert!(seen_block_end);
assert!(seen_complete);
}
#[tokio::test]
async fn chat_stream_emits_tool_call_arguments() {
let response = MessageResponse {
id: "r".into(),
model: "mock".into(),
message: Message::Assistant {
content: vec![ContentBlock::ToolUse {
id: "call_1".into(),
name: "search".into(),
input: json!({"q": "rust"}),
}],
},
usage: Usage::from_input_output(2, 5),
stop_reason: StopReason::ToolUse,
extra: Default::default(),
};
let provider = MockProvider::new(vec![response]);
let mut stream = provider.chat_stream(MessageRequest::default()).await.unwrap();
let mut saw_tool_args = false;
let mut saw_tool_end = false;
while let Some(event) = stream.next().await {
match event.unwrap() {
StreamEvent::ToolCallArgumentsDelta { arguments, .. } => {
let v: serde_json::Value = serde_json::from_str(&arguments).unwrap();
assert_eq!(v, json!({"q": "rust"}));
saw_tool_args = true;
}
StreamEvent::ToolCallEnd { .. } => saw_tool_end = true,
StreamEvent::MessageComplete { .. } => break,
_ => {}
}
}
assert!(saw_tool_args);
assert!(saw_tool_end);
}
}
+92 -28
View File
@@ -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,
/// DeepSeekOpenAI-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
View File
File diff suppressed because it is too large Load Diff
+234
View File
@@ -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
View File
@@ -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) => {
// 旧流结束 → 主动补一个 MessageCompletefull_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
View File
@@ -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
View File
@@ -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;
+45
View File
@@ -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(),
}
}
}
+165
View File
@@ -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,
}
}
}
+4 -1
View File
@@ -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")]
+209
View File
@@ -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 -1
View File
@@ -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;
+869
View File
@@ -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));
}
}
}
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -3,21 +3,28 @@
use thiserror::Error;
/// 记忆系统错误枚举。
///
/// 错误消息面向最终用户(中文),并尽量附带可操作的修复建议(如检查环境变量、重试)。
#[derive(Debug, Error)]
pub enum MemoryError {
#[error("Item not found: {0}")]
/// 按 ID 未找到指定记忆条目。可重试——通常是 namespace 拼写错误或条目已被淘汰。
#[error("未找到记忆条目 '{0}',请检查 ID 或 namespace 是否正确")]
NotFound(String),
#[error("Storage error: {0}")]
/// 底层存储失败(磁盘满、连接断开等)。一般不可恢复,建议上层记录并告警。
#[error("存储失败: {0},请检查后端存储是否可用(磁盘 / 网络 / 权限)")]
Storage(String),
#[error("Serialization error: {0}")]
/// 序列化 / 反序列化失败(数据结构变更、JSON 字段缺失等)。一般不可恢复。
#[error("序列化失败: {0},请检查数据结构兼容性或升级记忆格式")]
Serialization(String),
#[error("Invalid input: {0}")]
/// 调用方传入的参数不合法(空 ID、负数容量等)。可重试——修正参数即可。
#[error("参数无效: {0}")]
InvalidInput(String),
#[error("Retrieval error: {0}")]
/// 检索过程出错(索引重建失败、评分异常等)。可重试。
#[error("检索失败: {0},可重试或调整 query")]
RetrievalError(String),
}
@@ -26,4 +33,4 @@ impl MemoryError {
pub fn is_recoverable(&self) -> bool {
matches!(self, Self::NotFound(_) | Self::RetrievalError(_))
}
}
}
+28 -21
View File
@@ -19,7 +19,7 @@ pub const KNOWLEDGE_PREFIX: &str = "knowledge_";
/// 同时维护一个 `Vec<PageIndexEntry>` 索引以加速列表遍历。
pub struct KnowledgeStore {
store: Arc<dyn MemoryStore>,
index: std::sync::Mutex<Vec<PageIndexEntry>>,
index: tokio::sync::Mutex<Vec<PageIndexEntry>>,
}
impl KnowledgeStore {
@@ -27,7 +27,7 @@ impl KnowledgeStore {
pub fn new(store: Arc<dyn MemoryStore>) -> Self {
Self {
store,
index: std::sync::Mutex::new(Vec::new()),
index: tokio::sync::Mutex::new(Vec::new()),
}
}
@@ -40,7 +40,7 @@ impl KnowledgeStore {
..Default::default()
})
.await?;
let mut index = self.index.lock().unwrap();
let mut index = self.index.lock().await;
index.clear();
for item in items {
let page: KnowledgePage = serde_json::from_str(&item.content)
@@ -66,7 +66,7 @@ impl KnowledgeStore {
created_at: now,
};
self.store.save(item).await?;
let mut index = self.index.lock().unwrap();
let mut index = self.index.lock().await;
// 替换或追加
if let Some(existing) = index.iter_mut().find(|e| e.id == page.id) {
*existing = PageIndexEntry::from(&page);
@@ -106,7 +106,7 @@ impl KnowledgeStore {
pub async fn delete_page(&self, id: &str) -> Result<(), MemoryError> {
let full_id = format!("{KNOWLEDGE_PREFIX}{id}");
self.store.delete(&full_id).await?;
let mut index = self.index.lock().unwrap();
let mut index = self.index.lock().await;
index.retain(|e| e.id != id);
Ok(())
}
@@ -120,24 +120,31 @@ impl KnowledgeStore {
return Ok(Vec::new());
}
let needle = query.to_lowercase();
// 锁内仅 clone 匹配的 entry id,避免异步 get_page() 持有 index 锁。
let ids: Vec<String> = {
let index = self.index.lock().await;
index
.iter()
.filter(|entry| {
entry.title.to_lowercase().contains(&needle)
|| entry.summary.to_lowercase().contains(&needle)
|| entry.tags.iter().any(|t| t.to_lowercase().contains(&needle))
})
.map(|entry| entry.id.clone())
.collect()
};
let mut results = Vec::new();
let index = self.index.lock().unwrap();
for entry in index.iter() {
if entry.title.to_lowercase().contains(&needle)
|| entry.summary.to_lowercase().contains(&needle)
|| entry.tags.iter().any(|t| t.to_lowercase().contains(&needle))
{
if let Some(page) = self.get_page(&entry.id).await? {
results.push(page);
}
for id in &ids {
if let Some(page) = self.get_page(id).await? {
results.push(page);
}
}
Ok(results)
}
/// 获取内容目录(所有页面的轻量级索引条目)。
pub fn get_index(&self) -> Vec<PageIndexEntry> {
self.index.lock().unwrap().clone()
pub async fn get_index(&self) -> Vec<PageIndexEntry> {
self.index.lock().await.clone()
}
}
@@ -224,7 +231,7 @@ mod tests {
let ks = KnowledgeStore::new(store);
ks.add_page(make_page("p1", "A", &[])).await.unwrap();
ks.add_page(make_page("p2", "B", &[])).await.unwrap();
let index = ks.get_index();
let index = ks.get_index().await;
assert_eq!(index.len(), 2);
}
@@ -235,13 +242,13 @@ mod tests {
// 添加页面
ks.add_page(make_page("p1", "A", &[])).await.unwrap();
ks.add_page(make_page("p2", "B", &[])).await.unwrap();
assert_eq!(ks.get_index().len(), 2);
assert_eq!(ks.get_index().await.len(), 2);
// 模拟 index 漂移:清空后重建
ks.index.lock().unwrap().clear();
assert_eq!(ks.get_index().len(), 0);
ks.index.lock().await.clear();
assert_eq!(ks.get_index().await.len(), 0);
ks.rebuild_index().await.unwrap();
assert_eq!(ks.get_index().len(), 2);
assert_eq!(ks.get_index().await.len(), 2);
}
}
+1 -3
View File
@@ -113,9 +113,7 @@ fn extract_keywords(query: &str, stop_words: &HashSet<String>) -> Vec<String> {
.split(|c: char| !c.is_alphanumeric())
.filter_map(|s| {
let lower = s.to_lowercase();
if lower.is_empty() || lower.chars().count() < 2 {
None
} else if stop_words.contains(&lower) {
if lower.is_empty() || lower.chars().count() < 2 || stop_words.contains(&lower) {
None
} else {
Some(lower)
+1 -1
View File
@@ -4,4 +4,4 @@ pub mod composer;
pub use error::PromptError;
pub use template::{PromptTemplate, PromptTemplateRegistry, TemplateContext, TemplateValue};
pub use composer::PromptComposer;
pub use composer::{validate_messages, PromptComposer};
+63 -128
View File
@@ -1,12 +1,11 @@
use crate::llm::types::message::{ContentField, OpenaiChatMessage, OpenaiContentPart};
use crate::llm::types::request::OpenaiChatRequest;
use crate::llm::types::message::{ContentBlock, Message};
use crate::prompt::error::PromptError;
use crate::prompt::template::{PromptTemplate, TemplateContext};
/// 提示词组合器——构建多角色消息序列。
#[derive(Default)]
pub struct PromptComposer {
messages: Vec<OpenaiChatMessage>,
messages: Vec<Message>,
}
impl PromptComposer {
@@ -16,7 +15,7 @@ impl PromptComposer {
}
/// 从已有的消息列表初始化。
pub fn from_messages(messages: Vec<OpenaiChatMessage>) -> Self {
pub fn from_messages(messages: Vec<Message>) -> Self {
Self { messages }
}
@@ -24,34 +23,32 @@ impl PromptComposer {
/// 添加一条纯文本 system 消息。
pub fn system(mut self, text: impl Into<String>) -> Self {
self.push_message(OpenaiChatMessage::system_text(text.into()));
self.push_message(Message::system(text.into()));
self
}
/// 添加一条纯文本 user 消息。
pub fn user(mut self, text: impl Into<String>) -> Self {
self.push_message(OpenaiChatMessage::user_text(text.into()));
self.push_message(Message::user_text(text.into()));
self
}
/// 添加一条纯文本 assistant 消息。
pub fn assistant(mut self, text: impl Into<String>) -> Self {
self.push_message(OpenaiChatMessage::assistant_text(text.into()));
self.push_message(Message::assistant(text.into()));
self
}
/// 添加一条纯文本 developer 消息(o1 系列模型使用)。
/// IR 层统一映射为 `Message::System`,由 Provider 在发送时按目标模型决定 `role`。
pub fn developer(mut self, text: impl Into<String>) -> Self {
self.push_message(OpenaiChatMessage::developer_text(text.into()));
self.push_message(Message::system(text.into()));
self
}
/// 添加一条 Tool 消息(工具执行结果回传)。
pub fn tool(mut self, tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
self.push_message(OpenaiChatMessage::tool_result(
tool_call_id.into(),
content.into(),
));
self.push_message(Message::tool_result(tool_call_id.into(), content.into(), false));
self
}
@@ -64,7 +61,7 @@ impl PromptComposer {
ctx: &TemplateContext,
) -> Result<Self, PromptError> {
let text = template.render(ctx)?;
self.push_message(OpenaiChatMessage::user_text(text));
self.push_message(Message::user_text(text));
Ok(self)
}
@@ -75,7 +72,7 @@ impl PromptComposer {
ctx: &TemplateContext,
) -> Result<Self, PromptError> {
let text = template.render(ctx)?;
self.push_message(OpenaiChatMessage::system_text(text));
self.push_message(Message::system(text));
Ok(self)
}
@@ -86,7 +83,7 @@ impl PromptComposer {
ctx: &TemplateContext,
) -> Result<Self, PromptError> {
let text = template.render(ctx)?;
self.push_message(OpenaiChatMessage::assistant_text(text));
self.push_message(Message::assistant(text));
Ok(self)
}
@@ -97,150 +94,98 @@ impl PromptComposer {
ctx: &TemplateContext,
) -> Result<Self, PromptError> {
let text = template.render(ctx)?;
self.push_message(OpenaiChatMessage::developer_text(text));
self.push_message(Message::system(text));
Ok(self)
}
// ===== 多模态 ContentPart =====
// ===== 多模态 ContentBlock =====
/// 添加一条含指定 ContentPart 的 system 消息。
pub fn system_content(mut self, part: OpenaiContentPart) -> Self {
self.push_message(OpenaiChatMessage::System {
content: ContentField::Array(vec![part]),
name: None,
/// 添加一条含指定 ContentBlock 的 system 消息。
pub fn system_content(mut self, block: ContentBlock) -> Self {
self.push_message(Message::System {
content: vec![block],
});
self
}
/// 添加一条含指定 ContentPart 的 user 消息。
pub fn user_content(mut self, part: OpenaiContentPart) -> Self {
self.push_message(OpenaiChatMessage::User {
content: ContentField::Array(vec![part]),
name: None,
/// 添加一条含指定 ContentBlock 的 user 消息。
pub fn user_content(mut self, block: ContentBlock) -> Self {
self.push_message(Message::User {
content: vec![block],
});
self
}
/// 添加一条含指定 ContentPart 的 assistant 消息。
pub fn assistant_content(mut self, part: OpenaiContentPart) -> Self {
self.push_message(OpenaiChatMessage::Assistant {
content: ContentField::Array(vec![part]),
refusal: None,
name: None,
tool_calls: None,
/// 添加一条含指定 ContentBlock 的 assistant 消息。
pub fn assistant_content(mut self, block: ContentBlock) -> Self {
self.push_message(Message::Assistant {
content: vec![block],
});
self
}
/// 添加一条含指定 ContentPart 的 developer 消息。
pub fn developer_content(mut self, part: OpenaiContentPart) -> Self {
self.push_message(OpenaiChatMessage::Developer {
content: ContentField::Array(vec![part]),
name: None,
/// 添加一条含指定 ContentBlock 的 developer 消息。
pub fn developer_content(mut self, block: ContentBlock) -> Self {
self.push_message(Message::System {
content: vec![block],
});
self
}
/// 添加一条含指定 ContentPart 的 Tool 消息。
/// 添加一条含指定 ContentBlock 的 Tool 消息。
pub fn tool_content(
mut self,
tool_call_id: impl Into<String>,
part: OpenaiContentPart,
block: ContentBlock,
) -> Self {
self.push_message(OpenaiChatMessage::Tool {
content: ContentField::Array(vec![part]),
self.push_message(Message::ToolResult {
tool_call_id: tool_call_id.into(),
content: vec![block],
is_error: false,
});
self
}
/// 批量添加 ContentPart 作为 user 消息。
pub fn user_contents(mut self, parts: Vec<OpenaiContentPart>) -> Self {
self.push_message(OpenaiChatMessage::User {
content: ContentField::Array(parts),
name: None,
});
/// 批量添加 ContentBlock 作为 user 消息。
pub fn user_contents(mut self, blocks: Vec<ContentBlock>) -> Self {
self.push_message(Message::User { content: blocks });
self
}
/// 批量添加 ContentPart 作为 system 消息。
pub fn system_contents(mut self, parts: Vec<OpenaiContentPart>) -> Self {
self.push_message(OpenaiChatMessage::System {
content: ContentField::Array(parts),
name: None,
});
/// 批量添加 ContentBlock 作为 system 消息。
pub fn system_contents(mut self, blocks: Vec<ContentBlock>) -> Self {
self.push_message(Message::System { content: blocks });
self
}
/// 批量添加 ContentPart 作为 assistant 消息。
pub fn assistant_contents(mut self, parts: Vec<OpenaiContentPart>) -> Self {
self.push_message(OpenaiChatMessage::Assistant {
content: ContentField::Array(parts),
refusal: None,
name: None,
tool_calls: None,
});
/// 批量添加 ContentBlock 作为 assistant 消息。
pub fn assistant_contents(mut self, blocks: Vec<ContentBlock>) -> Self {
self.push_message(Message::Assistant { content: blocks });
self
}
/// 批量添加 ContentPart 作为 developer 消息。
pub fn developer_contents(mut self, parts: Vec<OpenaiContentPart>) -> Self {
self.push_message(OpenaiChatMessage::Developer {
content: ContentField::Array(parts),
name: None,
});
self
}
// ===== 角色标识 =====
/// 为上一条添加的消息设置 `name` 字段。
pub fn with_name(mut self, name: impl Into<String>) -> Self {
let name = name.into();
if let Some(msg) = self.messages.last_mut() {
set_message_name(msg, name);
}
/// 批量添加 ContentBlock 作为 developer 消息。
pub fn developer_contents(mut self, blocks: Vec<ContentBlock>) -> Self {
self.push_message(Message::System { content: blocks });
self
}
// ===== 构建 =====
/// 构建最终的消息列表。
pub fn build(self) -> Vec<OpenaiChatMessage> {
pub fn build(self) -> Vec<Message> {
self.messages
}
/// 构建并直接创建 ChatRequest(需搭配 model 参数)。
/// 返回的 `OpenaiChatRequest` 中 `tools`、`temperature`、`max_tokens` 等字段均为 `None`
/// 可通过结构体更新语法补全:`OpenaiChatRequest { tools: Some(...), ..req }`。
pub fn build_request(self, model: impl Into<String>) -> OpenaiChatRequest {
OpenaiChatRequest {
model: model.into(),
messages: self.messages,
..Default::default()
}
}
// ===== 内部方法 =====
fn push_message(&mut self, msg: OpenaiChatMessage) {
fn push_message(&mut self, msg: Message) {
self.messages.push(msg);
}
}
fn set_message_name(msg: &mut OpenaiChatMessage, name: String) {
match msg {
OpenaiChatMessage::Developer { name: n, .. } => *n = Some(name),
OpenaiChatMessage::System { name: n, .. } => *n = Some(name),
OpenaiChatMessage::User { name: n, .. } => *n = Some(name),
OpenaiChatMessage::Assistant { name: n, .. } => *n = Some(name),
OpenaiChatMessage::Tool { .. } => {}
OpenaiChatMessage::Function { .. } => {}
}
}
/// 验证消息序列是否符合 OpenAI API 要求。
pub fn validate_messages(messages: &[OpenaiChatMessage]) -> Result<(), PromptError> {
/// 验证消息序列是否符合 LLM API 要求(Tool 消息必须紧跟含 tool_calls 的 Assistant)。
pub fn validate_messages(messages: &[Message]) -> Result<(), PromptError> {
if messages.is_empty() {
return Err(PromptError::InvalidSequence(
"消息列表不能为空".to_string(),
@@ -251,10 +196,7 @@ pub fn validate_messages(messages: &[OpenaiChatMessage]) -> Result<(), PromptErr
for (i, msg) in messages.iter().enumerate() {
match msg {
OpenaiChatMessage::Tool {
tool_call_id,
..
} => {
Message::ToolResult { tool_call_id, .. } => {
if last_tool_call_ids.is_empty() {
return Err(PromptError::InvalidSequence(format!(
"消息[{i}] Tool 消息前必须有 Assistant 消息且含 tool_calls"
@@ -267,21 +209,14 @@ pub fn validate_messages(messages: &[OpenaiChatMessage]) -> Result<(), PromptErr
)));
}
}
OpenaiChatMessage::Assistant {
tool_calls: Some(calls),
..
} => {
Message::Assistant { content } => {
last_tool_call_ids.clear();
for call in calls {
let crate::llm::types::OpenaiToolCall::Function { id, .. } = call;
last_tool_call_ids.push(id.clone());
for block in content {
if let ContentBlock::ToolUse { id, .. } = block {
last_tool_call_ids.push(id.clone());
}
}
}
OpenaiChatMessage::Assistant {
tool_calls: None, ..
} => {
last_tool_call_ids.clear();
}
_ => {
last_tool_call_ids.clear();
}
@@ -318,18 +253,18 @@ mod tests {
assert_eq!(msgs.len(), 4);
match &msgs[3] {
OpenaiChatMessage::Tool {
Message::ToolResult {
tool_call_id,
content,
..
} => {
assert_eq!(tool_call_id, "call_123");
match content {
ContentField::String(s) => assert_eq!(s, "Sunny, 25°C"),
_ => {}
match &content[0] {
ContentBlock::Text { text } => assert_eq!(text, "Sunny, 25°C"),
_ => panic!("Expected Text block"),
}
}
_ => panic!("Expected Tool message"),
_ => panic!("Expected ToolResult message"),
}
}
@@ -345,7 +280,7 @@ mod tests {
#[test]
fn test_validate_messages_empty() {
let msgs: Vec<OpenaiChatMessage> = vec![];
let msgs: Vec<Message> = vec![];
assert!(validate_messages(&msgs).is_err());
}
+7 -7
View File
@@ -2,27 +2,27 @@ use thiserror::Error;
#[derive(Error, Debug)]
pub enum PromptError {
#[error("模板解析错误: {0}")]
#[error("模板解析错误: {0}。请检查模板语法({{var}} / {{#if}} / {{#each}}")]
Parse(String),
#[error("渲染错误: 变量 '{0}' 未找到")]
#[error("渲染错误: 变量 '{0}' 未找到。请在 TemplateContext 中插入该变量")]
VariableNotFound(String),
#[error("渲染错误: 引用的子模板 '{0}' 未注册")]
#[error("渲染错误: 引用的子模板 '{0}' 未注册。请先用 PromptTemplateRegistry::register 注册该子模板")]
PartialNotFound(String),
#[error("渲染错误: '{0}' 不是数组,无法遍历")]
#[error("渲染错误: '{0}' 不是数组,无法遍历。请确认传入的是数组或先判空")]
NotAnArray(String),
#[error("渲染递归超过最大深度限制 ({0})")]
#[error("渲染递归超过最大深度限制 ({0})。请检查是否有循环 include,或调大 MAX_DEPTH")]
MaxDepthReached(u8),
#[error("渲染错误: {0}")]
Render(String),
#[error("消息序列校验失败: {0}")]
#[error("消息序列校验失败: {0}。请检查消息角色顺序(例如 tool 必须在 assistant tool_call 之后)")]
InvalidSequence(String),
#[error("文件读取错误: {0}")]
#[error("文件读取错误: {0}。请检查模板文件路径与权限")]
Io(#[from] std::io::Error),
}
+13 -13
View File
@@ -5,44 +5,44 @@ use std::sync::Arc;
/// 工具调用过程中可能发生的所有错误。
#[derive(thiserror::Error, Debug, Clone)]
pub enum ToolError {
/// 工具未注册。
#[error("工具 '{0}' 未注册")]
/// 工具未注册。不可恢复——需调用方先 `registry.register(...)`。
#[error("工具 '{0}' 未注册。请先用 ToolRegistry::register(...) 注册该工具,或检查 LLM 输出的工具名拼写")]
NotFound(String),
/// 工具执行失败(可恢复——文本回传 LLM)。
#[error("工具 '{0}' 执行失败: {1}")]
/// 工具执行失败(可恢复——文本回传 LLM 由其决定重试或放弃)。
#[error("工具 '{0}' 执行失败: {1}。可让 LLM 调整参数后重试,或切换到备选工具")]
ExecutionFailed(String, String),
/// 工具参数无效(可恢复——文本回传 LLM)。
#[error("工具 '{0}' 参数无效: {1}")]
#[error("工具 '{0}' 参数无效: {1}。请检查 LLM 输出的参数是否符合 BaseTool::parameters() 声明的 JSON Schema")]
InvalidArguments(String, String),
/// 权限被拒绝(不可恢复——终止循环)。
#[error("权限被拒绝: 工具 '{0}' 需要 {1} 权限")]
#[error("权限被拒绝: 工具 '{0}' 需要 {1} 权限。请在 PermissionConfig 中显式允许,或人工确认后绕过")]
PermissionDenied(String, String),
/// MCP 协议错误(不可恢复)。
#[error("MCP 协议错误: {0}")]
#[error("MCP 协议错误: {0}。请检查 MCP server 版本与本库兼容,或查看 server 日志")]
McpError(String),
/// MCP 未初始化(不可恢复)。
#[error("MCP 未初始化: {0}")]
#[error("MCP 未初始化: {0}。请先调用 McpClient::initialize() 完成握手")]
McpNotInitialized(String),
/// MCP 超时(不可恢复)。
#[error("MCP 超时: {0}")]
#[error("MCP 超时: {0}。请检查 MCP server 是否存活,或调大超时配置")]
McpTimeout(String),
/// IO 错误(不可恢复)。
#[error("IO 错误: {0}")]
#[error("IO 错误: {0}。请检查文件路径、权限或磁盘空间")]
Io(Arc<std::io::Error>),
/// 取消。
/// 工具执行被取消。
#[error("工具执行已取消: {0}")]
Cancelled(String),
/// 其他未分类错误。
#[error("其他错误: {0}")]
#[error("工具调用错误: {0}")]
Other(String),
}
@@ -105,7 +105,7 @@ mod tests {
#[test]
fn test_io_is_not_recoverable() {
let io_err = std::io::Error::new(std::io::ErrorKind::Other, "disk");
let io_err = std::io::Error::other("disk");
let err = ToolError::from(io_err);
assert!(!err.is_recoverable());
}
+3
View File
@@ -20,6 +20,7 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
use tokio::sync::{oneshot, Mutex};
#[allow(deprecated)]
use crate::llm::types::ToolDefinition;
use crate::tools::base::{BaseTool, ToolContext, ToolRef};
use crate::tools::error::ToolError;
@@ -135,6 +136,7 @@ impl std::fmt::Debug for McpClient {
}
}
#[allow(deprecated)]
impl McpClient {
/// 创建一个 MCP 客户端。
pub fn new(server_name: impl Into<String>, transport: McpTransport) -> Self {
@@ -540,6 +542,7 @@ enum McpClientHandle {
}
#[async_trait]
#[allow(deprecated)]
impl BaseTool for McpToolAdapter {
fn name(&self) -> &str {
&self.name
+57 -16
View File
@@ -7,6 +7,7 @@ use std::time::Duration;
use futures::future::join_all;
use serde_json::Value;
#[allow(deprecated)]
use crate::llm::types::ToolDefinition;
use crate::tools::base::{ToolContext, ToolRef};
use crate::tools::error::ToolError;
@@ -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());
}
}