diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs index bc17d823c..7b687fb6e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs @@ -133,6 +133,7 @@ fn stream_delta(delta_text: &str, accumulated_text: &str) -> platform_llm::LlmSt accumulated_text: accumulated_text.to_string(), delta_text: delta_text.to_string(), finish_reason: None, + tool_call_deltas: Vec::new(), } } diff --git a/docs/project-memory/plans/【计划】LLM流式工具调用完整格式改造-2026-07-27.md b/docs/project-memory/plans/【计划】LLM流式工具调用完整格式改造-2026-07-27.md new file mode 100644 index 000000000..def78a1f4 --- /dev/null +++ b/docs/project-memory/plans/【计划】LLM流式工具调用完整格式改造-2026-07-27.md @@ -0,0 +1,461 @@ +# LLM 流式工具调用完整格式改造计划 + +日期:`2026-07-27` + +状态:`任务一已完成,待审核` + +## 1. 背景与问题 + +mentor 对本任务的说明是:当前 OpenAI Responses、流式返回和 Anthropic 只完成了较简单的版本,例如流式场景会丢弃 tool call。 + +当前 `platform-llm` 的统一流式增量 `LlmStreamDelta` 只承载: + +- 累计文本 `accumulated_text` +- 当前文本增量 `delta_text` +- 结束原因 `finish_reason` + +内部 `ParsedStreamEvent` 和 `stream_run` 的聚合状态也只处理文本、结束原因和 usage。流式请求结束时,`LlmRunResponse.tool_calls` 被固定设置为空数组,因此即使 Provider 的 SSE 已返回工具调用分片,上层仍无法取得完整 tool call。 + +三个协议的具体缺口如下: + +1. OpenAI Chat 流式分支没有聚合 `choices[].delta.tool_calls[]`。 +2. OpenAI Responses 流式分支主要处理 `response.output_text.delta`,没有完整处理 function call output item 生命周期。 +3. Anthropic 流式分支主要处理 `text_delta`,没有处理 `tool_use` 和 `input_json_delta`;请求侧当前也拒绝 function tools。 +4. 现有流完成与尾错保留逻辑以“已经得到文本”为主要成功条件,无法正确接受只有 tool call、没有文本的合法响应。 + +## 2. 改造目标 + +本次改造完成后,`platform-llm` 应满足: + +1. 文本流行为与现有调用方保持兼容。 +2. 流式 tool call 分片不会被丢弃。 +3. callback 能收到协议无关的标准化 tool call 增量。 +4. 多个并行或交错返回的 tool call 可以独立、确定性地聚合。 +5. 流结束时生成完整的 `LlmRunResponse.tool_calls`。 +6. 只有 tool call、没有文本的流式响应也能成功返回。 +7. OpenAI Chat、OpenAI Responses 和 Anthropic 最终输出相同的统一 `LlmToolCall`。 +8. Provider 特有 SSE 结构只留在 `platform-llm`,不泄漏到业务 Runtime。 +9. 流式与非流式对相同 Provider 响应形成一致的最终文本、tool calls、结束状态和 usage 语义。 + +## 3. 本次范围 + +### 3.1 纳入范围 + +- 扩展统一流式增量和内部解析事件。 +- 建立协议无关的 tool call 聚合状态机。 +- 补齐 OpenAI Chat 流式 tool calls。 +- 补齐 OpenAI Responses function call 流式事件。 +- 补齐 Anthropic tools 请求映射、非流式 `tool_use` 和流式 `tool_use`。 +- 调整流完成、空响应、尾错保留和 usage 汇总逻辑。 +- 审计并兼容当前所有 `stream_run` callback 调用方。 +- 增加三种协议的确定性 mock SSE 测试和下游回归。 +- 更新 `platform-llm` README、AI 游戏创作技术方案和必要的共享项目记忆。 + +### 3.2 暂不纳入范围 + +- Markdown 或其它前端富文本渲染。 +- 新增 LLM Provider 或模型选择功能。 +- 音频、视频、任意文件等新的多模态类型。 +- Realtime API。 +- 改造 Agent 工具实际执行器、权限、确认、持久化或恢复语义。 +- 静默扩展现有 `/api/llm/*` 对外 HTTP/SSE 契约。 +- 与流式 tool call 无关的全量上游字段照搬。 +- 未经单独评审的 reasoning 原文公开或持久化。 + +Anthropic `tool_result` 请求回传属于完整工具闭环。如果任务一至任务三实施时现有调用链已经需要它,则与 `tool_use` 一并补齐;如果当前没有调用方,本计划要求先冻结统一消息契约和测试,但不得在文档中宣称真实工具回传闭环已经完成。 + +## 4. 设计原则 + +1. **统一聚合,不复制三套状态机**:协议解析器只把原始事件映射成标准化内部事件,完整 tool call 由一个聚合器生成。 +2. **公共改动尽量增量兼容**:保留现有文本字段和 `LlmRunResponse.tool_calls`,通过新增字段承载工具分片。 +3. **身份冲突失败关闭**:无法唯一定位工具调用分片时返回协议错误,不把参数拼到错误调用。 +4. **确定性顺序**:最终 tool calls 按 Provider 声明的顺序或首次出现顺序输出。 +5. **不提前解释业务参数**:平台层负责完整拼接 arguments;具体工具 schema 和业务合法性仍由上层校验。 +6. **工具调用也是有效结果**:文本为空但存在完整 tool calls 时不得返回 `EmptyResponse`。 +7. **不以真实 Provider 代替确定性测试**:协议解析先用冻结 fixtures 覆盖,再视配置情况补真实网关 smoke。 +8. **保持现有安全边界**:不得把 reasoning、密钥、原始请求头、私有正文或绝对路径新增到公共事件和日志。 + +## 5. 公共结构改造 + +### 5.1 扩展 `LlmStreamDelta` + +保留现有字段和语义: + +| 字段 | 处理 | +| --- | --- | +| `accumulated_text` | 保留,继续表示当前累计可见文本 | +| `delta_text` | 保留,继续表示本次可见文本增量 | +| `finish_reason` | 保留,继续表示当前已知结束原因 | +| `tool_call_deltas` | 新增,表示本次事件携带的标准化工具调用分片 | + +新增标准化 tool call delta 类型,至少承载: + +| 字段 | 含义 | +| --- | --- | +| `index` | 当前响应内工具调用的稳定顺序 | +| `id` | 本次分片携带的工具调用 ID,可选 | +| `name` | 本次分片携带的函数名,可选 | +| `arguments_delta` | 本次新增的参数字符串,可为空 | +| `is_done` | 是否收到该工具调用的协议级完成信号 | + +流式早期可能尚未取得 ID、函数名或完整 arguments,因此不能直接用所有字段均必填的最终 `LlmToolCall` 替代 delta 类型。 + +### 5.2 保持最终响应稳定 + +`LlmRunResponse.tool_calls: Vec` 保持现有类型和最终语义。主要下游不需要迁移最终响应类型,只需要 `platform-llm` 在流结束时真正填充它。 + +### 5.3 callback 新语义 + +改造后 callback 可能在“没有文本、只有 tool call 分片”时触发。所有调用方必须显式处理该变化,不能假设每次 callback 都有非空 `delta_text` 或变化后的 `accumulated_text`。 + +## 6. 内部事件与聚合状态机 + +### 6.1 扩展 `ParsedStreamEvent` + +在现有文本、finish reason、usage 和 terminal 字段之外,新增标准化 `tool_call_deltas`。三个协议解析分支只负责输出标准化事件,不直接持有各自独立的最终 tool call 数组。 + +### 6.2 聚合状态 + +一次 `stream_run` 内统一维护: + +- 累计文本 +- 当前结束原因 +- 当前 usage +- 按工具调用槽位保存的 pending tool calls +- 工具调用首次出现顺序 +- 各工具调用是否完成 +- 整个响应是否终止 + +每个 pending tool call 至少保存: + +- 标准化 index +- 当前已知 ID +- 当前已知函数名 +- 已累计 arguments +- 是否收到完成信号 +- 仅供内部匹配的 Provider 定位信息 + +### 6.3 分片匹配规则 + +按以下优先级定位同一工具调用: + +1. Provider 提供的 `index`、`output_index` 或 content block index。 +2. 已存在的 tool call ID、item ID 或 call ID。 +3. 只有一个 pending call 时允许唯一回退。 +4. 无法唯一定位或身份发生冲突时,返回 `Deserialize` 类协议错误。 + +### 6.4 arguments 聚合规则 + +1. 按 SSE 到达顺序追加 arguments delta。 +2. 空 delta 不产生内容变化。 +3. done 事件携带完整 arguments 时: + - 与累计值一致则确认完成; + - 累计值为空则采用 done 值; + - 两者冲突则失败关闭。 +4. 多个工具调用必须拥有独立缓冲区。 +5. 平台层保留最终 arguments 字符串,不替代上层工具 schema 校验。 + +### 6.5 流结束汇总 + +收到协议终止事件或正常 EOF 后: + +1. 按首次出现顺序遍历 pending tool calls。 +2. 校验每个调用具有非空 ID 和函数名。 +3. 生成完整 `LlmToolCall`。 +4. 文本为空但 `tool_calls` 非空时返回成功。 +5. 文本和 `tool_calls` 都为空时才返回 `EmptyResponse`。 +6. tool call 身份不完整时不得用 finish reason 掩盖错误。 + +## 7. OpenAI Chat 流式改造 + +### 7.1 解析范围 + +在现有 `choices[0].delta.content`、finish reason 和 usage 之外,解析: + +- `choices[0].delta.tool_calls[].index` +- `tool_calls[].id` +- `tool_calls[].function.name` +- `tool_calls[].function.arguments` + +### 7.2 必须覆盖的情况 + +- ID、name、arguments 一次完整返回。 +- ID 只在第一帧出现。 +- name 和 arguments 位于不同帧。 +- arguments 被拆成多个字符串片段。 +- 两个或更多 tool calls 交错返回。 +- 同一帧同时包含文本和 tool call。 +- `finish_reason = tool_calls` 且没有文本。 +- usage-only 尾包。 +- `[DONE]` 终止。 +- 空或 `null` choices 心跳包保持现有兼容行为。 +- SSE chunk 和 UTF-8 字符边界被任意拆分。 + +### 7.3 阶段验收 + +相同 mock 响应分别走非流式和流式后,文本、tool calls、顺序、arguments 和 finish reason 应一致。 + +## 8. OpenAI Responses 流式改造 + +### 8.1 事件范围 + +至少处理: + +- `response.output_text.delta` +- `response.output_item.added` +- `response.function_call_arguments.delta` +- `response.function_call_arguments.done` +- `response.output_item.done` +- `response.completed` +- `response.failed` +- `error` + +实现前须通过 OpenAI 官方开发文档和 API schema 冻结当前事件字段,兼容网关变体只在已有 fixture 或真实证据支持时加入,不能凭猜测扩展。 + +### 8.2 聚合规则 + +1. `response.output_item.added`:当 item 为 `function_call` 时,按 `output_index` 创建槽位,记录 item ID、call ID、name 和可能存在的初始 arguments。 +2. `response.function_call_arguments.delta`:按 output index 或 item ID 找到槽位并追加 delta。 +3. `response.function_call_arguments.done`:标记参数完成;如果携带完整 arguments,执行一致性核对。 +4. `response.output_item.done`:补齐最终身份、name 和 arguments,并标记对应调用完成。 +5. `response.completed`:提取最终 status、usage 和响应身份,标记整个流终止。 +6. `response.failed` 或 `error`:映射成稳定的上游错误,不返回部分未完成 tool call。 + +### 8.3 兼容变体 + +聚合器允许合法的“只在 `output_item.done` 返回完整对象”变体;如果同一槽位的 index、item ID、call ID 或函数名冲突,则失败关闭。 + +## 9. Anthropic 改造 + +### 9.1 请求与非流式前置补齐 + +为了让真实 Anthropic 流式 tool call 可达,需要同步: + +- 将统一 `LlmFunctionTool` 映射到 Anthropic `tools`。 +- 映射 Anthropic `tool_choice`。 +- 移除当前对 Anthropic function tools 的本地拒绝。 +- 保留与本任务无关的 web search 拒绝。 +- 非流式响应识别 `text` 和 `tool_use` content block。 + +### 9.2 流式事件范围 + +- `message_start` +- `content_block_start` +- `content_block_delta` +- `content_block_stop` +- `message_delta` +- `message_stop` +- `error` + +### 9.3 tool use 聚合 + +1. `content_block_start`:当 block 类型为 `tool_use` 时,按 content block index 创建槽位,记录 tool use ID、name 和可能存在的初始 input。 +2. `content_block_delta`:当 delta 类型为 `input_json_delta` 时,读取并追加 `partial_json`,同时产生标准化 tool call delta。 +3. `content_block_stop`:标记当前工具调用完成,但不终止整个 message。 +4. `message_delta`:保存 stop reason 并合并 output token usage。 +5. `message_stop`:终止整个流并汇总文本与 tool calls。 + +文本 block 继续按现有 `text_delta` 规则处理。非流式与流式对相同内容应输出一致的文本和统一 `LlmToolCall`。 + +## 10. 流完成、尾错与 usage + +需要统一调整: + +1. `EmptyResponse` 判断改为“文本和工具调用都为空”。 +2. tail error 保留逻辑同时考虑已完成文本和已完成 tool calls。 +3. 已收到 finish reason 且存在完整 tool call 时,允许按现有容错类别保留完成结果。 +4. pending tool call 缺少 ID 或 name 时不得保留为成功结果。 +5. terminal 事件后忽略多余尾包,保持防重复语义。 +6. usage 可来自独立尾包、Responses completed 或 Anthropic message delta。 +7. 每个标准化事件最多触发一次 callback:有文本、有 tool call 分片,或现有协议要求传递 finish reason 时触发;纯 usage 更新默认不触发 UI callback。 + +## 11. 调用方兼容审计 + +### 11.1 通用 `/api/llm/*` SSE + +- 第一阶段保持现有 HTTP SSE 文本契约不变。 +- tool-only callback 不发送空文本事件。 +- 是否向外部 complete 事件增加 tool calls 必须先评审 shared contract,不在本任务中静默扩展。 + +### 11.2 Creation Agent JSON 流 + +- 只在累计文本发生变化时继续提取部分 JSON。 +- tool-only callback 不重复触发 `on_reply_update`。 + +### 11.3 RPG 和 runtime chat + +- 只有文本变化时刷新 UI。 +- tool-only 增量不得清空或重复覆盖当前文本。 + +### 11.4 AI Game Creator Shell + +- 保持 `accumulated_text` 和 `finish_reason` 现有语义。 +- tool-only callback 不产生空消息。 +- 当前非流式 native tool planning、Provider lifecycle、handoff、持久化和“未知结果不得原样重放”边界保持不变。 + +### 11.5 Rust 公共类型兼容 + +新增公共 struct 字段会影响通过 struct literal 构造它的代码。实施前必须全仓搜索 `LlmStreamDelta {`、`ParsedStreamEvent {` 和相关 fixtures,迁移全部仓库内构造点。 + +不为避免 struct literal 迁移而新建第二套平行流式 API;如发现仓库外必须保持的公开兼容承诺,再单独评审兼容入口。 + +## 12. 测试矩阵 + +### 12.1 聚合器纯单测 + +- 单工具分片。 +- 多工具交错。 +- ID 或 name 晚到。 +- arguments 多段拼接。 +- done 完整值与累计值一致。 +- done 完整值冲突。 +- 缺 ID 或缺 name。 +- tool-only 响应。 +- 文本与工具混合。 +- 确定性输出顺序。 + +### 12.2 OpenAI Chat SSE + +- 完整 tool call。 +- 分段 arguments。 +- 多 tool calls。 +- finish-only。 +- usage-only。 +- `[DONE]`。 +- chunk 与 UTF-8 边界。 +- malformed 尾包。 + +### 12.3 OpenAI Responses SSE + +- item added → arguments delta → done → completed。 +- 只有 output item done。 +- 多 output items。 +- 文本和 function call 混合。 +- completed usage。 +- failed 和 error。 + +### 12.4 Anthropic + +- 非流式 text + tool use。 +- 流式纯文本。 +- 单个 tool use。 +- 多个 content blocks。 +- text + tool use。 +- 多段 `partial_json`。 +- message delta usage 和 stop reason。 +- message stop。 +- error。 + +### 12.5 下游回归 + +- `platform-llm` 全量测试。 +- `api-server` LLM、Creation Agent 和 runtime chat 定向测试。 +- AI Game Creator Shell 流式聊天和 native tool planning 定向测试。 +- 现有纯文本流输出保持不变。 +- 非流式工具计划行为保持不变。 + +## 13. 实施阶段与任务拆分 + +所有任务严格串行执行,不并行修改 `platform-llm/src/lib.rs`。 + +### 任务一:公共结构、聚合器与 OpenAI Chat + +范围: + +1. 扩展 `LlmStreamDelta` 和内部解析事件。 +2. 实现协议无关 tool call 聚合器。 +3. 调整基础流完成、空响应和尾错判断。 +4. 补齐 OpenAI Chat 流式 tool calls。 +5. 完成聚合器和 Chat 测试。 + +非目标:不处理 Responses 和 Anthropic。 + +建议提交标题:`补齐LLM流式工具调用聚合基础与Chat协议` + +### 任务二:OpenAI Responses + +前置:任务一已经审核并形成稳定提交。 + +范围: + +1. 补齐 Responses function call 流式事件。 +2. 对齐 Responses 非流式和流式最终结果。 +3. 完成 Responses 测试矩阵。 + +非目标:不处理 Anthropic,不改调用方公开契约。 + +建议提交标题:`补齐OpenAI Responses流式工具调用` + +### 任务三:Anthropic + +前置:任务二已经审核并形成稳定提交。 + +范围: + +1. 补齐 tools 和 tool choice 请求映射。 +2. 补齐非流式 `tool_use`。 +3. 补齐流式 content block tool use。 +4. 完成 Anthropic 测试矩阵。 + +建议提交标题:`补齐Anthropic工具调用与流式协议` + +### 任务四:调用方兼容、全量验证与文档 + +前置:三种协议实现均已审核。 + +范围: + +1. 审计全部 `stream_run` callback。 +2. 避免 tool-only callback 产生空 UI 或 SSE 更新。 +3. 回归 api-server 和 AI Game Creator Shell。 +4. 执行完整验证矩阵。 +5. 更新 README、技术方案和共享项目记忆。 + +建议提交标题:`收口LLM完整流式格式兼容与验证` + +## 14. 验证顺序 + +各阶段按修改范围执行,最终阶段至少运行: + +```bash +cargo test -p platform-llm --manifest-path server-rs/Cargo.toml +cargo check -p platform-llm --manifest-path server-rs/Cargo.toml +# 根据实际受影响模块追加 api-server 与 AI Game Creator Shell 定向测试 +npm run check:encoding +git diff --check +``` + +如果改动实际进入 AI Game Creator Runtime 的 Provider native tool planning 路径,追加当前 AI 游戏创作技术方案规定的 native tool planning 定向回归。真实外部 Provider smoke 只作为补充,不替代确定性协议测试;没有真实配置时必须如实记录未执行。 + +## 15. 阶段交接规则 + +每个任务完成后更新本节,不依赖复制聊天记录。交接必须包含: + +- 完成范围 +- 关键设计决定 +- 修改文件 +- 已执行验证及结果 +- 未执行验证及原因 +- 当前提交 hash(提交后填写) +- 已知风险 +- 下一任务入口 + +### 当前交接 + +- 已完成范围:任务一。已新增公共 `LlmToolCallDelta` 并扩展 `LlmStreamDelta.tool_call_deltas`;内部 `ParsedStreamEvent` 已承载协议无关工具分片;统一聚合器已支持按 index、ID 和唯一 pending 槽位匹配,支持 ID / name 晚到、arguments 分段追加、完成值一致性校验、身份冲突失败关闭和按 Provider index 确定性输出;OpenAI Chat SSE 已解析 `delta.tool_calls`,tool-only 流可成功生成最终 `LlmRunResponse.tool_calls`;空响应和尾错保留已同时考虑完整 tool calls。 +- 关键设计决定:Provider 原始分片先映射为内部 `ParsedToolCallDelta`,聚合器解析稳定 index 后再向 callback 输出公共 `LlmToolCallDelta`;Chat 的 `finish_reason` 或 `[DONE]` 负责把尚未完成的 pending calls 标记为 done;正常 EOF 可汇总身份完整的调用,tail error 只有在 finish reason 已存在且文本或全部工具调用已完整时才保留结果;最终调用缺少 ID / name、同一 index 身份冲突、同一 ID 指向不同 index、arguments 完成值冲突或多 pending 下无法定位分片时统一返回 `Deserialize`。 +- 修改文件:`server-rs/crates/platform-llm/src/lib.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent.rs`(仅迁移测试中的 `LlmStreamDelta` struct literal)、本计划文档。 +- 已执行验证:`cargo test -p platform-llm --manifest-path server-rs/Cargo.toml`,50 passed;`cargo check -p platform-llm --manifest-path server-rs/Cargo.toml`,通过;`cargo check -p api-server --manifest-path server-rs/Cargo.toml`,通过;`cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`,通过,仅有既有 unused / dead-code warnings;`cargo fmt --manifest-path server-rs/Cargo.toml --all -- --check`,通过;`npm run check:encoding`,5237 files passed;`git diff --check`,通过。 +- 未执行验证:未运行真实外部 Provider smoke;任务一使用确定性 mock SSE 覆盖协议行为,且本次未读取 `.env` / `.env.local` 或真实密钥。未运行 Responses / Anthropic 新协议测试,因为它们明确属于任务二、任务三。 +- 当前提交 hash:本阶段代码与本交接同提交,以当前分支提交历史为准。 +- 已知风险:OpenAI Responses 和 Anthropic 流式工具调用仍未实现;公共 callback 现在会收到 tool-only 更新,通用 `/api/llm/*`、Creation Agent、RPG/runtime chat 和 AI Game Creator Shell 的完整兼容收口仍属于任务四,在任务四前可能出现空文本回调或重复 UI 更新;本阶段没有扩展现有对外 HTTP/SSE complete 契约。 +- 已知工作区修改:`.env`、`.env.local` 为任务开始前已有用户修改,本任务未读取、覆盖或提交。 +- 下一步:任务一形成稳定提交后,按任务二范围补齐 OpenAI Responses,不提前处理 Anthropic。 + +## 16. 新任务提示词 + +任务一建议使用: + +> 阅读 `AGENTS.md` 和 `docs/project-memory/plans/【计划】LLM流式工具调用完整格式改造-2026-07-27.md`,执行计划中的任务一:扩展公共流式结构、实现协议无关 tool call 聚合器、补齐 OpenAI Chat 流式 tool calls,并完成对应测试。不要处理 OpenAI Responses 和 Anthropic,不做无关重构。完成后更新计划文档的阶段交接,但先不要提交,等待审核。 + +任务二至任务四沿用该格式,只替换阶段范围,并要求先核对上一阶段提交和交接记录。 diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index 62ea4aa8d..6bc467278 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -191,11 +191,21 @@ impl LlmResponseTextVerbosity { } // 上层在流式消费时拿到的是“累计文本 + 当前增量”,避免每层重新自己拼接。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LlmToolCallDelta { + pub index: usize, + pub id: Option, + pub name: Option, + pub arguments_delta: String, + pub is_done: bool, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct LlmStreamDelta { pub accumulated_text: String, pub delta_text: String, pub finish_reason: Option, + pub tool_call_deltas: Vec, } // 用于保留 token 计数,后续模块可以决定是否写入审计或成本统计。 @@ -456,14 +466,20 @@ struct ChatCompletionsMessage { #[derive(Deserialize)] struct ChatCompletionsToolCall { - id: String, - function: ChatCompletionsFunctionCall, + #[serde(default)] + index: Option, + #[serde(default)] + id: Option, + #[serde(default)] + function: Option, } #[derive(Deserialize)] struct ChatCompletionsFunctionCall { - name: String, - arguments: String, + #[serde(default)] + name: Option, + #[serde(default)] + arguments: Option, } #[derive(Deserialize)] @@ -568,9 +584,34 @@ struct ParsedStreamEvent { delta_text: Option, finish_reason: Option, usage: Option, + tool_call_deltas: Vec, is_terminal: bool, } +#[derive(Clone, Debug, PartialEq, Eq)] +struct ParsedToolCallDelta { + index: Option, + id: Option, + name: Option, + arguments_delta: String, + completed_arguments: Option, + is_done: bool, +} + +#[derive(Debug, Default)] +struct ToolCallAccumulator { + pending: Vec, +} + +#[derive(Debug)] +struct PendingToolCall { + index: usize, + id: Option, + name: Option, + arguments: String, + is_done: bool, +} + #[derive(Debug)] struct SseEventDrainError { parsed_events: Vec, @@ -1128,6 +1169,7 @@ impl LlmClient { let mut accumulated_text = String::new(); let mut finish_reason = None; let mut usage = None; + let mut tool_call_accumulator = ToolCallAccumulator::default(); let mut undecoded_chunk_bytes = Vec::new(); let emit_finish_only_delta = request.api_kind == LlmApiKind::OpenAiChat; let mut stream_terminated = false; @@ -1140,6 +1182,7 @@ impl LlmClient { if retain_completed_stream_after_tail_error( accumulated_text.as_str(), &finish_reason, + &tool_call_accumulator, "read_stream_failed", &llm_error, ) { @@ -1170,6 +1213,7 @@ impl LlmClient { if retain_completed_stream_after_tail_error( accumulated_text.as_str(), &finish_reason, + &tool_call_accumulator, "decode_stream_failed", &error, ) { @@ -1196,6 +1240,7 @@ impl LlmClient { &mut accumulated_text, &mut finish_reason, &mut usage, + &mut tool_call_accumulator, emit_finish_only_delta, &mut on_delta, ) @@ -1224,6 +1269,7 @@ impl LlmClient { if retain_completed_stream_after_tail_error( accumulated_text.as_str(), &finish_reason, + &tool_call_accumulator, "decode_stream_failed", &llm_error, ) { @@ -1248,6 +1294,7 @@ impl LlmClient { &mut accumulated_text, &mut finish_reason, &mut usage, + &mut tool_call_accumulator, emit_finish_only_delta, &mut on_delta, ) @@ -1271,6 +1318,7 @@ impl LlmClient { &mut accumulated_text, &mut finish_reason, &mut usage, + &mut tool_call_accumulator, emit_finish_only_delta, &mut on_delta, ) @@ -1288,7 +1336,18 @@ impl LlmClient { } let content = accumulated_text.trim().to_string(); - if content.is_empty() { + let tool_calls = tool_call_accumulator.finalize().map_err(|error| { + log_llm_raw_failure( + &self.config, + &request, + true, + 1, + "parse_stream_tool_calls_failed", + parser.raw_text().as_str(), + ); + error + })?; + if content.is_empty() && tool_calls.is_empty() { log_llm_raw_failure( &self.config, &request, @@ -1307,7 +1366,7 @@ impl LlmClient { finish_reason, response_id, usage, - tool_calls: Vec::new(), + tool_calls, }) } @@ -1559,11 +1618,251 @@ impl OpenAiCompatibleSseParser { } } +impl ToolCallAccumulator { + fn apply( + &mut self, + deltas: Vec, + ) -> Result, LlmError> { + let mut normalized = Vec::with_capacity(deltas.len()); + for delta in deltas { + if let Some(delta) = self.apply_one(delta)? { + normalized.push(delta); + } + } + Ok(normalized) + } + + fn apply_one( + &mut self, + delta: ParsedToolCallDelta, + ) -> Result, LlmError> { + if delta.id.as_deref().is_some_and(|id| id.trim().is_empty()) { + return Err(tool_call_protocol_error("tool call id 不能为空")); + } + if delta + .name + .as_deref() + .is_some_and(|name| name.trim().is_empty()) + { + return Err(tool_call_protocol_error("tool call name 不能为空")); + } + + let position = self.resolve_position(&delta)?; + let position = if let Some(position) = position { + position + } else { + let index = delta.index.unwrap_or_else(|| self.next_available_index()); + self.pending.push(PendingToolCall { + index, + id: None, + name: None, + arguments: String::new(), + is_done: false, + }); + self.pending.len() - 1 + }; + let pending = &mut self.pending[position]; + + if pending.is_done + && (!delta.arguments_delta.is_empty() + || delta.completed_arguments.is_some() + || !delta.is_done) + { + return Err(tool_call_protocol_error(format!( + "tool call index={} 已完成后仍收到新分片", + pending.index + ))); + } + + merge_tool_call_identity(&mut pending.id, delta.id.as_deref(), "id", pending.index)?; + merge_tool_call_identity( + &mut pending.name, + delta.name.as_deref(), + "name", + pending.index, + )?; + + let mut arguments_delta = delta.arguments_delta; + if !arguments_delta.is_empty() { + pending.arguments.push_str(arguments_delta.as_str()); + } + if let Some(completed_arguments) = delta.completed_arguments { + if pending.arguments.is_empty() { + pending.arguments = completed_arguments.clone(); + arguments_delta = completed_arguments; + } else if pending.arguments != completed_arguments { + return Err(tool_call_protocol_error(format!( + "tool call index={} 完整 arguments 与已累计分片冲突", + pending.index + ))); + } + } + if delta.is_done { + pending.is_done = true; + } + + let has_material_update = delta.id.is_some() + || delta.name.is_some() + || !arguments_delta.is_empty() + || delta.is_done; + Ok(has_material_update.then(|| LlmToolCallDelta { + index: pending.index, + id: delta.id, + name: delta.name, + arguments_delta, + is_done: delta.is_done, + })) + } + + fn resolve_position(&self, delta: &ParsedToolCallDelta) -> Result, LlmError> { + if let Some(index) = delta.index { + if let Some(id) = delta.id.as_deref() + && self + .pending + .iter() + .any(|pending| pending.index != index && pending.id.as_deref() == Some(id)) + { + return Err(tool_call_protocol_error(format!( + "tool call id={id} 与 index={index} 指向不同调用" + ))); + } + return Ok(self + .pending + .iter() + .position(|pending| pending.index == index)); + } + + if let Some(id) = delta.id.as_deref() + && let Some(position) = self + .pending + .iter() + .position(|pending| pending.id.as_deref() == Some(id)) + { + return Ok(Some(position)); + } + + match self.pending.len() { + 0 => Ok(None), + 1 => Ok(Some(0)), + _ => Err(tool_call_protocol_error( + "tool call 分片缺少可唯一定位的 index 或 id", + )), + } + } + + fn next_available_index(&self) -> usize { + self.pending + .iter() + .map(|pending| pending.index) + .max() + .map(|index| index.saturating_add(1)) + .unwrap_or(0) + } + + fn mark_all_done(&mut self) -> Vec { + let mut completed = self + .pending + .iter_mut() + .filter_map(|pending| { + if pending.is_done { + return None; + } + pending.is_done = true; + Some(LlmToolCallDelta { + index: pending.index, + id: pending.id.clone(), + name: pending.name.clone(), + arguments_delta: String::new(), + is_done: true, + }) + }) + .collect::>(); + completed.sort_by_key(|delta| delta.index); + completed + } + + fn has_complete_tool_calls(&self) -> bool { + !self.pending.is_empty() + && self.pending.iter().all(|pending| { + pending.is_done + && pending + .id + .as_deref() + .is_some_and(|id| !id.trim().is_empty()) + && pending + .name + .as_deref() + .is_some_and(|name| !name.trim().is_empty()) + }) + } + + fn finalize(&self) -> Result, LlmError> { + let mut ordered = self.pending.iter().collect::>(); + ordered.sort_by_key(|pending| pending.index); + ordered + .into_iter() + .map(|pending| { + let id = pending + .id + .as_deref() + .filter(|id| !id.trim().is_empty()) + .ok_or_else(|| { + tool_call_protocol_error(format!( + "tool call index={} 缺少 id", + pending.index + )) + })?; + let name = pending + .name + .as_deref() + .filter(|name| !name.trim().is_empty()) + .ok_or_else(|| { + tool_call_protocol_error(format!( + "tool call index={} 缺少 name", + pending.index + )) + })?; + Ok(LlmToolCall { + id: id.to_string(), + name: name.to_string(), + arguments: pending.arguments.clone(), + }) + }) + .collect() + } +} + +fn merge_tool_call_identity( + current: &mut Option, + incoming: Option<&str>, + field: &str, + index: usize, +) -> Result<(), LlmError> { + let Some(incoming) = incoming else { + return Ok(()); + }; + if let Some(current) = current { + if current != incoming { + return Err(tool_call_protocol_error(format!( + "tool call index={index} 的 {field} 发生冲突" + ))); + } + } else { + *current = Some(incoming.to_string()); + } + Ok(()) +} + +fn tool_call_protocol_error(message: impl Into) -> LlmError { + LlmError::Deserialize(format!("解析 LLM 流式 tool call 失败:{}", message.into())) +} + fn consume_stream_parser_result( result: Result, SseEventDrainError>, accumulated_text: &mut String, finish_reason: &mut Option, usage: &mut Option, + tool_call_accumulator: &mut ToolCallAccumulator, emit_finish_only_delta: bool, on_delta: &mut F, ) -> Result @@ -1579,9 +1878,10 @@ where accumulated_text, finish_reason, usage, + tool_call_accumulator, emit_finish_only_delta, on_delta, - ); + )?; if stream_terminated { return Ok(true); @@ -1590,6 +1890,7 @@ where if retain_completed_stream_after_tail_error( accumulated_text.as_str(), finish_reason, + tool_call_accumulator, "parse_stream_failed", &error, ) { @@ -1604,6 +1905,7 @@ where fn retain_completed_stream_after_tail_error( accumulated_text: &str, finish_reason: &Option, + tool_call_accumulator: &ToolCallAccumulator, stage: &str, error: &LlmError, ) -> bool { @@ -1614,8 +1916,9 @@ fn retain_completed_stream_after_tail_error( | LlmErrorKind::Transport | LlmErrorKind::Deserialize ); - let retain_response = - !accumulated_text.trim().is_empty() && finish_reason.is_some() && is_tolerable_tail_error; + let has_complete_output = + !accumulated_text.trim().is_empty() || tool_call_accumulator.has_complete_tool_calls(); + let retain_response = has_complete_output && finish_reason.is_some() && is_tolerable_tail_error; if retain_response { warn!( @@ -1632,9 +1935,10 @@ fn consume_stream_events( accumulated_text: &mut String, finish_reason: &mut Option, usage: &mut Option, + tool_call_accumulator: &mut ToolCallAccumulator, emit_finish_only_delta: bool, on_delta: &mut F, -) -> bool +) -> Result where F: FnMut(&LlmStreamDelta), { @@ -1643,6 +1947,7 @@ where delta_text, finish_reason: event_finish_reason, usage: event_usage, + tool_call_deltas: parsed_tool_call_deltas, is_terminal, } = event; @@ -1656,31 +1961,39 @@ where accumulated_text.push_str(delta_text.as_str()); } + let mut tool_call_deltas = tool_call_accumulator.apply(parsed_tool_call_deltas)?; + if event_finish_reason.is_some() || is_terminal { + tool_call_deltas.extend(tool_call_accumulator.mark_all_done()); + } + let has_tool_call_deltas = !tool_call_deltas.is_empty(); + if let Some(event_finish_reason) = event_finish_reason { *finish_reason = Some(event_finish_reason.clone()); - if has_delta || emit_finish_only_delta { + if has_delta || has_tool_call_deltas || emit_finish_only_delta { let update = LlmStreamDelta { accumulated_text: accumulated_text.clone(), delta_text, finish_reason: Some(event_finish_reason), + tool_call_deltas, }; on_delta(&update); } - } else if has_delta { + } else if has_delta || has_tool_call_deltas { let update = LlmStreamDelta { accumulated_text: accumulated_text.clone(), delta_text, finish_reason: None, + tool_call_deltas, }; on_delta(&update); } if is_terminal { - return true; + return Ok(true); } } - false + Ok(false) } fn normalize_non_empty(value: String, error_message: &str) -> Result { @@ -2083,7 +2396,7 @@ fn parse_chat_completions_response( .unwrap_or_default() .trim() .to_string(); - let tool_calls = extract_chat_tool_calls(first_choice); + let tool_calls = extract_chat_tool_calls(first_choice)?; if content.is_empty() && tool_calls.is_empty() { return Err(LlmError::EmptyResponse); @@ -2227,8 +2540,8 @@ fn extract_message_text(choice: &ChatCompletionsChoice) -> Option { }) } -fn extract_chat_tool_calls(choice: &ChatCompletionsChoice) -> Vec { - choice +fn extract_chat_tool_calls(choice: &ChatCompletionsChoice) -> Result, LlmError> { + let tool_calls = choice .message .as_ref() .and_then(|message| message.tool_calls.as_deref()) @@ -2239,12 +2552,79 @@ fn extract_chat_tool_calls(choice: &ChatCompletionsChoice) -> Vec { .as_ref() .and_then(|message| message.tool_calls.as_deref()) }) + .unwrap_or_default(); + + tool_calls + .iter() + .enumerate() + .map(|(position, tool_call)| { + let index = tool_call.index.unwrap_or(position); + let id = tool_call + .id + .as_deref() + .filter(|id| !id.trim().is_empty()) + .ok_or_else(|| { + LlmError::Deserialize(format!( + "解析 LLM Chat tool call 失败:index={index} 缺少 id" + )) + })?; + let function = tool_call.function.as_ref().ok_or_else(|| { + LlmError::Deserialize(format!( + "解析 LLM Chat tool call 失败:index={index} 缺少 function" + )) + })?; + let name = function + .name + .as_deref() + .filter(|name| !name.trim().is_empty()) + .ok_or_else(|| { + LlmError::Deserialize(format!( + "解析 LLM Chat tool call 失败:index={index} 缺少 function.name" + )) + })?; + let arguments = function.arguments.as_deref().ok_or_else(|| { + LlmError::Deserialize(format!( + "解析 LLM Chat tool call 失败:index={index} 缺少 function.arguments" + )) + })?; + Ok(LlmToolCall { + id: id.to_string(), + name: name.to_string(), + arguments: arguments.to_string(), + }) + }) + .collect() +} + +fn extract_chat_stream_tool_call_deltas( + choice: &ChatCompletionsChoice, +) -> Vec { + choice + .delta + .as_ref() + .and_then(|message| message.tool_calls.as_deref()) .unwrap_or_default() .iter() - .map(|tool_call| LlmToolCall { - id: tool_call.id.clone(), - name: tool_call.function.name.clone(), - arguments: tool_call.function.arguments.clone(), + .map(|tool_call| { + let function = tool_call.function.as_ref(); + ParsedToolCallDelta { + index: tool_call.index, + id: tool_call + .id + .as_deref() + .filter(|id| !id.trim().is_empty()) + .map(str::to_string), + name: function + .and_then(|function| function.name.as_deref()) + .filter(|name| !name.trim().is_empty()) + .map(str::to_string), + arguments_delta: function + .and_then(|function| function.arguments.as_deref()) + .unwrap_or_default() + .to_string(), + completed_arguments: None, + is_done: false, + } }) .collect() } @@ -2319,6 +2699,7 @@ fn parse_sse_event_block( delta_text: None, finish_reason: None, usage: None, + tool_call_deltas: Vec::new(), is_terminal: true, })) } else { @@ -2355,6 +2736,7 @@ fn parse_sse_event_block( delta_text: None, finish_reason: None, usage: Some(usage), + tool_call_deltas: Vec::new(), is_terminal: false, })) } else { @@ -2368,6 +2750,7 @@ fn parse_sse_event_block( delta_text: extract_message_text(first_choice), finish_reason: first_choice.finish_reason.clone(), usage: parsed.usage, + tool_call_deltas: extract_chat_stream_tool_call_deltas(first_choice), is_terminal: false, })) } @@ -2389,12 +2772,14 @@ fn parse_responses_sse_event(data: &str) -> Result, Ll .map(str::to_string), finish_reason: None, usage: None, + tool_call_deltas: Vec::new(), is_terminal: false, })), "response.completed" => Ok(Some(ParsedStreamEvent { delta_text: None, finish_reason: Some("completed".to_string()), usage: None, + tool_call_deltas: Vec::new(), is_terminal: false, })), "response.failed" | "error" => { @@ -2441,6 +2826,7 @@ fn parse_anthropic_sse_event(data: &str) -> Result, Ll .map(str::to_string), finish_reason: None, usage: None, + tool_call_deltas: Vec::new(), is_terminal: false, })) } @@ -2452,6 +2838,7 @@ fn parse_anthropic_sse_event(data: &str) -> Result, Ll .and_then(serde_json::Value::as_str) .map(str::to_string), usage: None, + tool_call_deltas: Vec::new(), is_terminal: false, })), // message_stop 只是流终止信号;真正的 stop_reason 已由 message_delta 提供, @@ -2759,6 +3146,217 @@ mod tests { )); } + fn parsed_tool_call_delta( + index: Option, + id: Option<&str>, + name: Option<&str>, + arguments_delta: &str, + completed_arguments: Option<&str>, + is_done: bool, + ) -> ParsedToolCallDelta { + ParsedToolCallDelta { + index, + id: id.map(str::to_string), + name: name.map(str::to_string), + arguments_delta: arguments_delta.to_string(), + completed_arguments: completed_arguments.map(str::to_string), + is_done, + } + } + + #[test] + fn tool_call_accumulator_assembles_late_identity_and_argument_fragments() { + let mut accumulator = ToolCallAccumulator::default(); + let first = accumulator + .apply(vec![parsed_tool_call_delta( + Some(0), + Some("call_project_index"), + None, + "{\"path\":\"", + None, + false, + )]) + .expect("first tool call fragment should apply"); + let second = accumulator + .apply(vec![parsed_tool_call_delta( + Some(0), + None, + Some("project_index"), + r#"game"}"#, + None, + false, + )]) + .expect("late tool call name should apply"); + let completed = accumulator.mark_all_done(); + + assert_eq!(first[0].index, 0); + assert_eq!(first[0].id.as_deref(), Some("call_project_index")); + assert_eq!(second[0].name.as_deref(), Some("project_index")); + assert_eq!(completed.len(), 1); + assert!(completed[0].is_done); + assert_eq!( + accumulator.finalize().expect("tool call should finalize"), + vec![LlmToolCall { + id: "call_project_index".to_string(), + name: "project_index".to_string(), + arguments: r#"{"path":"game"}"#.to_string(), + }] + ); + } + + #[test] + fn tool_call_accumulator_keeps_interleaved_calls_in_provider_index_order() { + let mut accumulator = ToolCallAccumulator::default(); + accumulator + .apply(vec![ + parsed_tool_call_delta( + Some(1), + Some("call_b"), + Some("second"), + r#"{"value":"#, + None, + false, + ), + parsed_tool_call_delta( + Some(0), + Some("call_a"), + Some("first"), + r#"{"value":"#, + None, + false, + ), + ]) + .expect("initial interleaved fragments should apply"); + accumulator + .apply(vec![ + parsed_tool_call_delta(Some(1), None, None, "2}", None, false), + parsed_tool_call_delta(Some(0), None, None, "1}", None, false), + ]) + .expect("remaining interleaved fragments should apply"); + accumulator.mark_all_done(); + + assert_eq!( + accumulator.finalize().expect("calls should finalize"), + vec![ + LlmToolCall { + id: "call_a".to_string(), + name: "first".to_string(), + arguments: r#"{"value":1}"#.to_string(), + }, + LlmToolCall { + id: "call_b".to_string(), + name: "second".to_string(), + arguments: r#"{"value":2}"#.to_string(), + }, + ] + ); + } + + #[test] + fn tool_call_accumulator_validates_completed_arguments_and_identity() { + let mut accumulator = ToolCallAccumulator::default(); + accumulator + .apply(vec![parsed_tool_call_delta( + Some(0), + Some("call_a"), + Some("first"), + r#"{"value":"#, + None, + false, + )]) + .expect("initial fragment should apply"); + accumulator + .apply(vec![parsed_tool_call_delta( + Some(0), + None, + None, + "1}", + Some(r#"{"value":1}"#), + true, + )]) + .expect("matching completed arguments should apply"); + assert!(accumulator.has_complete_tool_calls()); + + let mut conflicting_arguments = ToolCallAccumulator::default(); + conflicting_arguments + .apply(vec![parsed_tool_call_delta( + Some(0), + Some("call_a"), + Some("first"), + "{}", + None, + false, + )]) + .expect("initial arguments should apply"); + let error = conflicting_arguments + .apply(vec![parsed_tool_call_delta( + Some(0), + None, + None, + "", + Some(r#"{"value":1}"#), + true, + )]) + .expect_err("conflicting completed arguments should fail"); + assert!(matches!(error, LlmError::Deserialize(_))); + + let mut conflicting_identity = ToolCallAccumulator::default(); + conflicting_identity + .apply(vec![parsed_tool_call_delta( + Some(0), + Some("call_a"), + Some("first"), + "", + None, + false, + )]) + .expect("initial identity should apply"); + let error = conflicting_identity + .apply(vec![parsed_tool_call_delta( + Some(0), + Some("call_b"), + None, + "", + None, + false, + )]) + .expect_err("conflicting id should fail"); + assert!(matches!(error, LlmError::Deserialize(_))); + } + + #[test] + fn tool_call_accumulator_rejects_incomplete_and_ambiguous_calls() { + let mut incomplete = ToolCallAccumulator::default(); + incomplete + .apply(vec![parsed_tool_call_delta( + Some(0), + Some("call_a"), + None, + "{}", + None, + false, + )]) + .expect("incomplete call may remain pending during streaming"); + let error = incomplete + .finalize() + .expect_err("missing name should fail finalization"); + assert!(matches!(error, LlmError::Deserialize(_))); + + let mut ambiguous = ToolCallAccumulator::default(); + ambiguous + .apply(vec![ + parsed_tool_call_delta(Some(0), Some("call_a"), Some("a"), "", None, false), + parsed_tool_call_delta(Some(1), Some("call_b"), Some("b"), "", None, false), + ]) + .expect("two indexed calls should apply"); + let error = ambiguous + .apply(vec![parsed_tool_call_delta( + None, None, None, "{}", None, false, + )]) + .expect_err("unaddressed fragment with multiple calls should fail"); + assert!(matches!(error, LlmError::Deserialize(_))); + } + #[test] fn sse_parser_handles_split_chunks_and_done_marker() { let mut parser = OpenAiCompatibleSseParser::new(LlmApiKind::OpenAiChat); @@ -2778,6 +3376,35 @@ mod tests { assert!(events_b[1].is_terminal); } + #[test] + fn chat_sse_parser_emits_tool_call_fragments() { + let mut parser = OpenAiCompatibleSseParser::new(LlmApiKind::OpenAiChat); + let events = parser + .push_chunk(concat!( + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_project_index\",\"type\":\"function\",\"function\":{\"name\":\"project_index\",\"arguments\":\"{\\\"path\\\":\\\"\"}}]}}]}\n\n", + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"game\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n", + )) + .expect("chat tool call stream should parse"); + + assert_eq!(events.len(), 2); + assert_eq!(events[0].tool_call_deltas.len(), 1); + assert_eq!(events[0].tool_call_deltas[0].index, Some(0)); + assert_eq!( + events[0].tool_call_deltas[0].id.as_deref(), + Some("call_project_index") + ); + assert_eq!( + events[0].tool_call_deltas[0].name.as_deref(), + Some("project_index") + ); + assert_eq!( + events[0].tool_call_deltas[0].arguments_delta, + "{\"path\":\"" + ); + assert_eq!(events[1].tool_call_deltas[0].arguments_delta, r#"game"}"#); + assert_eq!(events[1].finish_reason.as_deref(), Some("tool_calls")); + } + #[test] fn sse_parser_preserves_events_before_malformed_tail_in_same_chunk() { let mut parser = OpenAiCompatibleSseParser::new(LlmApiKind::OpenAiChat); @@ -3495,6 +4122,211 @@ mod tests { ); } + #[tokio::test] + async fn stream_run_accumulates_interleaved_chat_tool_calls_without_text() { + let server_url = spawn_mock_server(vec![MockResponse { + status_line: "200 OK", + content_type: "text/event-stream; charset=utf-8", + body: concat!( + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"call_asset_list\",\"type\":\"function\",\"function\":{\"name\":\"asset_list\",\"arguments\":\"{\\\"limit\\\":\"}}]}}]}\n\n", + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_project_index\",\"type\":\"function\",\"function\":{\"name\":\"project_index\",\"arguments\":\"{\\\"path\\\":\\\"\"}}]}}]}\n\n", + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"arguments\":\"2}\"}}]}}]}\n\n", + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"game\\\"}\"}}]}}]}\n\n", + "data: {\"choices\":[{\"finish_reason\":\"tool_calls\"}]}\n\n", + "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":7,\"total_tokens\":12}}\n\n", + "data: [DONE]\n\n" + ) + .to_string(), + extra_headers: vec![("x-request-id", "req_chat_tools_01")], + }]); + + let client = build_test_client(server_url, 0); + let mut updates = Vec::new(); + let response = client + .stream_run( + LlmRunRequest::single_turn("系统", "检查项目") + .with_openai_chat() + .with_function_tools(vec![ + LlmFunctionTool::new( + "project_index", + "索引项目", + serde_json::json!({ "type": "object" }), + ), + LlmFunctionTool::new( + "asset_list", + "列出素材", + serde_json::json!({ "type": "object" }), + ), + ]), + |delta| updates.push(delta.clone()), + ) + .await + .expect("tool-call-only chat stream should succeed"); + + assert_eq!(response.text, ""); + assert_eq!(response.finish_reason.as_deref(), Some("tool_calls")); + assert_eq!(response.response_id.as_deref(), Some("req_chat_tools_01")); + assert_eq!( + response.tool_calls, + vec![ + LlmToolCall { + id: "call_project_index".to_string(), + name: "project_index".to_string(), + arguments: r#"{"path":"game"}"#.to_string(), + }, + LlmToolCall { + id: "call_asset_list".to_string(), + name: "asset_list".to_string(), + arguments: r#"{"limit":2}"#.to_string(), + }, + ] + ); + assert_eq!( + response.usage, + Some(LlmTokenUsage { + prompt_tokens: 5, + completion_tokens: 7, + total_tokens: 12, + }) + ); + assert_eq!(updates.len(), 5); + assert!(updates.iter().all(|update| update.delta_text.is_empty())); + assert!( + updates + .iter() + .all(|update| update.accumulated_text.is_empty()) + ); + assert_eq!(updates[0].tool_call_deltas[0].index, 1); + assert_eq!(updates[1].tool_call_deltas[0].index, 0); + assert_eq!(updates[4].tool_call_deltas.len(), 2); + assert_eq!( + updates[4] + .tool_call_deltas + .iter() + .map(|delta| delta.index) + .collect::>(), + vec![0, 1] + ); + assert!( + updates[4] + .tool_call_deltas + .iter() + .all(|delta| delta.is_done) + ); + } + + #[tokio::test] + async fn stream_run_emits_chat_text_and_tool_call_in_one_update() { + let server_url = spawn_mock_server(vec![MockResponse { + status_line: "200 OK", + content_type: "text/event-stream; charset=utf-8", + body: concat!( + "data: {\"choices\":[{\"delta\":{\"content\":\"先检查项目。\",\"tool_calls\":[{\"index\":0,\"id\":\"call_project_index\",\"type\":\"function\",\"function\":{\"name\":\"project_index\",\"arguments\":\"{}\"}}]}}]}\n\n", + "data: {\"choices\":[{\"finish_reason\":\"tool_calls\"}]}\n\n", + "data: [DONE]\n\n" + ) + .to_string(), + extra_headers: Vec::new(), + }]); + + let client = build_test_client(server_url, 0); + let mut updates = Vec::new(); + let response = client + .stream_run( + LlmRunRequest::single_turn("系统", "检查项目").with_openai_chat(), + |delta| updates.push(delta.clone()), + ) + .await + .expect("mixed text and tool call stream should succeed"); + + assert_eq!(response.text, "先检查项目。"); + assert_eq!(response.tool_calls.len(), 1); + assert_eq!(updates.len(), 2); + assert_eq!(updates[0].delta_text, "先检查项目。"); + assert_eq!(updates[0].tool_call_deltas.len(), 1); + assert!(!updates[0].tool_call_deltas[0].is_done); + assert_eq!(updates[1].delta_text, ""); + assert!(updates[1].tool_call_deltas[0].is_done); + } + + #[tokio::test] + async fn stream_run_keeps_completed_tool_call_before_malformed_tail() { + let server_url = spawn_mock_server(vec![MockResponse { + status_line: "200 OK", + content_type: "text/event-stream; charset=utf-8", + body: concat!( + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_noop\",\"type\":\"function\",\"function\":{\"name\":\"noop\",\"arguments\":\"{}\"}}]}}]}\n\n", + "data: {\"choices\":[{\"finish_reason\":\"tool_calls\"}]}\n\n", + "data: {\"choices\":[malformed]}\n\n" + ) + .to_string(), + extra_headers: Vec::new(), + }]); + + let client = build_test_client(server_url, 0); + let response = client + .stream_run( + LlmRunRequest::single_turn("系统", "执行空操作").with_openai_chat(), + |_| {}, + ) + .await + .expect("completed tool call should survive malformed SSE tail"); + + assert_eq!(response.text, ""); + assert_eq!(response.finish_reason.as_deref(), Some("tool_calls")); + assert_eq!( + response.tool_calls, + vec![LlmToolCall { + id: "call_noop".to_string(), + name: "noop".to_string(), + arguments: "{}".to_string(), + }] + ); + } + + #[tokio::test] + async fn stream_run_rejects_chat_tool_call_missing_name() { + let log_dir = std::env::temp_dir().join(format!( + "platform-llm-incomplete-tool-call-test-{}", + build_llm_raw_log_prefix("missing_name") + )); + let server_url = spawn_mock_server(vec![MockResponse { + status_line: "200 OK", + content_type: "text/event-stream; charset=utf-8", + body: concat!( + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_incomplete\",\"type\":\"function\",\"function\":{\"arguments\":\"{}\"}}]}}]}\n\n", + "data: {\"choices\":[{\"finish_reason\":\"tool_calls\"}]}\n\n", + "data: [DONE]\n\n" + ) + .to_string(), + extra_headers: Vec::new(), + }]); + + let config = LlmConfig::new( + LlmProvider::Ark, + server_url, + "test-key".to_string(), + "test-model".to_string(), + DEFAULT_REQUEST_TIMEOUT_MS, + 0, + 1, + ) + .expect("config should be valid") + .with_raw_log_dir_override(log_dir.clone()); + let client = LlmClient::new(config).expect("client should be created"); + let error = client + .stream_run( + LlmRunRequest::single_turn("系统", "执行工具").with_openai_chat(), + |_| {}, + ) + .await + .expect_err("incomplete tool call identity should fail closed"); + + assert!(matches!(error, LlmError::Deserialize(_))); + assert!(error.to_string().contains("缺少 name")); + fs::remove_dir_all(log_dir).expect("log dir should be removed"); + } + #[tokio::test] async fn stream_run_keeps_completed_chat_response_before_malformed_tail() { let server_url = spawn_mock_server(vec![MockResponse {