合并 codex/ai-game-creator-app 工具计划响应交接误判修复
Project CI / Repository checks (pull_request) Failing after 56s
Project CI / Backend tests (pull_request) Successful in 4m42s
Project CI / Native shell tests (pull_request) Failing after 10m17s
Project CI / Frontend tests (pull_request) Successful in 2m37s

This commit is contained in:
2026-07-27 07:02:30 +00:00
9 changed files with 339 additions and 30 deletions
@@ -63,6 +63,8 @@ pub(crate) use models::{
AgentRuntimeVerificationGate, ParsedAgentRuntimeToolPlan,
AGENT_RUNTIME_LOCK_STALE_AFTER_SECONDS,
};
#[cfg(test)]
pub(crate) use provider_control::mark_game_creator_agent_runtime_provider_success_handoff_needs_reconciliation_for_test;
pub(crate) use provider_retry::{
game_creator_agent_runtime_provider_request_id,
game_creator_agent_runtime_transient_retry_backoff_ms,
@@ -347,6 +347,53 @@ pub(in crate::agent) fn mark_game_creator_agent_runtime_provider_request_needs_r
root: &Path,
snapshot: &AgentRuntimeProviderRequestSnapshot,
request_id: &str,
) -> Result<(), String> {
mark_game_creator_agent_runtime_provider_request_needs_reconciliation_with_diagnostic_at_locked(
root, snapshot, request_id, None,
)
}
pub(in crate::agent) fn mark_game_creator_agent_runtime_provider_success_handoff_needs_reconciliation_at_locked(
root: &Path,
snapshot: &AgentRuntimeProviderRequestSnapshot,
request_id: &str,
error: &str,
) -> Result<(), String> {
let failure_kind = if snapshot.request_kind == "tool-plan" {
tool_plan_handoff::failure_kind(error)
} else {
"provider-success-handoff"
};
let error = redact_agent_runtime_error(root, error, 500);
mark_game_creator_agent_runtime_provider_request_needs_reconciliation_with_diagnostic_at_locked(
root,
snapshot,
request_id,
Some((failure_kind, error.as_str())),
)
}
#[cfg(test)]
pub(crate) fn mark_game_creator_agent_runtime_provider_success_handoff_needs_reconciliation_for_test(
root: &Path,
snapshot: &AgentRuntimeProviderRequestSnapshot,
request_id: &str,
error: &str,
) -> Result<(), String> {
let _control_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"runtime.provider_request.success_handoff_reconciliation.test",
)?;
mark_game_creator_agent_runtime_provider_success_handoff_needs_reconciliation_at_locked(
root, snapshot, request_id, error,
)
}
fn mark_game_creator_agent_runtime_provider_request_needs_reconciliation_with_diagnostic_at_locked(
root: &Path,
snapshot: &AgentRuntimeProviderRequestSnapshot,
request_id: &str,
diagnostic: Option<(&'static str, &str)>,
) -> Result<(), String> {
let state_path = game_creator_agent_runtime_session_path(root, &snapshot.agent_id);
let mut state = serde_json::from_str::<AgentRuntimeState>(
@@ -371,29 +418,58 @@ pub(in crate::agent) fn mark_game_creator_agent_runtime_provider_request_needs_r
{
return Err("孤立 Provider 请求与当前 Runtime 身份冲突".to_string());
}
let diagnostic = diagnostic.map(|(failure_kind, error)| {
(
failure_kind,
format!("{:x}", Sha256::digest(error.as_bytes())),
error.chars().count(),
)
});
state.status = "running".to_string();
state.phase = "needs-reconciliation".to_string();
state.current_action = "Provider 请求终态无法确认".to_string();
state.waiting_on = "开发者核对 Provider 请求与计费状态".to_string();
state.next_step = "确认孤立 Provider 请求后再恢复当前 run".to_string();
if diagnostic.is_some() {
state.current_action = "Provider 成功响应交接失败".to_string();
state.waiting_on = "开发者核对安全失败分类并修复交接".to_string();
state.next_step = "修复交接后人工决定是否重试当前 run".to_string();
} else {
state.current_action = "Provider 请求终态无法确认".to_string();
state.waiting_on = "开发者核对 Provider 请求与计费状态".to_string();
state.next_step = "确认孤立 Provider 请求后再恢复当前 run".to_string();
}
let public_detail = diagnostic
.as_ref()
.map(|(failure_kind, error_sha256, error_chars)| {
format!(
"requestId={request_id} · failureKind={failure_kind} · errorSha256={error_sha256} · errorChars={error_chars}"
)
})
.unwrap_or_else(|| format!("requestId={request_id}"));
let event_detail = diagnostic
.is_some()
.then_some(public_detail.as_str())
.unwrap_or(request_id);
state.error = Some(format!(
"{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: requestId={request_id}"
"{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: {public_detail}"
));
state.updated_at = unix_timestamp();
append_game_creator_agent_runtime_task(root, &state)?;
refresh_game_creator_agent_runtime_task_queue(root, &mut state)?;
write_game_creator_agent_runtime_state(root, &state)?;
let event_summary = if diagnostic.is_some() {
"Provider 已返回响应,但成功 handoff 未能持久提交;Runtime 已停止自动重放。"
} else {
"检测到只有 started、没有可信终态的 Provider 请求,Runtime 已停止自动重放。"
};
let _ = append_game_creator_agent_runtime_event(
root,
&state,
"provider_request.needs_reconciliation",
"running",
"needs-reconciliation",
"检测到只有 started、没有可信终态的 Provider 请求,Runtime 已停止自动重放。",
Some(request_id),
event_summary,
Some(event_detail),
);
let _ = append_agent_db_record(
root,
let audit = if let Some((failure_kind, error_sha256, error_chars)) = diagnostic {
serde_json::json!({
"recordType": "agent.runtime.provider_request.needs_reconciliation",
"agentId": state.agent_id,
@@ -404,8 +480,24 @@ pub(in crate::agent) fn mark_game_creator_agent_runtime_provider_request_needs_r
"requestId": request_id,
"requestKind": snapshot.request_kind,
"requestSlot": snapshot.request_slot,
}),
);
"failureKind": failure_kind,
"errorSha256": error_sha256,
"errorChars": error_chars,
})
} else {
serde_json::json!({
"recordType": "agent.runtime.provider_request.needs_reconciliation",
"agentId": state.agent_id,
"taskId": state.task_id,
"sessionId": state.session_id,
"runId": state.run_id,
"source": state.source,
"requestId": request_id,
"requestKind": snapshot.request_kind,
"requestSlot": snapshot.request_slot,
})
};
let _ = append_agent_db_record(root, audit);
emit_game_creator_agent_runtime_update(root, &snapshot.agent_id);
Ok(())
}
@@ -1181,12 +1181,12 @@ where
"runtime.provider_request.success_handoff_reconciliation",
)
{
let _ =
mark_game_creator_agent_runtime_provider_request_needs_reconciliation_at_locked(
root,
&snapshot,
&request_id,
);
let _ = mark_game_creator_agent_runtime_provider_success_handoff_needs_reconciliation_at_locked(
root,
&snapshot,
&request_id,
&error,
);
}
let error = redact_agent_runtime_error(root, &error, 500);
return Err(format!(
@@ -7903,6 +7903,63 @@ async fn agent_runtime_orphan_provider_started_requires_reconciliation_without_r
fs::remove_dir_all(root).ok();
}
#[test]
fn provider_success_handoff_reconciliation_persists_only_safe_diagnostics() {
let root = unique_project_path();
let state = start_agent_runtime_steer_fixture(&root, "provider-handoff-diagnostic-run");
let snapshot = capture_game_creator_agent_runtime_provider_request_snapshot(
&root,
&state.agent_id,
&state.session_id,
&state.run_id,
"tool-plan",
"loop-11-repair-0",
state.applied_steer_cursor,
)
.expect("capture Provider handoff diagnostic snapshot");
let request_id = "provider-request-handoff-diagnostic";
let private_error = "tool-plan 成功响应交接 arguments 命中敏感规则 #0PRIVATE_PROVIDER_TEXT";
let error_sha256 = format!("{:x}", Sha256::digest(private_error.as_bytes()));
mark_game_creator_agent_runtime_provider_success_handoff_needs_reconciliation_for_test(
&root,
&snapshot,
request_id,
private_error,
)
.expect("persist safe Provider handoff diagnostic");
let runtime = read_game_creator_agent_runtime_at(&root, &state.agent_id)
.expect("read handoff diagnostic runtime")
.state;
let public_error = runtime.error.expect("handoff diagnostic runtime error");
assert!(public_error.contains(request_id));
assert!(public_error.contains("failureKind=tool-plan-sensitive-content"));
assert!(public_error.contains(&format!("errorSha256={error_sha256}")));
assert!(public_error.contains(&format!("errorChars={}", private_error.chars().count())));
assert!(!public_error.contains("PRIVATE_PROVIDER_TEXT"));
let records = read_agent_db_records_for_test(&root);
let audit = records
.iter()
.find(|record| {
record["recordType"] == "agent.runtime.provider_request.needs_reconciliation"
&& record["requestId"] == request_id
})
.expect("handoff diagnostic audit");
assert_eq!(audit["failureKind"], "tool-plan-sensitive-content");
assert_eq!(audit["errorSha256"], error_sha256);
assert_eq!(
audit["errorChars"],
serde_json::json!(private_error.chars().count())
);
assert!(audit.get("error").is_none());
assert!(audit.get("response").is_none());
assert!(audit.get("arguments").is_none());
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn context_compaction_started_orphan_requires_reconciliation_without_replay() {
let root = unique_project_path();
@@ -22,3 +22,42 @@ pub(crate) use model::{
AgentRuntimeToolPlanHandoffEntry, AgentRuntimeToolPlanHandoffLedger,
AgentRuntimeToolPlanHandoffLookup, TOOL_PLAN_HANDOFF_SCHEMA_VERSION,
};
pub(crate) fn failure_kind(error: &str) -> &'static str {
if error.contains("敏感规则") || error.contains("敏感 JSON") {
"tool-plan-sensitive-content"
} else if error.contains("绝对路径") {
"tool-plan-absolute-path"
} else if error.contains("thinking") {
"tool-plan-thinking-normalization"
} else if error.contains("超过")
|| error.contains("不能为空")
|| error.contains("无效")
|| error.contains("不支持")
{
"tool-plan-response-shape"
} else if error.contains("slot")
|| error.contains("loop")
|| error.contains("repair")
|| error.contains("identity")
|| error.contains("顺序")
{
"tool-plan-identity-order"
} else if error.contains("写入")
|| error.contains("读取")
|| error.contains("创建")
|| error.contains("目录")
|| error.contains("文件")
|| error.contains("权限")
{
"tool-plan-storage"
} else if error.contains("冲突")
|| error.contains("不匹配")
|| error.contains("不存在")
|| error.contains("损坏")
{
"tool-plan-integrity"
} else {
"tool-plan-unknown"
}
}
@@ -124,7 +124,7 @@ pub(super) fn validate_response(
&& fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) => {}
_ => return Err("tool-plan 成功响应交接 thinking normalization 元数据无效".to_string()),
}
validate_private_content(root, "text", &response.text, true)?;
validate_source_or_narrative_content("text", &response.text)?;
if let Some(finish_reason) = response.finish_reason.as_deref() {
validate_short_metadata(
root,
@@ -168,7 +168,7 @@ pub(super) fn validate_response(
"tool-plan 成功响应交接 arguments 超过 {TOOL_PLAN_HANDOFF_ARGUMENTS_MAX_BYTES} 字节上限"
));
}
validate_private_content(root, "arguments", &call.arguments, true)?;
validate_tool_plan_arguments(root, "arguments", &call.arguments)?;
}
if response.thinking_wrapper_valid
&& response.thinking_wrapper_balanced
@@ -226,7 +226,100 @@ pub(super) fn validate_private_content(
Ok(())
}
fn validate_source_or_narrative_content(label: &str, value: &str) -> Result<(), String> {
validate_source_content(label, value)?;
let json_like = value
.trim_start()
.as_bytes()
.first()
.is_some_and(|byte| matches!(byte, b'{' | b'['));
if json_like {
match serde_json::from_str::<serde_json::Value>(value) {
Ok(json) => {
validate_json_sensitive_keys(label, &json)?;
validate_json_absolute_path_inputs(label, &json, None)?;
}
Err(_) => {
validate_json_like_sensitive_keys(label, value)?;
validate_json_like_absolute_path_inputs(label, value)?;
}
}
}
Ok(())
}
fn validate_tool_plan_arguments(root: &Path, label: &str, value: &str) -> Result<(), String> {
validate_secret_tokens_and_controls(label, value)?;
match serde_json::from_str::<serde_json::Value>(value) {
Ok(json) => {
validate_json_sensitive_keys(label, &json)?;
validate_json_absolute_path_inputs(label, &json, None)?;
validate_json_private_string_values(root, label, &json, None)?;
}
Err(_) => validate_private_content(root, label, value, true)?,
}
Ok(())
}
fn validate_json_private_string_values(
root: &Path,
label: &str,
value: &serde_json::Value,
parent_key: Option<&str>,
) -> Result<(), String> {
match value {
serde_json::Value::String(value) => {
if parent_key.is_some_and(is_tool_plan_content_field) {
validate_source_content(label, value)?;
} else {
validate_private_content_view(root, label, value)?;
}
}
serde_json::Value::Array(values) => {
for value in values {
validate_json_private_string_values(root, label, value, parent_key)?;
}
}
serde_json::Value::Object(values) => {
for (key, value) in values {
validate_json_private_string_values(root, label, value, Some(key))?;
}
}
_ => {}
}
Ok(())
}
fn validate_source_content(label: &str, value: &str) -> Result<(), String> {
validate_secret_tokens_and_controls(label, value)?;
let lower = value.to_ascii_lowercase();
if let Some(rule) = ["authorization:", "cookie:", "bearer "]
.into_iter()
.position(|marker| lower.contains(marker))
{
return Err(format!(
"tool-plan 成功响应交接 {label} 命中敏感规则 #{}",
rule + 2
));
}
Ok(())
}
fn validate_secret_tokens_and_controls(label: &str, value: &str) -> Result<(), String> {
if redact_secret_tokens(value) != value {
return Err(format!("tool-plan 成功响应交接 {label} 命中敏感规则 #5"));
}
if value
.chars()
.any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
{
return Err(format!("tool-plan 成功响应交接 {label} 包含不安全控制字符"));
}
Ok(())
}
fn validate_private_content_view(_root: &Path, label: &str, value: &str) -> Result<(), String> {
validate_secret_tokens_and_controls(label, value)?;
let lower = value.to_ascii_lowercase();
let sensitive_rule = [
".env",
@@ -236,19 +329,12 @@ fn validate_private_content_view(_root: &Path, label: &str, value: &str) -> Resu
"bearer ",
]
.into_iter()
.position(|marker| lower.contains(marker))
.or_else(|| (redact_secret_tokens(value) != value).then_some(5));
.position(|marker| lower.contains(marker));
if let Some(rule) = sensitive_rule {
return Err(format!(
"tool-plan 成功响应交接 {label} 命中敏感规则 #{rule}"
));
}
if value
.chars()
.any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
{
return Err(format!("tool-plan 成功响应交接 {label} 包含不安全控制字符"));
}
Ok(())
}
@@ -505,7 +505,31 @@ fn tool_plan_handoff_rejects_out_of_order_entries() {
#[test]
fn tool_plan_handoff_rejects_dangerous_content_and_invalid_calls_without_writing() {
let cases = [
response("load .env.local", Vec::new()),
response(
"safe text",
vec![call(
"call-env-path",
"file.write",
r#"{"path":".env.local","content":"placeholder"}"#,
)],
),
response("sk-0123456789abcdef", Vec::new()),
response(
"safe text",
vec![call(
"call-secret-content",
"file.write",
r#"{"path":"game/index.html","content":"const sample = 'sk-0123456789abcdef';"}"#,
)],
),
response(
"safe text",
vec![call(
"call-bearer-content",
"file.write",
r#"{"path":"game/index.html","content":"Authorization: Bearer placeholder"}"#,
)],
),
response(
"safe text",
vec![call("call-path", "file.write", r#"{"path":"/etc/passwd"}"#)],
@@ -699,13 +723,13 @@ fn tool_plan_handoff_allows_ordinary_source_in_content_html_and_patch_fields() {
let identity = identity("loop-0-repair-0");
let arguments = serde_json::json!({
"path": "src/security-form.ts",
"content": "const token = props.token; const apiKey = options.apiKey; const samplePath = '/home/example';",
"content": "const token = props.token; const apiKey = options.apiKey; const samplePath = '/home/example'; const envExample = '.env.local';",
"html": "<input name=\"password\" autocomplete=\"current-password\">",
"patch": "const defaults = { client_secret: label, private_key: fieldName };"
"patch": "const defaults = { client_secret: label, private_key: fieldName, config: 'game-creator.config' };"
})
.to_string();
let expected = response(
"safe source plan",
"无需读取 .env 或 game-creator.config;只修改普通源码。",
vec![call("call-source-fields", "file.write", &arguments)],
);
@@ -3661,3 +3661,11 @@
- 原因:把“入口变了”误当成“本轮全部正式产物都新鲜”,并直接复用资源 manifest 的完整 source 对象写公开审计。manifest 的本地来源元数据与公开 event / agentDb 的最小身份字段不是同一个安全边界。
- 处理:完成合同升级为 `game-creator-autonomous-completion-contract.v2``baselineArtifacts` 必填并参与指纹;旧 v1、缺基线或提交前身份漂移失败关闭。资源 manifest 可以保留 prompt,但 `asset.register / asset.update` 写审计前必须清除 `source.prompt`,只保留资源身份和模型。
- 并发补验:revision 漂移、repository context drift 和项目锁竞争只能在同一 logical run 有界重试;只有明确 blocker / repair 或成功 `ok` observation 才可重放终态,失败 observation 不能当通过,只读 Agent 不能借补验获得命令权限,completion 统计仍只能增加一次。
## tool-plan handoff 不能把计划叙述和源码字段当成配置载荷扫描
- 现象:Provider 已返回 HTTP 200 并计费,tool-plan lifecycle 却只有 `started`handoff 账本停在上一 loopRuntime 进入 `needs-reconciliation`;重启 Runner 或 `/resume` 后仍原样被屏障阻断。
- 原因:在解析 function arguments 之前,对整段 `response.text` 和序列化 arguments 统一执行 `.env``game-creator.config` 等字面标记扫描。安全叙述如“无需读取 `.env`”,或 `oldText / newText / content / patch` 中的普通源码字面量,会在真实路径和内容字段尚未区分时被误判。原始响应未成功交接时不会留下正文,因此现场只能结合 loop 边界和最小复现定位,不能把高概率分支冒充已恢复的原响应证据。
- 处理:计划叙述与规范源码内容字段只检查真实密钥 token 形状、凭据头标记和不安全控制字符;结构化敏感 JSON key、非内容字段的配置痕迹和绝对路径、真实 token、容量、thinking、身份、顺序及账本完整性继续失败关闭。成功 handoff 失败时只在 Runtime event/state 和 Agent DB 保存受控 `failureKind`、脱敏错误 SHA-256、字符数与 requestId,禁止保存正文、arguments、密钥和绝对路径。
- 验证:必须同时覆盖 narrative 和 `oldText / newText / content / html / patch` 提及 `.env` / `game-creator.config` 可 round-trip`path=.env.local``sk-...` 真实 token 仍拒绝,全部 handoff 回归通过;诊断审计必须断言不存在 `error / response / arguments` 原文。修复后的外部 Provider 重试仍需新起独立轮次,不能与故障轮或确定性回归拼接为 PASS。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs`
@@ -733,3 +733,4 @@ game-project/
- `design-foundation` 已增加专属职责边界:项目文件只允许写 `memory/project.md``game/game_design.md`;配置 External Editor API Key 且合同要求界面原型时,只额外允许固定 `assets/ui-prototype.png`。它不得创建、修改、删除或补丁 `game/index.html`,不得改动其它程序实现、发布、音频或美术素材,也不得调用 `preview.start``preview.validate``game.static_smoke`,或借 `command.exec / command.start / command.run_limited` 启动预览服务、浏览器、Playwright 和桌面 / 移动试玩。程序和质量 Agent 的共享 Runtime 工具合同不因此缩减;有 / 无画布配置和其它 Agent 不受影响的聚焦回归为 `3/3` 通过。
- `canvas.asset_generate.replaceExisting` 默认并必须保持 `false`;只有静态专业 Agent 的 `delegated-*` 唯一 repair run 才能申请 `true`。Runtime 要求当前 delivery 带 `repairOfDelegationId`,原 delivery 已被同一父 Agent / 父 run 认领,原始与返工合同的目标 Agent 和精确 `expectedArtifacts` 路径一致;普通 run、未声明路径、错误 Agent、未认领原交付或缺失原图都失败关闭。图片生成仍服从 `design-foundation` / `art-asset-plan` 的固定输出路径、比例、尺寸、kind 和 label,禁止先删除正式图片;请求前记录旧文件 SHA-256,外部生成返回后在项目写锁内复核,旧图在网络请求期间变化即拒绝覆盖。授权替换先写私有临时文件,再以备份 / rename 切换;落盘或 manifest 登记失败时恢复旧图,不把新旧文件并存状态当作成功。
- 2026-07-27 新起的“16 任务正式产物 + 两张真实画布图片 + current revision 静态 / 双视口浏览器 / PNG 证据 + 受限 repair 替换”独立外部 Provider 验收,使用 `npm run agc:test:chat -- --timeout-minutes 75`,约 `59m50s` 后以退出码 `0` 完整 **PASS**。同一轮真实生成并登记 `assets/ui-prototype.png``2829418` bytes)与 `assets/art-spritesheet.png``1361906` bytes),固定 `16` 个 manifest task 均为当前父 Run 下唯一 logical run、一次 started、一次 completed、零 failed / cancelled 和一次 manifest projection;七份基础正式产物、两张 PNG、当前 revision 的 `game.static_smoke`、desktop / mobile `lane-defense-v1` playtest、浏览器报告与截图全部通过。`turn.report=settled` 且唯一 assistantbusy / pending / running / confirmation / user-input / reconciliation 均为 `0`;隔离 Runner、一次性项目和隔离 AppData 已自动清理。此前失败轮继续独立保留,不与本轮拼接;未来合同变化仍须新起完整轮次复验。
- 2026-07-27 补充 tool-plan 成功响应交接的内容边界:Provider 的自然语言计划叙述,以及结构化 arguments 中 `body / code / content / css / html / newText / oldText / patch / script / text` 等源码内容字段,只检查真实密钥 token 形状、凭据头标记和不安全控制字符;仅仅提及 `.env``game-creator.config` 不能阻断已经计费的安全响应。结构化输入中的敏感 JSON key、非内容字段中的配置痕迹或绝对路径、真实 token、容量、thinking、身份、顺序和账本完整性门禁仍失败关闭。成功 handoff 失败进入 reconciliation 时,Runtime 额外只持久化受控 `failureKind`、脱敏错误 SHA-256 和字符数,不保存 Provider 正文、function arguments、密钥或绝对路径。定向回归覆盖叙述/源码字段放行、`.env.local` 路径和真实 token 拒绝、全部 tool-plan handoff 回归及诊断零正文。