diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow.rs index 6078abf04..1081ff2fc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow.rs @@ -5,7 +5,7 @@ use crate::platform_session::current_platform_session; use crate::ui_editor::commands::separation::*; use crate::ui_editor::commands::utils::{ parse_limited_llm_tool_arguments, read_ui_reference_image_data_url, request_ui_editor_llm, - request_with_feedback, strict_json_schema, + run_with_repair_history, strict_json_schema, }; use crate::ui_editor::state::State; use crate::ui_editor::utils::NodeId; @@ -275,30 +275,26 @@ async fn visual_binding( ) .with_strict(true); let base_prompt = gen_binding_prompt(nodes.to_vec()); - let result = request_with_feedback( + let initial_history = vec![LlmMessage::user_multimodal(vec![ + LlmMessageContentPart::InputText { text: base_prompt }, + LlmMessageContentPart::InputImage { + image_url: source_url.clone(), + }, + LlmMessageContentPart::InputImage { + image_url: processed_url.clone(), + }, + ])]; + let result = run_with_repair_history( 2, - |feedback| { - let prompt = feedback.map_or_else( - || base_prompt.clone(), - |error| format!("{base_prompt}\n上一次输出错误:{error}\n请修正并完整返回。"), - ); - let source_url = source_url.clone(); - let processed_url = processed_url.clone(); + initial_history, + |history| { let tool = tool.clone(); let client = client.clone(); let llm_config = llm_config.clone(); async move { - let request = LlmRunRequest::new(vec![LlmMessage::user_multimodal(vec![ - LlmMessageContentPart::InputText { text: prompt }, - LlmMessageContentPart::InputImage { - image_url: source_url, - }, - LlmMessageContentPart::InputImage { - image_url: processed_url, - }, - ])]) - .with_function_tools(vec![tool.clone()]) - .with_tool_choice(LlmToolChoice::Required); + let request = LlmRunRequest::new(history) + .with_function_tools(vec![tool.clone()]) + .with_tool_choice(LlmToolChoice::Required); request_ui_editor_llm(&client, &llm_config, request) .await .map_err(|e| e.to_string()) diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/utils.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/utils.rs index 8aec25302..4f849d227 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/utils.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/utils.rs @@ -1,8 +1,9 @@ use crate::agent::request_game_creator_llm_text; use crate::config::{apply_game_creator_llm_reasoning_effort, parse_game_creator_llm_api_kind}; use base64::Engine as _; -use platform_llm::{LlmClient, LlmError, LlmRunRequest, LlmRunResponse}; +use platform_llm::{LlmClient, LlmError, LlmMessage, LlmRunRequest, LlmRunResponse}; use schemars::JsonSchema; +use serde::Serialize; use std::fs::File; use std::future::Future; use std::io::Read; @@ -26,31 +27,39 @@ pub(crate) async fn request_ui_editor_llm( request_game_creator_llm_text(client, llm, request).await } -/// 结构化 LLM 请求的小型 repair harness:第一次请求或校验失败后, -/// 将错误反馈给模型并只额外重试一次。网络/模型调用本身的错误也会 -/// 进入第二次请求的反馈文本;调用方负责在第二次失败后决定业务状态。 -pub(crate) async fn request_with_feedback( - more_turn: usize, - request: Request, - validate: Validate, +/// 按 append-only history 重试结构化 LLM 请求;仅业务校验失败会追加反馈消息。 +pub(crate) async fn run_with_repair_history( + max_retries: usize, + initial_history: Vec, + requester: Requester, + validater: Validater, ) -> Result where - Request: Fn(Option) -> Fut, + T: Serialize, + Requester: Fn(Vec) -> Fut, Fut: Future>, - Validate: Fn(&T) -> Result<(), String>, + Validater: Fn(&T) -> Result<(), String>, { - let mut feedback = None; - for attempt in 0..=more_turn { - let result = request(feedback.clone()) - .await - .and_then(|value| validate(&value).map(|_| value)); - match result { - Ok(value) => return Ok(value), - Err(error) if attempt < more_turn => feedback = Some(error), + let mut history = initial_history; + for attempt in 0..=max_retries { + let value = match requester(history.clone()).await { + Ok(value) => value, + Err(_error) if attempt < max_retries => continue, + Err(error) => return Err(error), + }; + match validater(&value) { + Ok(()) => return Ok(value), + Err(error) if attempt < max_retries => { + let serialized = serde_json::to_string(&value) + .map_err(|serialize_error| format!("序列化修复反馈失败:{serialize_error}"))?; + history.push(LlmMessage::system(format!( + "上一次模型输出:\n{serialized}\n\n业务校验失败:\n{error}\n\n请修正并完整返回。" + ))); + } Err(error) => return Err(error), } } - unreachable!("repair harness always returns within requested turns") + unreachable!("repair history runner always returns within requested retries") } pub(crate) fn parse_limited_llm_tool_arguments( @@ -173,15 +182,17 @@ mod tests { } #[tokio::test] - async fn feedback_harness_zero_more_turn_calls_once_without_feedback() { + async fn repair_history_zero_retries_calls_once_with_initial_history() { let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let seen = calls.clone(); - let result = request_with_feedback( + let initial_history = vec![LlmMessage::user("初始 prompt")]; + let result = run_with_repair_history( 0, - move |feedback| { + initial_history.clone(), + move |history| { let seen = seen.clone(); async move { - seen.lock().unwrap().push(feedback); + seen.lock().unwrap().push(history); Ok::<_, String>(serde_json::json!({"ok": true})) } }, @@ -190,7 +201,85 @@ mod tests { .await .expect("single turn should succeed"); assert_eq!(result, serde_json::json!({"ok": true})); - assert_eq!(calls.lock().unwrap().as_slice(), &[None]); + assert_eq!(calls.lock().unwrap().as_slice(), &[initial_history]); + } + + #[tokio::test] + async fn repair_history_request_error_retries_without_appending_history() { + let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let attempts = std::sync::Arc::new(std::sync::Mutex::new(0usize)); + let seen_calls = calls.clone(); + let seen_attempts = attempts.clone(); + let initial_history = vec![LlmMessage::user("初始 prompt")]; + let result = run_with_repair_history( + 1, + initial_history.clone(), + move |history| { + seen_calls.lock().unwrap().push(history); + let attempt = { + let mut attempts = seen_attempts.lock().unwrap(); + let attempt = *attempts; + *attempts += 1; + attempt + }; + async move { + if attempt == 0 { + Err("网络错误".to_string()) + } else { + Ok::<_, String>(serde_json::json!({"ok": true})) + } + } + }, + |_| Ok(()), + ) + .await + .expect("retry-only error should recover"); + assert_eq!(result, serde_json::json!({"ok": true})); + assert_eq!( + calls.lock().unwrap().as_slice(), + &[initial_history.clone(), initial_history] + ); + } + + #[tokio::test] + async fn repair_history_business_failure_appends_serialized_value_and_error() { + let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let attempts = std::sync::Arc::new(std::sync::Mutex::new(0usize)); + let seen_calls = calls.clone(); + let seen_attempts = attempts.clone(); + let initial_history = vec![LlmMessage::user("初始 prompt")]; + let result = run_with_repair_history( + 1, + initial_history.clone(), + move |history| { + seen_calls.lock().unwrap().push(history); + let mut attempts = seen_attempts.lock().unwrap(); + let attempt = *attempts; + *attempts += 1; + async move { Ok::<_, String>(serde_json::json!({"attempt": attempt})) } + }, + |value: &serde_json::Value| { + if value["attempt"] == 0 { + Err("业务校验失败".to_string()) + } else { + Ok(()) + } + }, + ) + .await + .expect("business feedback should recover"); + assert_eq!(result, serde_json::json!({"attempt": 1})); + let calls = calls.lock().unwrap(); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0], initial_history); + assert_eq!(calls[1].len(), 2); + assert_eq!(calls[1][0], LlmMessage::user("初始 prompt")); + assert_eq!( + calls[1][1], + LlmMessage::system( + "上一次模型输出:\n{\"attempt\":0}\n\n业务校验失败:\n业务校验失败\n\n请修正并完整返回。" + ) + ); } #[test] diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 00092043a..2e6658f85 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8034,6 +8034,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - AGC LLM 对话入口在解析 Router 凭据和访问上游前先读取用户 `wallet_balance`。余额为 `0` 时直接返回 `409 MUD_POINTS_INSUFFICIENT`,客户端显示“泥点余额不足”;不创建、续期或使用 Router 账号。余额读取失败同样失败关闭,返回“泥点余额暂时不可用”。 - 余额大于 `0` 的请求继续走 Router,成功后仍按 best-effort 后置结算;退款占用、冻结或扣费时余额不足的处理继续由钱包事务和既有结算规则负责。 +## 2026-09-09 UI Editor 结构化请求 repair history + +- UI Editor 的结构化 LLM repair 由 `run_with_repair_history` 统一维护 append-only `LlmMessage` history;调用方只构造初始 prompt 并提供 `requester(history) -> Result`。 +- `validater(&T) -> Result<(), String>` 只负责业务校验。网络、模型、tool 缺失、JSON 或反序列化错误只按原 history 重试;只有业务校验失败才把序列化后的响应和校验错误合并为一条 system message 追加到 history。 +- history 仅存在本次请求内存中,不重复图片、不截断、不扩展 `platform-llm` 消息协议;重试次数参数统一使用 `max_retries`。 + ## 2026-08-29 DirectProject 受控联网搜索默认与边界 - 正式产品本次只覆盖 `DirectProject` 单 Codex Agent。`Provider`、`ToolHost`、`DirectHome` 不是 Agent,也不是本次联网主链路;不新增全路由联网或工具桥。唯一受控联网工具为 `agc_tools.agc_web_search`,链路固定为 Codex MCP 工具目录 -> 客户端 loopback `DirectToolBridge` -> 有界 Bing RSS HTTPS -> 过滤 / 脱敏 -> MCP 结果回传。