From b18b3673828da2e52230b3279c8d35ef20840a71 Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Mon, 27 Jul 2026 17:01:11 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BF=90=E8=A1=8C=E7=BB=AD?= =?UTF-8?q?=E4=BD=9C=E4=B8=8E=E5=B7=A5=E5=85=B7=E8=B7=AF=E5=BE=84=E4=BA=A4?= =?UTF-8?q?=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 无活动 Runtime 时将自然语言继续转换为新执行并保留显式恢复语义 成功响应落账前按工具契约规范化项目内绝对路径 拒绝重复键隐藏路径、动态 MCP 和项目外路径的交接放宽 补齐续作分流、路径重放和失败关闭回归测试 同步更新 Runtime 技术方案与共享踩坑记录 --- .../src-tauri/src/swarm_cli/tests.rs | 14 ++ .../src-tauri/src/swarm_cli/turn_dispatch.rs | 10 + .../tool_plan_handoff/content_validation.rs | 9 + .../src-tauri/src/tool_plan_handoff/ledger.rs | 189 ++++++++++++++++- .../src-tauri/src/tool_plan_handoff/tests.rs | 192 +++++++++++++++++- docs/project-memory/shared-memory/pitfalls.md | 14 ++ ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 + 7 files changed, 412 insertions(+), 18 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs index 9d9ff52e5..05e6e3049 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs @@ -239,6 +239,20 @@ fn resume_is_an_explicit_control_command() { ); } +#[test] +fn natural_language_resume_without_active_runtime_starts_a_new_run() { + assert_eq!( + normalize_interaction_action_without_active_runtime(AgentInteractionAction::Resume), + AgentInteractionAction::Execute + ); + assert_eq!( + normalize_interaction_action_without_active_runtime(AgentInteractionAction::Reply( + "记得之前的工作".to_string() + )), + AgentInteractionAction::Reply("记得之前的工作".to_string()) + ); +} + #[test] fn parent_runtime_matching_is_scoped_to_requested_profile() { let mut standard = runtime("running", "planning", 0); diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs index 2477acf33..fbdd800e6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs @@ -201,6 +201,7 @@ pub(super) fn handle_swarm_user_turn( } else { AgentInteractionAction::Execute }; + let action = normalize_interaction_action_without_active_runtime(action); writeln!(output, "[意图] {}", action.label()) .map_err(|error| format!("写入终端失败:{error}"))?; match action { @@ -232,6 +233,15 @@ pub(super) fn handle_swarm_user_turn( } } +pub(super) fn normalize_interaction_action_without_active_runtime( + action: AgentInteractionAction, +) -> AgentInteractionAction { + match action { + AgentInteractionAction::Resume => AgentInteractionAction::Execute, + action => action, + } +} + fn decide_interaction_action( root: &Path, parent_agent_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs index 9b92a731d..d8160e32d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs @@ -250,6 +250,15 @@ fn validate_source_or_narrative_content(label: &str, value: &str) -> Result<(), fn validate_tool_plan_arguments(root: &Path, label: &str, value: &str) -> Result<(), String> { validate_secret_tokens_and_controls(label, value)?; + if crate::agent_native_tools::validate_agent_runtime_protocol_json( + value, + "校验 tool-plan arguments JSON 失败", + ) + .is_err() + { + validate_json_like_sensitive_keys(label, value)?; + validate_json_like_absolute_path_inputs(label, value)?; + } match serde_json::from_str::(value) { Ok(json) => { validate_json_sensitive_keys(label, &json)?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/ledger.rs index 99dbec362..2599ecd0b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/ledger.rs @@ -13,8 +13,9 @@ use super::model::{ AgentRuntimeToolPlanHandoffEntry, AgentRuntimeToolPlanHandoffLedger, AgentRuntimeToolPlanHandoffLookup, AgentRuntimeToolPlanHandoffResponse, AgentRuntimeToolPlanHandoffToolCall, AgentRuntimeToolPlanHandoffUsage, - TOOL_PLAN_HANDOFF_MAX_ENTRIES, TOOL_PLAN_HANDOFF_REQUEST_RESERVE_BYTES, - TOOL_PLAN_HANDOFF_SCHEMA_VERSION, TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES, + TOOL_PLAN_HANDOFF_ARGUMENTS_MAX_BYTES, TOOL_PLAN_HANDOFF_MAX_ENTRIES, + TOOL_PLAN_HANDOFF_REQUEST_RESERVE_BYTES, TOOL_PLAN_HANDOFF_SCHEMA_VERSION, + TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES, }; use super::storage_common::{response_fingerprint, validate_path_identity, write_ledger_at}; #[cfg(unix)] @@ -241,6 +242,11 @@ pub(super) fn response_for_persistence( response: &LlmRunResponse, ) -> Result { let thinking = normalize_thinking_for_persistence(&response.text); + let tool_calls = response + .tool_calls + .iter() + .map(|call| normalize_tool_call_for_persistence(root, call)) + .collect::, _>>()?; let persisted = AgentRuntimeToolPlanHandoffResponse { provider: response.provider, model: response.model.clone(), @@ -256,12 +262,181 @@ pub(super) fn response_for_persistence( .usage .as_ref() .map(AgentRuntimeToolPlanHandoffUsage::from), - tool_calls: response - .tool_calls - .iter() - .map(AgentRuntimeToolPlanHandoffToolCall::from) - .collect(), + tool_calls, }; validate_response(root, &persisted)?; Ok(persisted) } + +fn normalize_tool_call_for_persistence( + root: &Path, + call: &platform_llm::LlmToolCall, +) -> Result { + let arguments = normalize_tool_call_arguments(root, &call.name, &call.arguments)?; + Ok(AgentRuntimeToolPlanHandoffToolCall { + id: call.id.clone(), + name: call.name.clone(), + arguments, + }) +} + +fn normalize_tool_call_arguments( + root: &Path, + tool_name: &str, + arguments: &str, +) -> Result { + if arguments.len() > TOOL_PLAN_HANDOFF_ARGUMENTS_MAX_BYTES + || !should_normalize_tool_call_paths(tool_name) + || crate::agent_native_tools::validate_agent_runtime_protocol_json( + arguments, + "校验待规范化 tool-plan arguments 失败", + ) + .is_err() + { + return Ok(arguments.to_string()); + } + let Ok(mut value) = serde_json::from_str::(arguments) else { + return Ok(arguments.to_string()); + }; + let changed = if tool_name == crate::agent::AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME { + normalize_legacy_tool_plan_paths(root, &mut value) + } else if let Some(runtime_tool) = runtime_tool_for_native_handoff_function(tool_name) { + value + .get_mut("input") + .is_some_and(|input| normalize_runtime_tool_input_paths(root, runtime_tool, input)) + } else { + false + }; + if !changed { + return Ok(arguments.to_string()); + } + serde_json::to_string(&value) + .map_err(|error| format!("序列化 tool-plan 项目相对路径失败:{error}")) +} + +fn should_normalize_tool_call_paths(tool_name: &str) -> bool { + tool_name == crate::agent::AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME + || runtime_tool_for_native_handoff_function(tool_name).is_some() +} + +fn runtime_tool_for_native_handoff_function(tool_name: &str) -> Option<&'static str> { + [ + "project.search", + "file.list", + "file.read", + "file.write", + "file.patch", + "file.delete", + "project.patchset", + "project.git_commit", + "command.exec", + "command.start", + "image.inspect", + "canvas.asset_generate", + ] + .into_iter() + .find(|tool| { + crate::agent_native_tools::native_runtime_function_name(tool).as_deref() == Some(tool_name) + }) +} + +fn normalize_legacy_tool_plan_paths(root: &Path, value: &mut serde_json::Value) -> bool { + let Some(actions) = value + .get_mut("actions") + .and_then(serde_json::Value::as_array_mut) + else { + return false; + }; + actions.iter_mut().fold(false, |changed, action| { + let Some(tool) = action + .get("tool") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + else { + return changed; + }; + let normalized = action + .get_mut("input") + .is_some_and(|input| normalize_runtime_tool_input_paths(root, &tool, input)); + normalized || changed + }) +} + +fn normalize_runtime_tool_input_paths( + root: &Path, + tool: &str, + value: &mut serde_json::Value, +) -> bool { + match tool { + "project.search" | "file.list" => normalize_string_field(root, value, "path", true), + "file.read" | "file.write" | "file.patch" | "file.delete" => { + normalize_string_field(root, value, "path", false) + } + "project.patchset" => value + .get_mut("changes") + .and_then(serde_json::Value::as_array_mut) + .is_some_and(|changes| { + changes.iter_mut().fold(false, |changed, change| { + normalize_string_field(root, change, "path", false) || changed + }) + }), + "project.git_commit" | "image.inspect" => { + normalize_string_array_field(root, value, "paths") + } + "command.exec" | "command.start" => normalize_string_field(root, value, "cwd", true), + "canvas.asset_generate" => normalize_string_field(root, value, "outputPath", false), + _ => false, + } +} + +fn normalize_string_field( + root: &Path, + object: &mut serde_json::Value, + key: &str, + allow_project_root: bool, +) -> bool { + let Some(field) = object.get_mut(key) else { + return false; + }; + let Some(relative) = field + .as_str() + .and_then(|value| normalize_project_absolute_path(root, value, allow_project_root)) + else { + return false; + }; + *field = serde_json::Value::String(relative); + true +} + +fn normalize_string_array_field(root: &Path, object: &mut serde_json::Value, key: &str) -> bool { + object + .get_mut(key) + .and_then(serde_json::Value::as_array_mut) + .is_some_and(|values| { + values.iter_mut().fold(false, |changed, value| { + let normalized = value + .as_str() + .and_then(|value| normalize_project_absolute_path(root, value, false)) + .is_some_and(|relative| { + *value = serde_json::Value::String(relative); + true + }); + normalized || changed + }) + }) +} + +fn normalize_project_absolute_path( + root: &Path, + value: &str, + allow_project_root: bool, +) -> Option { + let candidate = Path::new(value); + if !candidate.is_absolute() { + return None; + } + if candidate == root { + return allow_project_root.then(|| ".".to_string()); + } + crate::project::relative_project_path(root, candidate).ok() +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs index 75194cb00..dc88d02b2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs @@ -573,29 +573,199 @@ fn tool_plan_handoff_rejects_dangerous_content_and_invalid_calls_without_writing !tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id,).exists() ); } +} +#[test] +fn tool_plan_handoff_normalizes_project_absolute_paths_before_round_trip() { let project = tempdir().expect("tool-plan handoff project"); let identity = identity("loop-0-repair-0"); - let project_path_argument = format!(r#"{{"path":"{}"}}"#, project.path().display()); - write_at( + let game_path = project.path().join("game/index.html"); + let asset_path = project.path().join("assets/ui.png"); + let root_text = project.path().to_string_lossy().into_owned(); + let expected_content = format!("const projectRootExample = {root_text:?};"); + let entry = write( + project.path(), + &identity, + 0, + &response( + "safe text", + vec![ + call( + "call-project-path", + "runtime_tool_file_write", + &serde_json::json!({ + "reason": "写入页面", + "input": { + "path": game_path, + "content": expected_content, + }, + }) + .to_string(), + ), + call( + "call-project-cwd", + "runtime_tool_command_exec", + &serde_json::json!({ + "reason": "运行测试", + "input": { + "program": "npm", + "args": ["test"], + "cwd": project.path(), + }, + }) + .to_string(), + ), + call( + "call-project-paths", + "runtime_tool_project_git_commit", + &serde_json::json!({ + "reason": "提交修改", + "input": { + "message": "测试", + "paths": [game_path, asset_path], + }, + }) + .to_string(), + ), + ], + ), + ); + let replayed = entry.to_llm_response(); + let file_write: serde_json::Value = + serde_json::from_str(&replayed.tool_calls[0].arguments).expect("file.write arguments"); + assert_eq!(file_write["input"]["path"], "game/index.html"); + assert_eq!(file_write["input"]["content"], expected_content); + let command: serde_json::Value = + serde_json::from_str(&replayed.tool_calls[1].arguments).expect("command arguments"); + assert_eq!(command["input"]["cwd"], "."); + let git_commit: serde_json::Value = + serde_json::from_str(&replayed.tool_calls[2].arguments).expect("git commit arguments"); + assert_eq!( + git_commit["input"]["paths"], + serde_json::json!(["game/index.html", "assets/ui.png"]) + ); + + let persisted = read_for_run_at(project.path(), &identity.agent_id, &identity.run_id) + .expect("read normalized handoff") + .expect("normalized handoff ledger"); + assert_eq!(persisted.entries[0].to_llm_response(), replayed); +} + +#[test] +fn tool_plan_handoff_normalizes_legacy_wrapper_paths_without_changing_source() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let path = project.path().join("game/index.html"); + let old_text = format!("const root = {:?};", project.path().to_string_lossy()); + let arguments = serde_json::json!({ + "thinkingSummary": "修复页面", + "planUpdate": null, + "plan": [], + "actions": [{ + "tool": "file.patch", + "reason": "修复页面", + "input": { + "path": path, + "oldText": old_text, + "newText": "const ready = true;", + "expectedReplacements": 1, + }, + }], + "response": "", + }) + .to_string(); + let entry = write( project.path(), &identity, - &identity.base_request_slot, 0, - &provider_request_id("project-path"), &response( "safe text", vec![call( - "call-project-path", - "file.write", - &project_path_argument, + "call-legacy-project-path", + "submit_agent_tool_plan", + &arguments, )], ), - ) - .expect_err("project path must fail"); - assert!( - !tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id,).exists() ); + let replayed: serde_json::Value = + serde_json::from_str(&entry.to_llm_response().tool_calls[0].arguments) + .expect("legacy arguments"); + assert_eq!(replayed["actions"][0]["input"]["path"], "game/index.html"); + assert_eq!(replayed["actions"][0]["input"]["oldText"], old_text); +} + +#[test] +fn tool_plan_handoff_does_not_rewrite_dynamic_mcp_or_unknown_arguments() { + for (index, tool_name) in [ + "mcp_tool_0123456789abcdef01234567", + "file.write", + "unknown_tool", + ] + .into_iter() + .enumerate() + { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let arguments = serde_json::json!({ + "reason": "读取", + "input": { "path": project.path().join("game/index.html") }, + }) + .to_string(); + let error = write_at( + project.path(), + &identity, + &identity.base_request_slot, + 0, + &provider_request_id(&format!("untrusted-project-path-{index}")), + &response( + "safe text", + vec![call("call-untrusted-project-path", tool_name, &arguments)], + ), + ) + .expect_err("dynamic MCP and unknown arguments must not be rewritten"); + assert!(error.contains("绝对路径"), "unexpected error: {error}"); + } +} + +#[test] +fn tool_plan_handoff_does_not_normalize_duplicate_key_or_file_root_inputs() { + for index in 0..3 { + let project = tempdir().expect("tool-plan handoff project"); + let project_arguments = if index == 0 { + format!( + r#"{{"reason":"first","reason":"second","input":{{"path":{}}}}}"#, + serde_json::to_string(&project.path().join("game/index.html")) + .expect("serialize duplicate path") + ) + } else if index == 1 { + serde_json::json!({ + "reason": "错误地把项目根当文件", + "input": { "path": project.path() }, + }) + .to_string() + } else { + r#"{"reason":"隐藏绝对路径","input":{"path":"/etc/passwd","path":"game/index.html"}}"# + .to_string() + }; + let identity = identity("loop-0-repair-0"); + let error = write_at( + project.path(), + &identity, + &identity.base_request_slot, + 0, + &provider_request_id(&format!("non-normalizable-project-path-{index}")), + &response( + "safe text", + vec![call( + "call-non-normalizable-project-path", + "runtime_tool_file_write", + &project_arguments, + )], + ), + ) + .expect_err("duplicate keys and file-root paths must remain rejected"); + assert!(error.contains("绝对路径"), "unexpected error: {error}"); + } } #[test] diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index ef7d7df3b..e694a6326 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3669,3 +3669,17 @@ - 处理:计划叙述与规范源码内容字段只检查真实密钥 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`。 + +## 自然语言“继续修复”不能在无活动 Runtime 时落入空 resume + +- 现象:旧 run 已取消且 `/resume` 明确报告无可恢复任务,用户随后输入“继续”“继续之前干的事情”或“那就继续修复”,interaction 仍返回 `resume`,宿主反复扫描后不新建任务。 +- 原因:interaction 模型能看到会话历史,却不知道宿主已经排除了 active、pending 和可 steer Runtime;宿主又把自然语言 `resume` 与显式 `/resume` 当成相同控制动作机械执行。 +- 处理:活动 Runtime、排队任务和 Goal 仍在 interaction 前优先 steer/恢复;只有这些门禁全部为空后,自然语言 interaction 返回的 `resume` 才规范化为 `execute` 并创建新 run。显式 `/resume` 继续保持纯恢复控制,不因无任务而隐式执行。 +- 验证:保留自然语言必须进入统一 interaction loop 与显式 `/resume` 命令测试,并新增无活动 Runtime 时 `Resume -> Execute`、普通 reply 不变的回归。 + +## tool-plan 中项目内绝对路径应在成功交接时规范化 + +- 现象:Provider 已成功返回原生工具调用,但 `path / paths / cwd / outputPath` 等结构化输入使用了当前项目根目录内的绝对路径;交接安全门禁以 `tool-plan-absolute-path` 失败,run 进入 `needs-reconciliation`,后续“继续”只能排队。 +- 原因:Runtime 工具最终只接受项目相对路径,但 Provider 不一定始终遵守提示;交接层此前只能拒绝全部绝对路径,无法区分“当前项目内、可无损转换”的输入与项目外越界输入。 +- 处理:成功响应写入 tool-plan handoff 前,只对内置 Runtime 原生函数和 legacy tool-plan wrapper 的合法、无重复 key JSON arguments 按工具 schema 的精确位置做规范化;仅改写完整字符串且位于当前项目根目录内的 `file.*.path`、`project.patchset.changes[*].path`、`project.git_commit.paths[*]`、`command.*.cwd`、`image.inspect.paths[*]` 与 `canvas.asset_generate.outputPath` 等真实路径字段。源码/叙述字段、任务产物描述和动态 MCP arguments 不改写。项目外绝对路径、畸形或重复 key JSON、敏感 key 和真实凭据继续失败关闭。规范化后的 handoff 同时作为当前进程执行值和重启 replay 值,避免 live/restart 语义漂移。 +- 验证:覆盖项目内 `path`、项目根 `cwd`、`paths` 数组的相对化与落账重放,源码 `content` 原文保持不变,动态 MCP 和项目外绝对路径仍拒绝,并运行全部 tool-plan handoff 回归。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 2ca1c2f0d..a1135bbd1 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -733,3 +733,5 @@ game-project/ - `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` 且唯一 assistant,busy / 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 回归及诊断零正文。 +- 自然语言 interaction 的 `resume` 只代表“继续当前未完成 Runtime”。宿主在进入 interaction 前已确认当前 Session 没有可 steer、pending 或 running 的 Runtime 时,模型返回的自然语言 `resume` 必须规范化为 `execute`,基于会话历史新建 run;显式 `/resume` 仍只执行恢复扫描且无任务时不新建,避免“那就继续修复”被反复吞成空恢复。 +- tool-plan 成功响应落账前,对内置 Runtime 原生函数与 legacy wrapper 的合法、无重复 key JSON arguments 按工具 schema 的精确位置做项目路径 canonicalization:`file.*.path`、`project.patchset.changes[*].path`、`project.git_commit.paths[*]`、`command.*.cwd`、`image.inspect.paths[*]` 与 `canvas.asset_generate.outputPath` 若是当前项目根目录内的完整绝对路径,转换为 `/` 分隔的项目相对路径后再校验、持久化并执行;源码/叙述字段、任务产物描述、动态 MCP arguments 和项目外绝对路径不得改写,后两者继续由绝对路径门禁失败关闭。项目根只允许搜索/列举范围与命令 cwd 规范化为 `.`,不能成为文件目标。当前进程与重启恢复都必须从同一份规范化 handoff 重放,禁止分别执行原响应和持久响应。