修复分支审查发现的三处问题
Project CI / Repository checks (pull_request) Failing after 59s
Project CI / Backend tests (pull_request) Successful in 3m24s
Project CI / Frontend tests (pull_request) Successful in 3m47s
Project CI / Native shell tests (pull_request) Successful in 9m59s

一、流式工具槽位按 id 归位,消除终态载荷错位产生的重复调用

extract_responses_completed_tool_fragments 用 output[] 数组下标当槽位,而增量
路径用事件自带的 output_index。网关若在 completed 快照里省掉此前占用过某个
output_index 的 reasoning / message 条目,两者基准就错位,终态分片会落进一个
从未占用过的空槽位。空槽位上 merge_tool_identity 的 current 为 None、冲突检测
不触发,于是静默产出两条 id 完全相同的调用。

探针实测:output_index=1 的调用 + 只含一个 function_call 的 completed 载荷,
返回 Ok 且 tool_calls 为两条一模一样的 call_a/get_weather,零告警。

这说明 872a2f645 的身份冲突修复是不完整的——它只堵了「撞上已占用槽位」,没堵
「落进空槽位」。改为在 push_tool_fragment 里先按非空 id 归位到已有槽位:槽位只是
传输层归并键,真正的身份是 id。名字冲突仍由 merge_tool_identity 拦截,并进去之后
函数名不一致照常失败关闭。

下游影响:本仓库 agent_native_tools.rs:308 按 call id 唯一性校验,重复即报
CallIdentity 错误,所以现状是合法响应被误判成协议错误、空耗格式修复配额,不是
重复执行。但 platform-llm 是给 module-ai / module-story 等复用的基础 crate,
不能指望每个消费方自己去重,故在 crate 层修。

二、补回 check-native-shells 丢失的 App.tsx iframe 断言

85b9c2f19 合并 codex 时,check-native-shells.mjs 的 assertAiGameCreatorShell-
UserDevBoundary 双方独立重写产生冲突,当时判定「我方是上游的严格超集」并整段取
我方——这个判断是错的。上游有一条我方没有:App.tsx 全文不得直接出现 <iframe>,
预览必须委托给客户端工作台。我方版本只约束 DeveloperProjectPanels 自身的 iframe
数量与挂载位置,管不到外壳自己内嵌预览框。

补回该断言并反向验证:往 App.tsx 塞一个 iframe 后门禁确实报错,移除后通过。

三、流式工具校验失败分支落盘真实 attempt

stream_run 新增的四处工具调用后置校验失败分支(截断门禁、finish_tool_calls
失败、不完整终态拒绝、一致性断言)调用 log_llm_raw_failure 时把 attempt 硬编码成
字面量 1,同函数其余 12 处均传真实 attempt。重试后落盘日志全部标成第一次尝试,
排障时看不出真实次数。

隔离验证:停用按 id 归位后,两条新增用例转红;第三条(不同 id 的并行调用必须保持
两条)两边均绿,它守的是归并不得过头。

platform-llm 106 passed(原 103)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 14:18:10 +00:00
parent c086fb8656
commit 57f355b7b6
3 changed files with 126 additions and 14 deletions
@@ -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 / 缺函数名拒绝,本来就是失败关闭。
+7
View File
@@ -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(/<iframe\b/g)].length !== 0) {
throw new Error(
'AI game creator app shell must delegate preview iframe to the client workbench',
);
}
const clientPreviewFrameCount = [
...aiGameCreatorProjectDevelopmentSource.matchAll(/<iframe\b/g),
+118 -13
View File
@@ -808,13 +808,33 @@ fn merge_tool_identity(
impl StreamAccumulation {
fn push_tool_fragment(&mut self, fragment: ToolCallFragment) -> 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、不发增量事件的网关也必须能解出工具调用。