重构 UI Editor LLM repair history

将 request_with_feedback 改为 run_with_repair_history 并统一 max_retries 与 validater 命名。

让 requester 接收 append-only LlmMessage history,业务校验失败追加序列化响应和错误反馈。

迁移 visual_binding 调用方并补充 retry-only 与业务反馈 history 测试。

同步 UI Editor 结构化请求 repair history 决策文档。
This commit is contained in:
2026-09-09 16:45:55 +08:00
parent 6e4f9a6f1f
commit d4b5f9fc14
3 changed files with 135 additions and 44 deletions
@@ -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())
@@ -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<T, Request, Fut, Validate>(
more_turn: usize,
request: Request,
validate: Validate,
/// 按 append-only history 重试结构化 LLM 请求;仅业务校验失败会追加反馈消息。
pub(crate) async fn run_with_repair_history<T, Requester, Fut, Validater>(
max_retries: usize,
initial_history: Vec<LlmMessage>,
requester: Requester,
validater: Validater,
) -> Result<T, String>
where
Request: Fn(Option<String>) -> Fut,
T: Serialize,
Requester: Fn(Vec<LlmMessage>) -> Fut,
Fut: Future<Output = Result<T, String>>,
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]
@@ -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<T, String>`
- `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 结果回传。