diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 9cc860f53..4e3a3d326 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -277,7 +277,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 工具事件的协议槽位缺失时必须失败关闭,不得跳过也不得按事件内位置猜测:槽位是并行分片唯一的归并依据。跳过会静默丢掉整个调用——只剩一个调用时才可能被 `StreamUnavailable` 断言兜住,丢一半毫无察觉;Responses 的整体终态原因是 `completed` / `incomplete`,也不会触发只识别 `tool_use` / `tool_calls` 的那道断言。猜测则会把两个不同调用合并成一个混合体(后者的 id / name 覆盖前者,arguments 被拼接)。判定字段为 Chat 的 `delta.tool_calls[].index`、Responses 的 `output_index`、Anthropic 的 content block `index`。该约束只覆盖工具事件,纯文本增量不依赖槽位,不受影响。 -槽位存在但被两个不同调用共用时同样必须失败关闭:同一槽位的 id 与函数名只允许**从缺失变为已知**或**重复同一个值**,出现互不相同的非空值即返回 `Deserialize`。“覆盖身份、追加参数”并不自洽——前一个调用参数为空时拼接结果就是后一个调用的合法 JSON,参数完整性检查兜不住,调用方只会拿到后一个工具,前一个静默消失;Responses 的权威完整参数还会整段覆盖,产出“前一个调用的身份配后一个调用的参数”。两者都会原样交给 Runtime 执行。已知触发路径有两条:兼容网关把 Chat 的 `index` 恒置 0,以及 Responses 的 `response.completed` 回退按 `output[]` 下标重建槽位时与流式 `output_index` 基准错位(例如 completed 载荷省略 reasoning item)。id 必须与函数名一同参与判定——并行调用同一个工具是最常见的并行场景,此时函数名相同,只有 id 能区分。空白身份按缺失跳过、不算冲突:部分兼容网关在续传分片里回发完整 `function` 对象且 `name` / `id` 为空串,按“不等即冲突”会把它们整批误杀,这也与归一层的空白即缺失约定一致。 +槽位存在但被两个不同调用共用时同样必须失败关闭:同一槽位的 id 与函数名只允许**从缺失变为已知**或**重复同一个值**,出现互不相同的非空值即返回 `Deserialize`。“覆盖身份、追加参数”并不自洽——前一个调用参数为空时拼接结果就是后一个调用的合法 JSON,参数完整性检查兜不住,调用方只会拿到后一个工具,前一个静默消失;Responses 的权威完整参数还会整段覆盖,产出“前一个调用的身份配后一个调用的参数”。两者都会原样交给 Runtime 执行。已知触发路径有两条:兼容网关把 Chat 的 `index` 恒置 0,以及 Responses 的 `response.completed` 回退按 `output[]` 下标重建槽位时与流式 `output_index` 基准错位(例如 completed 载荷省略 reasoning item)。id 必须与函数名一同参与判定——并行调用同一个工具是最常见的并行场景,此时函数名相同,只有 id 能区分。槽位本身只是传输层的归并键,真正的调用身份是 id:同一个非空 id 落到两个槽位只可能是上游在不同事件里用了不同的槽位基准(Responses 的终态载荷按 `output[]` 数组下标重建槽位,网关若在快照里省掉此前占用过某个 `output_index` 的 reasoning / message 条目就会与增量事件错位),此时必须按 id 归位到已有槽位,不能新建。按新槽位新建会产出两条 id 完全相同的重复调用,而且因为落进的是空槽位、同槽位冲突检测根本不触发,全程无告警;下游按 id 唯一性校验的消费方会把这种合法响应误判成协议错误并空耗格式修复配额,不做该校验的消费方则会重复执行同一个工具。按 id 归位不放松身份校验——并进已有槽位后函数名不一致仍照常失败关闭。空白身份按缺失跳过、不算冲突:部分兼容网关在续传分片里回发完整 `function` 对象且 `name` / `id` 为空串,按“不等即冲突”会把它们整批误杀,这也与归一层的空白即缺失约定一致。 工具参数字段必须区分“缺失”与“类型非法”:字段不存在或为 `null` 是合法缺省(零参函数),存在但不是字符串一律返回 `Deserialize`。把两者混同的写法(`as_str` 遇到非字符串返回 `None`)会让参数被当成缺省,归一层再补成 `{}`,于是一个身份完整、参数是合法 JSON 的调用直接交给下游执行,既有校验全都拦不住——零参函数的 `{}` 与“参数类型错了所以变成 `{}`”在归一层无法区分。涉及 Responses 的 `response.function_call_arguments.delta` 的 `delta`、`.done` 的 `arguments`、终态载荷 `output[].arguments`,以及 Anthropic `input_json_delta` 的 `partial_json`;Chat 走强类型 DTO,同样输入本就反序列化失败,本规则是把三协议口径拉齐。该约束只覆盖参数字段:id 与函数名即使类型不对也只会退化成缺失,随后被归一层按缺 id / 缺函数名拒绝,本来就是失败关闭。 diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 63a774f85..423154400 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -2400,6 +2400,13 @@ function assertAiGameCreatorShellUserDevBoundary() { ) { throw new Error('AI game creator preview panels must stay inside the dev-only pane'); } + // 上面几条只约束 DeveloperProjectPanels 自身的 iframe 数量和挂载位置,管不到 App.tsx + // 直接内嵌 iframe 的情况——预览必须一律委托给客户端工作台,外壳自己不持有预览框。 + if ([...aiGameCreatorShellAppSource.matchAll(/ Result<(), LlmError> { - if !self - .tool_calls - .iter() - .any(|pending| pending.slot == fragment.slot) - { + // 槽位只是传输层的归并键,真正的身份是 id。同一个 id 落到两个槽位只可能是上游在 + // 不同事件里用了不同的槽位基准:Responses 的终态载荷按 output[] 数组下标重建槽位, + // 网关若在快照里省掉此前占用过某个 output_index 的条目(reasoning / message), + // 就会与增量事件的 output_index 错位。此时若按新槽位新建,会产出两条 id 完全相同的 + // 重复调用——而且因为落进的是空槽位,merge_tool_identity 的冲突检测(只在同槽位 + // 已有身份时比对)根本不会触发,全程无告警。所以先按 id 归位。 + // + // 名字冲突仍由 merge_tool_identity 拦截:并进去之后两侧函数名不同会照常失败关闭。 + let slot = fragment + .id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + .and_then(|id| { + self.tool_calls + .iter() + .find(|pending| { + pending.id.as_deref().map(str::trim) == Some(id) + && pending.slot != fragment.slot + }) + .map(|pending| pending.slot) + }) + .unwrap_or(fragment.slot); + + if !self.tool_calls.iter().any(|pending| pending.slot == slot) { self.tool_calls.push(PendingToolCall { - slot: fragment.slot, + slot, id: None, name: None, arguments: String::new(), @@ -823,12 +843,12 @@ impl StreamAccumulation { let entry = self .tool_calls .iter_mut() - .find(|pending| pending.slot == fragment.slot) + .find(|pending| pending.slot == slot) .expect("slot was just ensured"); // 身份先校验:冲突时连参数都不能并进去,累加状态已经不可信。 - merge_tool_identity(&mut entry.id, fragment.id, "id", fragment.slot)?; - merge_tool_identity(&mut entry.name, fragment.name, "函数名", fragment.slot)?; + merge_tool_identity(&mut entry.id, fragment.id, "id", slot)?; + merge_tool_identity(&mut entry.name, fragment.name, "函数名", slot)?; if let Some(delta) = fragment.arguments_delta { entry.arguments.push_str(delta.as_str()); } @@ -1580,7 +1600,7 @@ impl LlmClient { &self.config, &request, true, - 1, + attempt, "stream_tool_calls_truncated", parser.raw_text().as_str(), ); @@ -1603,7 +1623,7 @@ impl LlmClient { &self.config, &request, true, - 1, + attempt, "parse_stream_tool_calls_failed", parser.raw_text().as_str(), ); @@ -1621,7 +1641,7 @@ impl LlmClient { &self.config, &request, true, - 1, + attempt, "stream_tool_calls_incomplete_finish", parser.raw_text().as_str(), ); @@ -1640,7 +1660,7 @@ impl LlmClient { &self.config, &request, true, - 1, + attempt, "stream_tool_calls_missing", parser.raw_text().as_str(), ); @@ -4949,6 +4969,91 @@ mod tests { ); } + #[tokio::test] + async fn stream_run_merges_completed_event_rebased_slot_by_tool_call_id() { + // completed 载荷按 output[] 数组下标重建槽位,网关若省掉此前占用 output_index=0 的 + // reasoning 条目,重建出的下标 0 就与增量事件用的 output_index=1 错位。落进的是空 + // 槽位,同槽位身份冲突检测不会触发,旧实现因此静默产出两条 id 完全相同的调用。 + let server_url = spawn_mock_server(vec![MockResponse { + status_line: "200 OK", + content_type: "text/event-stream; charset=utf-8", + body: concat!( + r#"data: {"type":"response.output_item.added","item":{"id":"fc_0","type":"function_call","call_id":"call_a","name":"get_weather"},"output_index":1}"#, "\n\n", + r#"data: {"type":"response.function_call_arguments.done","item_id":"fc_0","output_index":1,"arguments":"{\"city\":\"杭州\"}"}"#, "\n\n", + r#"data: {"type":"response.completed","response":{"output":[{"id":"fc_0","type":"function_call","call_id":"call_a","name":"get_weather","arguments":"{\"city\":\"杭州\"}"}]}}"#, "\n\n" + ) + .to_string(), + extra_headers: Vec::new(), + }]); + + let response = build_test_client(server_url, 0) + .stream_run(weather_tool_request(LlmApiKind::OpenAiResponses), |_| {}) + .await + .expect("槽位基准错位时应按 id 归并而不是新建"); + + assert_eq!( + response.tool_calls, + vec![LlmToolCall { + id: "call_a".to_string(), + name: "get_weather".to_string(), + arguments: r#"{"city":"杭州"}"#.to_string(), + }] + ); + } + + #[tokio::test] + async fn stream_run_rejects_rebased_slot_when_tool_call_name_disagrees() { + // 按 id 归并不放松身份校验:并进去之后函数名不一致仍须失败关闭, + // 否则会拿一个调用的 id 配另一个调用的名字。 + expect_stream_slot_identity_conflict_error( + LlmApiKind::OpenAiResponses, + concat!( + r#"data: {"type":"response.output_item.added","item":{"id":"fc_0","type":"function_call","call_id":"call_a","name":"get_weather"},"output_index":1}"#, "\n\n", + r#"data: {"type":"response.completed","response":{"output":[{"id":"fc_0","type":"function_call","call_id":"call_a","name":"get_air_quality","arguments":"{\"city\":\"杭州\"}"}]}}"#, "\n\n" + ), + ) + .await; + } + + #[tokio::test] + async fn stream_run_keeps_parallel_responses_tool_calls_with_distinct_ids() { + // 作用域守卫:按 id 归并只在 id 相同时生效,不同 id 的并行调用必须保持两条。 + let server_url = spawn_mock_server(vec![MockResponse { + status_line: "200 OK", + content_type: "text/event-stream; charset=utf-8", + body: concat!( + r#"data: {"type":"response.output_item.added","item":{"id":"fc_0","type":"function_call","call_id":"call_a","name":"get_weather"},"output_index":0}"#, "\n\n", + r#"data: {"type":"response.function_call_arguments.done","item_id":"fc_0","output_index":0,"arguments":"{\"city\":\"杭州\"}"}"#, "\n\n", + r#"data: {"type":"response.output_item.added","item":{"id":"fc_1","type":"function_call","call_id":"call_b","name":"get_air_quality"},"output_index":1}"#, "\n\n", + r#"data: {"type":"response.function_call_arguments.done","item_id":"fc_1","output_index":1,"arguments":"{\"city\":\"苏州\"}"}"#, "\n\n", + r#"data: {"type":"response.completed"}"#, "\n\n" + ) + .to_string(), + extra_headers: Vec::new(), + }]); + + let response = build_test_client(server_url, 0) + .stream_run(weather_tool_request(LlmApiKind::OpenAiResponses), |_| {}) + .await + .expect("不同 id 的并行调用必须各自保留"); + + assert_eq!( + response.tool_calls, + vec![ + LlmToolCall { + id: "call_a".to_string(), + name: "get_weather".to_string(), + arguments: r#"{"city":"杭州"}"#.to_string(), + }, + LlmToolCall { + id: "call_b".to_string(), + name: "get_air_quality".to_string(), + arguments: r#"{"city":"苏州"}"#.to_string(), + }, + ] + ); + } + #[tokio::test] async fn stream_run_recovers_responses_tool_calls_from_completed_event_only() { // 只发 completed、不发增量事件的网关也必须能解出工具调用。