From d24321d657df651a3d4409a71da23a7c7ff25d11 Mon Sep 17 00:00:00 2001 From: suzmii Date: Thu, 27 Aug 2026 17:08:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=AC=AC=E4=B8=89=E6=96=B9?= =?UTF-8?q?=20Provider=20=E6=B5=81=E5=BC=8F=E5=B7=A5=E5=85=B7=E8=AE=A1?= =?UTF-8?q?=E5=88=92=E8=AF=B7=E6=B1=82=20(#201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 变更摘要 - 当 Provider 配置 `llm.stream=true` 时,Provider tool-plan 请求改为使用 `stream_run`,由后端聚合完整响应;关闭流式时继续使用 `run`。 - 补充 Anthropic 原生 SSE 工具调用、原生最终回复和 OpenAI 响应流测试夹具。 - 新增流式原生函数工具计划回归测试,覆盖请求 `stream=true`、工具调用聚合、respond_to_user 收束和最终回复请求。 - 修复共享 Provider 测试夹具对 `stream=true` 的 Responses / Chat Completions 请求返回正确 SSE,避免时序测试误报 Timeout。 - 修正自主构建截断 scaffold 修复分支的错误前缀匹配,并补齐最终回复失败审计夹具的流式 planning 响应。 - 同步第三方 Provider 兼容性技术说明,记录缺陷 5 已修复。 ## 验证 已通过: - 5 个 Provider handoff/retry/restart 回归用例(逐项运行) - `autonomous_game_build_repairs_truncated_scaffold_into_bounded_patch` - `background_agent_runtime_asset_generation_respects_project_policy` - `background_final_reply_failure_keeps_private_conversation_and_hashes_public_audits` - `npm run ai-game-creator-shell:typecheck` - `npm run check:encoding` - `git diff --check` 说明:Provider handoff 重启用例在并发启动时曾出现一次时序失败,单独串行复跑通过;当前提交已通过 pre-push 检查。`cargo fmt --check` 仍会报告基线其他文件已有格式差异,本 PR 未改动这些无关文件。 Close #186 --------- Co-authored-by: 段舒康 Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/201 Reviewed-by: 段舒康 Co-authored-by: suzmii Co-committed-by: suzmii --- .../src-tauri/src/agent/runtime_driver.rs | 2 +- .../agent/runtime_protocol/provider_retry.rs | 24 +- .../src-tauri/src/tests/mod.rs | 432 ++++++++++++++---- .../src-tauri/src/tests/provider.rs | 114 +++++ .../src-tauri/src/tests/response_stream.rs | 10 +- ...】AGC接第三方Provider的兼容性缺陷-2026-08-19.md | 28 +- 6 files changed, 499 insertions(+), 111 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index babb4ba92..2875c99c7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -668,7 +668,7 @@ pub(super) const AGENT_RUNTIME_AUTONOMOUS_MANIFEST_DAG_WAIT_LIVENESS_ERROR_PREFI pub(super) const AGENT_RUNTIME_AUTONOMOUS_RESPONSE_PLAN_LIVENESS_ERROR_PREFIX: &str = "自主构建专业 Agent 已给出结论但结构化计划仍未完成"; pub(super) const AGENT_RUNTIME_AUTONOMOUS_TRUNCATED_SCAFFOLD_ERROR_PREFIX: &str = - "自主构建完整写入游戏入口(index.html 或 game/index.html)时必须提交闭合且可运行的 HTML"; + "自主构建完整写入 game/index.html 时必须提交闭合且可运行的 HTML"; pub(super) const AGENT_RUNTIME_QUALITY_REVIEW_AGENT_ID: &str = "quality-review"; pub(super) const AGENT_RUNTIME_SUPERVISOR_INITIAL_COLLABORATION_LIVENESS_ERROR_PREFIX: &str = "Project Supervisor 首批协作在首个 planning 窗口内未建立"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs index 05c3d0431..a6983321b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs @@ -1694,7 +1694,12 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_persis &config_path_for_request, ) .map_err(platform_llm::LlmError::InvalidConfig)?; - client.run(request).await + request_game_creator_agent_runtime_provider_llm( + &client, + &llm_for_request, + request, + ) + .await } _ => unreachable!("agent mode is normalized"), } @@ -1704,6 +1709,18 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_persis .await } +async fn request_game_creator_agent_runtime_provider_llm( + client: &platform_llm::LlmClient, + llm: &GameCreatorLlmConfig, + request: platform_llm::LlmRunRequest, +) -> Result { + if llm.stream { + client.stream_run(request, |_| {}).await + } else { + client.run(request).await + } +} + pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transient_retries( root: &Path, provider_snapshot: &AgentRuntimeProviderRequestSnapshot, @@ -1748,9 +1765,8 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transi request_game_creator_agent_codex_cli(request.clone()).await } GAME_CREATOR_AGENT_MODE_PROVIDER => { - client - .expect("provider mode constructs an HTTP client") - .run(request.clone()) + let client = client.expect("provider mode constructs an HTTP client"); + request_game_creator_agent_runtime_provider_llm(&client, llm, request.clone()) .await } _ => unreachable!("agent mode is normalized"), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 0f9d84221..2a464b0c0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -1557,17 +1557,35 @@ pub(crate) fn spawn_mock_llm_tool_plan_then_invalid_final_reply( let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr")); std::thread::spawn(move || { let (mut planning_stream, _) = listener.accept().expect("mock tool plan accept"); - drop(read_mock_http_request(&mut planning_stream)); - let planning_body = serde_json::json!({ - "id": "resp_invalid_final_reply_planning", - "model": "mock-game-model", - "output_text": planning_response, - "status": "completed", - "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } - }) - .to_string(); + let planning_request = read_mock_http_request(&mut planning_stream); + let planning_streaming = planning_request.contains("\"stream\":true"); + let (planning_body, planning_content_type) = if planning_streaming { + ( + format!( + "data: {}\n\ndata: {}\n\n", + serde_json::json!({ + "type": "response.output_text.delta", + "delta": planning_response + }), + serde_json::json!({ "type": "response.completed" }) + ), + "text/event-stream; charset=utf-8", + ) + } else { + ( + serde_json::json!({ + "id": "resp_invalid_final_reply_planning", + "model": "mock-game-model", + "output_text": planning_response, + "status": "completed", + "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } + }) + .to_string(), + "application/json", + ) + }; let planning_response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + "HTTP/1.1 200 OK\r\nContent-Type: {planning_content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", planning_body.len(), planning_body ); @@ -1607,6 +1625,74 @@ pub(crate) fn final_tool_plan_response(response: impl Into) -> String { .to_string() } +pub(crate) fn native_anthropic_tool_plan_response( + call_id: &str, + function_name: &str, + arguments: &str, +) -> String { + let events = vec![ + serde_json::json!({ + "type": "message_start", + "message": { "usage": { "input_tokens": 11, "output_tokens": 22 } } + }), + serde_json::json!({ + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "tool_use", + "id": call_id, + "name": function_name, + "input": {} + } + }), + serde_json::json!({ + "type": "content_block_delta", + "index": 0, + "delta": { "type": "input_json_delta", "partial_json": arguments } + }), + serde_json::json!({ "type": "content_block_stop", "index": 0 }), + serde_json::json!({ "type": "message_delta", "delta": { "stop_reason": "tool_use" } }), + serde_json::json!({ "type": "message_stop" }), + ]; + events + .iter() + .map(|event| format!("data: {event}\n\n")) + .collect() +} + +pub(crate) fn native_anthropic_text_stream_response(text: &str) -> String { + let events = vec![ + serde_json::json!({ + "type": "message_start", + "message": { "usage": { "input_tokens": 11, "output_tokens": 0 } } + }), + serde_json::json!({ + "type": "content_block_start", + "index": 0, + "content_block": { "type": "text", "text": "" } + }), + serde_json::json!({ + "type": "content_block_delta", + "index": 0, + "delta": { "type": "text_delta", "text": text } + }), + serde_json::json!({ + "type": "content_block_stop", + "index": 0 + }), + serde_json::json!({ + "type": "message_delta", + "delta": { "stop_reason": "end_turn" }, + "usage": { "output_tokens": 22 } + }), + serde_json::json!({ "type": "message_stop" }), + ]; + events + .iter() + .map(|event| format!("data: {event}\n\n")) + .collect() +} + fn user_input_tool_plan_response(question: &str) -> String { serde_json::json!({ "thinkingSummary": "实现路径取决于用户选择,需要先暂停并澄清", @@ -1694,30 +1780,65 @@ fn spawn_mock_llm_server_responses_with_capture( if let Some(sender) = request_sender.as_ref() { let _ = sender.send(request_text.clone()); } - let body = if request_text.contains("POST /responses HTTP/1.1") { - serde_json::json!({ - "id": "resp_game_creator_mock", - "model": "mock-game-model", - "output_text": response_content, - "status": "completed", - "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } - }) + let is_responses = request_text.contains("POST /responses HTTP/1.1"); + let is_stream = request_text.contains("\"stream\":true"); + let (body, content_type) = if is_stream && is_responses { + ( + format!( + "data: {}\n\ndata: {}\n\n", + serde_json::json!({ + "type": "response.output_text.delta", + "delta": response_content + }), + serde_json::json!({ "type": "response.completed" }) + ), + "text/event-stream; charset=utf-8", + ) + } else if is_stream { + ( + format!( + "data: {}\n\ndata: [DONE]\n\n", + serde_json::json!({ + "id": "chatcmpl_game_creator_mock", + "object": "chat.completion.chunk", + "choices": [{ + "index": 0, + "delta": { "role": "assistant", "content": response_content }, + "finish_reason": "stop" + }] + }) + ), + "text/event-stream; charset=utf-8", + ) + } else if is_responses { + ( + serde_json::json!({ + "id": "resp_game_creator_mock", + "model": "mock-game-model", + "output_text": response_content, + "status": "completed", + "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } + }) + .to_string(), + "application/json", + ) } else { - serde_json::json!({ - "id": "chatcmpl_game_creator_mock", - "model": "mock-game-model", - "choices": [ - { + ( + serde_json::json!({ + "id": "chatcmpl_game_creator_mock", + "model": "mock-game-model", + "choices": [{ "message": { "content": response_content }, "finish_reason": "stop" - } - ], - "usage": { "prompt_tokens": 11, "completion_tokens": 22, "total_tokens": 33 } - }) - } - .to_string(); + }], + "usage": { "prompt_tokens": 11, "completion_tokens": 22, "total_tokens": 33 } + }) + .to_string(), + "application/json", + ) + }; let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body ); @@ -2091,18 +2212,34 @@ fn spawn_mock_llm_tool_plan_then_transient_final_reply( .try_into() .unwrap_or(u64::MAX); let planning_request = read_mock_http_request(&mut planning_stream); - let _ = request_capture_sender.send((planning_accepted_at_ms, planning_request)); + let _ = request_capture_sender.send((planning_accepted_at_ms, planning_request.clone())); let _ = request_notice_sender.send(()); - let planning_body = serde_json::json!({ - "id": "resp_transient_final_reply_planning", - "model": "mock-game-model", - "output_text": planning_response, - "status": "completed", - "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } - }) - .to_string(); + let planning_body = if planning_request.contains("\"stream\":true") { + format!( + "data: {}\n\ndata: {}\n\n", + serde_json::json!({ + "type": "response.output_text.delta", + "delta": planning_response + }), + serde_json::json!({ "type": "response.completed" }) + ) + } else { + serde_json::json!({ + "id": "resp_transient_final_reply_planning", + "model": "mock-game-model", + "output_text": planning_response, + "status": "completed", + "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } + }) + .to_string() + }; + let planning_content_type = if planning_request.contains("\"stream\":true") { + "text/event-stream; charset=utf-8" + } else { + "application/json" + }; let planning_http_response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + "HTTP/1.1 200 OK\r\nContent-Type: {planning_content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", planning_body.len(), planning_body ); @@ -2182,7 +2319,7 @@ fn spawn_mock_llm_tool_plan_then_transient_final_compaction( let (mut planning_stream, _) = listener.accept().expect("mock tool plan accept"); let planning_accepted_at_ms = accepted_at_ms(); let planning_request = read_mock_http_request(&mut planning_stream); - let _ = request_capture_sender.send((planning_accepted_at_ms, planning_request)); + let _ = request_capture_sender.send((planning_accepted_at_ms, planning_request.clone())); replace_test_local_config( &config_path, format!( @@ -2206,16 +2343,32 @@ fn spawn_mock_llm_tool_plan_then_transient_final_compaction( ), ); let _ = request_notice_sender.send(()); - let planning_body = serde_json::json!({ - "id": "resp_final_compaction_planning", - "model": "mock-game-model", - "output_text": planning_response, - "status": "completed", - "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } - }) - .to_string(); + let planning_body = if planning_request.contains("\"stream\":true") { + format!( + "data: {}\n\ndata: {}\n\n", + serde_json::json!({ + "type": "response.output_text.delta", + "delta": planning_response + }), + serde_json::json!({ "type": "response.completed" }) + ) + } else { + serde_json::json!({ + "id": "resp_final_compaction_planning", + "model": "mock-game-model", + "output_text": planning_response, + "status": "completed", + "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } + }) + .to_string() + }; + let planning_content_type = if planning_request.contains("\"stream\":true") { + "text/event-stream; charset=utf-8" + } else { + "application/json" + }; let planning_http_response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + "HTTP/1.1 200 OK\r\nContent-Type: {planning_content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", planning_body.len(), planning_body ); @@ -2240,19 +2393,35 @@ fn spawn_mock_llm_tool_plan_then_transient_final_compaction( let recovered_compaction_request = read_mock_http_request(&mut recovered_compaction_stream); let _ = request_capture_sender.send(( recovered_compaction_accepted_at_ms, - recovered_compaction_request, + recovered_compaction_request.clone(), )); let _ = request_notice_sender.send(()); - let compaction_body = serde_json::json!({ - "id": "resp_final_compaction_recovered", - "model": "mock-game-model", - "output_text": compaction_response, - "status": "completed", - "usage": { "input_tokens": 44, "output_tokens": 12, "total_tokens": 56 } - }) - .to_string(); + let compaction_body = if recovered_compaction_request.contains("\"stream\":true") { + format!( + "data: {}\n\ndata: {}\n\n", + serde_json::json!({ + "type": "response.output_text.delta", + "delta": compaction_response + }), + serde_json::json!({ "type": "response.completed" }) + ) + } else { + serde_json::json!({ + "id": "resp_final_compaction_recovered", + "model": "mock-game-model", + "output_text": compaction_response, + "status": "completed", + "usage": { "input_tokens": 44, "output_tokens": 12, "total_tokens": 56 } + }) + .to_string() + }; + let compaction_content_type = if recovered_compaction_request.contains("\"stream\":true") { + "text/event-stream; charset=utf-8" + } else { + "application/json" + }; let compaction_http_response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + "HTTP/1.1 200 OK\r\nContent-Type: {compaction_content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", compaction_body.len(), compaction_body ); @@ -2290,9 +2459,36 @@ fn spawn_mock_llm_tool_plan_then_transient_final_compaction( fn spawn_mock_llm_raw_responses_with_capture( response_bodies: Vec, request_sender: Option>, +) -> String { + spawn_mock_llm_raw_responses_with_content_type_with_capture( + response_bodies + .into_iter() + .map(|body| body.to_string()) + .collect(), + request_sender, + "application/json", + ) +} + +fn spawn_mock_llm_stream_responses_with_capture( + response_bodies: Vec, + request_sender: Option>, +) -> String { + spawn_mock_llm_raw_responses_with_content_type_with_capture( + response_bodies, + request_sender, + "text/event-stream; charset=utf-8", + ) +} + +fn spawn_mock_llm_raw_responses_with_content_type_with_capture( + response_bodies: Vec, + request_sender: Option>, + content_type: &str, ) -> String { let listener = bind_test_tcp_listener("mock raw llm bind"); let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr")); + let content_type = content_type.to_string(); std::thread::spawn(move || { for response_body in response_bodies { let (mut stream, _) = listener.accept().expect("mock raw llm accept"); @@ -2300,11 +2496,11 @@ fn spawn_mock_llm_raw_responses_with_capture( if let Some(sender) = request_sender.as_ref() { let _ = sender.send(request_text); } - let body = response_body.to_string(); let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body + "HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + content_type, + response_body.len(), + response_body ); stream .write_all(response.as_bytes()) @@ -2468,6 +2664,11 @@ enum ResponseStreamMockFinalResponse { Disconnect, } +enum ResponseStreamMockPlanningResponse { + NonStream(String), + Stream(String), +} + struct ResponseStreamMockServer { base_url: String, first_delta_written: mpsc::Receiver<()>, @@ -2487,7 +2688,7 @@ impl ResponseStreamMockServer { fn spawn_response_stream_mock_llm_server( api_kind: &str, - planning_response: String, + planning_response: ResponseStreamMockPlanningResponse, final_response: Option, ) -> ResponseStreamMockServer { let listener = bind_test_tcp_listener("response stream mock bind"); @@ -2501,39 +2702,72 @@ fn spawn_response_stream_mock_llm_server( let (stop, stop_receiver) = mpsc::channel(); let handle = std::thread::spawn(move || { let mut requests = Vec::new(); - let (mut planning_stream, _) = listener - .accept() - .expect("response stream planning request accept"); - let planning_request = read_mock_http_request(&mut planning_stream); - requests.push(planning_request); - let planning_body = match api_kind.as_str() { - "openai_responses" => serde_json::json!({ - "id": "resp_response_stream_planning", - "model": "response-stream-model", - "output_text": planning_response, - "status": "completed", - "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } - }), - "openai_chat" => serde_json::json!({ - "id": "chatcmpl_response_stream_planning", - "model": "response-stream-model", - "choices": [{ - "message": { "content": planning_response }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 11, "completion_tokens": 22, "total_tokens": 33 } - }), - other => panic!("unsupported response stream mock api kind: {other}"), + let planning_http_response = match planning_response { + ResponseStreamMockPlanningResponse::NonStream(planning_response) => { + let planning_body = match api_kind.as_str() { + "openai_responses" => serde_json::json!({ + "id": "resp_response_stream_planning", + "model": "response-stream-model", + "output_text": planning_response, + "status": "completed", + "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } + }), + "openai_chat" => serde_json::json!({ + "id": "chatcmpl_response_stream_planning", + "model": "response-stream-model", + "choices": [{ + "message": { "content": planning_response }, + "finish_reason": "stop" + }], + "usage": { "prompt_tokens": 11, "completion_tokens": 22, "total_tokens": 33 } + }), + other => panic!("unsupported response stream mock api kind: {other}"), + } + .to_string(); + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + planning_body.len(), + planning_body + ) + } + ResponseStreamMockPlanningResponse::Stream(planning_response) => { + let planning_body = match api_kind.as_str() { + "openai_responses" => format!( + "data: {}\n\ndata: {}\n\n", + serde_json::json!({ + "type": "response.output_text.delta", + "delta": planning_response + }), + serde_json::json!({ "type": "response.completed" }) + ), + "openai_chat" => format!( + "data: {}\n\ndata: {}\n\ndata: [DONE]\n\n", + serde_json::json!({ + "choices": [{ "delta": { "content": planning_response } }] + }), + serde_json::json!({ + "choices": [{ "finish_reason": "stop" }] + }) + ), + other => panic!("unsupported response stream mock api kind: {other}"), + }; + format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + planning_body.len(), + planning_body + ) + } + }; + { + let (mut stream, _) = listener + .accept() + .expect("response stream planning request accept"); + let planning_request = read_mock_http_request(&mut stream); + requests.push(planning_request); + stream + .write_all(planning_http_response.as_bytes()) + .expect("response stream planning response"); } - .to_string(); - let planning_http_response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - planning_body.len(), - planning_body - ); - planning_stream - .write_all(planning_http_response.as_bytes()) - .expect("response stream planning response"); if let Some(final_response) = final_response { listener @@ -3978,7 +4212,7 @@ fn run_response_stream_distinct_final_reply_case(api_kind: &str, case_name: &str let canonical_response = format!("{first_delta}{second_delta}"); let mock = spawn_response_stream_mock_llm_server( api_kind, - final_tool_plan_response(&planning_fallback), + ResponseStreamMockPlanningResponse::Stream(final_tool_plan_response(&planning_fallback)), Some(ResponseStreamMockFinalResponse::Deltas( first_delta.clone(), second_delta.clone(), @@ -4080,7 +4314,7 @@ fn run_response_stream_distinct_final_reply_case(api_kind: &str, case_name: &str .all(|request| request.contains(expected_route))); let planning_request = mock_http_request_json(&requests[0]); let final_request = mock_http_request_json(&requests[1]); - assert_eq!(planning_request["stream"], Value::Bool(false)); + assert_eq!(planning_request["stream"], Value::Bool(true)); assert_eq!(final_request["stream"], Value::Bool(true)); assert!(requests[0].contains("respond_to_user")); assert!(!requests[0].contains("\"name\":\"submit_agent_tool_plan\"")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index a531cc7fe..0d8609c1a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -2505,6 +2505,120 @@ async fn background_agent_runtime_executes_native_function_tool_plan() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn background_agent_runtime_executes_streamed_native_function_tool_plan() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-stream-native-tool", "流式原生工具项目") + .expect("project init"); + let (sender, receiver) = mpsc::channel(); + let arguments = serde_json::json!({ + "reason": "读取项目索引", + "input": {} + }) + .to_string(); + let function_name = native_runtime_function_name("project.index").expect("index function"); + let base_url = spawn_mock_llm_stream_responses_with_capture( + vec![ + native_anthropic_tool_plan_response( + "call-stream-native-index", + &function_name, + &arguments, + ), + native_anthropic_tool_plan_response( + "call-stream-native-final", + AGENT_RUNTIME_RESPOND_FUNCTION_NAME, + &serde_json::json!({ + "response": "流式原生工具调用已完成聚合。STREAM_NATIVE_TOOL_OK" + }) + .to_string(), + ), + native_anthropic_text_stream_response( + "最终回复已通过独立流式收束请求生成。STREAM_NATIVE_FINAL_OK", + ), + ], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentMode": "provider", + "agentLlm": {{ + "design-director": {{ + "apiKey": "stream-native-tool-key", + "baseUrl": {base_url:?}, + "model": "stream-native-tool-model", + "apiKind": "anthropic", + "stream": true, + "webSearchEnabled": false, + "maxRetries": 0 + }} + }} +}}"# + )); + let run_id = "design-stream-native-function-tool-run"; + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "用流式原生工具读取项目索引", + run_id, + ) + .expect("start streamed native tool task"); + + let request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("streamed native tool request"); + assert!(request.contains("POST /v1/messages HTTP/1.1")); + assert_eq!( + mock_http_request_json(&request)["stream"], + Value::Bool(true) + ); + assert!(request.contains(&function_name)); + let respond_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("streamed native respond_to_user request"); + assert!(respond_request.contains(AGENT_RUNTIME_RESPOND_FUNCTION_NAME)); + assert_eq!( + mock_http_request_json(&respond_request)["stream"], + Value::Bool(true) + ); + let final_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("streamed native final-reply request"); + assert!(!final_request.contains(AGENT_RUNTIME_RESPOND_FUNCTION_NAME)); + assert_eq!( + mock_http_request_json(&final_request)["stream"], + Value::Bool(true) + ); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.status, "idle"); + assert_eq!(runtime.phase, "completed"); + assert_eq!(runtime.recent_tool_calls.len(), 1); + assert_eq!(runtime.recent_tool_calls[0].tool, "project.index"); + assert_eq!(runtime.recent_tool_calls[0].status, "ok"); + assert_eq!( + runtime.last_response.as_deref(), + Some("最终回复已通过独立流式收束请求生成。STREAM_NATIVE_FINAL_OK") + ); + let protocol_records = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| { + record["recordType"] == "agent.runtime.tool_plan.protocol" && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(protocol_records.len(), 2); + assert_eq!(protocol_records[0]["protocol"], "native_runtime_tools"); + assert_eq!(protocol_records[0]["functionCallCount"], 1); + assert_eq!(protocol_records[0]["functionNames"][0], function_name); + assert_eq!( + protocol_records[1]["functionNames"][0], + AGENT_RUNTIME_RESPOND_FUNCTION_NAME + ); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn manual_context_compaction_is_private_and_hydrates_runtime_usage() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs index 03665afb2..32a9c3ec9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs @@ -335,7 +335,7 @@ async fn response_stream_disabled_keeps_direct_planning_reply_to_one_request() { let direct_response = "非流配置直接采用 planning response,且只发起一次请求。"; let mock = spawn_response_stream_mock_llm_server( "openai_responses", - final_tool_plan_response(direct_response), + ResponseStreamMockPlanningResponse::NonStream(final_tool_plan_response(direct_response)), None, ); let base_url = mock.base_url.clone(); @@ -426,7 +426,7 @@ async fn response_stream_private_process_output_is_never_published_or_committed_ let raw_provider_response = format!("{first_delta}{second_delta}"); let mock = spawn_response_stream_mock_llm_server( "openai_responses", - final_tool_plan_response(&planning_fallback), + ResponseStreamMockPlanningResponse::Stream(final_tool_plan_response(&planning_fallback)), Some(ResponseStreamMockFinalResponse::Deltas( first_delta.clone(), second_delta.clone(), @@ -593,7 +593,7 @@ async fn response_stream_private_process_output_is_never_published_or_committed_ assert_eq!(requests.len(), 2); assert_eq!( mock_http_request_json(&requests[0])["stream"], - Value::Bool(false) + Value::Bool(true) ); assert_eq!( mock_http_request_json(&requests[1])["stream"], @@ -629,7 +629,7 @@ async fn response_stream_final_disconnect_with_retry_disabled_fails_without_comm let planning_fallback = "final stream 失败后只提交这条 planning fallback。"; let mock = spawn_response_stream_mock_llm_server( "openai_responses", - final_tool_plan_response(planning_fallback), + ResponseStreamMockPlanningResponse::Stream(final_tool_plan_response(planning_fallback)), Some(ResponseStreamMockFinalResponse::Disconnect), ); let base_url = mock.base_url.clone(); @@ -718,7 +718,7 @@ async fn response_stream_final_disconnect_with_retry_disabled_fails_without_comm ); assert_eq!( mock_http_request_json(&requests[0])["stream"], - Value::Bool(false) + Value::Bool(true) ); assert_eq!( mock_http_request_json(&requests[1])["stream"], diff --git a/docs/technical/【技术说明】AGC接第三方Provider的兼容性缺陷-2026-08-19.md b/docs/technical/【技术说明】AGC接第三方Provider的兼容性缺陷-2026-08-19.md index b7bf9ae78..97adc4d00 100644 --- a/docs/technical/【技术说明】AGC接第三方Provider的兼容性缺陷-2026-08-19.md +++ b/docs/technical/【技术说明】AGC接第三方Provider的兼容性缺陷-2026-08-19.md @@ -1,8 +1,8 @@ # 【技术说明】AGC 接第三方 Provider 的兼容性缺陷 - 首次记录:2026-08-19 -- 最新核对:2026-08-25,当前实现仍保留本文所述 Provider 分发约束 -- 结论:**这不是单一策划链路的问题**。各创作流程共用同一套 Provider 分发;第三方端点必须满足当前 `agentMode`、`apiKind` 和工具调用协议约束。缺陷 4 已修复,其余限制仍按本文处理。 +- 最新核对:2026-08-27,当前实现仍保留本文所述 Provider 分发约束 +- 结论:**这不是单一策划链路的问题**。各创作流程共用同一套 Provider 分发;第三方端点必须满足当前 `agentMode`、`apiKind` 和工具调用协议约束。缺陷 4 已修复;`llm.stream=true` 时 Provider tool-plan 现在按配置发送流式请求并在后端聚合完整响应,前端展示合同不变。其余限制仍按本文处理。 --- @@ -14,6 +14,7 @@ | 2 | `codex_app_server` 模式把第三方端点喂给 codex | apiKind≠openai_responses 时秒挂;否则 413 + 工具误用,180 秒超时后留下待核对的孤儿请求 | 模式前提未被约束 | | 3 | `provider` 模式下 `tool_choice=required` 与 DeepSeek 思考模式互斥 | 首个 tool-plan 请求 400,整个 runtime 起不来 | 参数空间缺一个值 | | 4 | 普通 action 批次带 plan update 时,两条预检规则互斥 | 「更新计划 + 委派专业 Agent」同一轮返回就报「批次成员身份或顺序不匹配」 | **本分支回归**(已修) | +| 5 | `llm.stream` 只记录配置,不驱动 Provider tool-plan 传输 | 要求 `stream=true` 的网关第一发 tool-plan 得到 HTTP 400,整轮不可用 | 传输配置失效(已修) | 缺陷 1~3 叠加的结果:**当前代码里没有任何一组配置能让 DeepSeek 跑起来**。缺陷 4 与 provider 无关,换成 `gpt-5.6-terra` 打通 LLM 链路后才暴露出来。 @@ -291,3 +292,26 @@ let expected_member_plan_update = batch - DeepSeek 网关 413 的具体阈值,以及 `provider` 模式下 AGC 自组的请求体是否也会触顶。 --- + +## 9. 缺陷 5:`llm.stream` 未作用于 Provider tool-plan(已修) + +### 现象 + +`agentMode=provider`、`llm.stream=true` 时,审计与重试指纹记录 `stream=true`,但首个 tool-plan 仍调用 `LlmClient::run()`,请求体实际为 `stream=false`。只接受流式请求的 OpenAI 兼容网关返回 HTTP 400 `Stream must be set to true`;由于这是本地请求构造错误,重试同一请求无法恢复。 + +### 修复边界 + +Provider 的持久化重试分发与常规重试分发统一按 `llm.stream` 选择 `stream_run()` / `run()`。`stream_run()` 负责聚合文本、工具调用与终态,tool-plan 仍在响应完整后按现有协议解析、校验和交接;不把半截 tool-call 参数发布给前端,也不改变最终回复的 response-stream 合同。 + +### 回归 + +- `response_stream_uses_distinct_streamed_final_reply_for_responses_and_chat`:覆盖 Responses / Chat 两种 wire 的 tool-plan 与 final-reply 请求均发送 `stream=true`。 +- `response_stream_disabled_keeps_direct_planning_reply_to_one_request`:覆盖 `llm.stream=false` 时 tool-plan 仍发送 `stream=false` 且保持单请求直接收束。 +- `background_agent_runtime_executes_streamed_native_function_tool_plan`:覆盖 Anthropic tool-use 分片在 Shell Runtime 中聚合为原生工具动作,并完成 tool-plan 协议审计与动作执行。 +- `platform-llm` 既有 Chat / Responses 流式工具调用聚合用例继续覆盖分片工具参数装配。 + +### 升级边界 + +升级前遗留的 durable retry sidecar 若是在旧实现(审计记录 `stream=true`、实际发送 `stream=false`)期间创建,升级恢复后会按当前配置真实发送流式请求。该行为修正了配置与 wire 行为的一致性,但不保证与升级前已发出的失败请求字节一致;排查跨版本恢复时以 raw failure log 的请求快照为准。 + +---