修复运行续作与工具路径交接
Project CI / Repository checks (push) Failing after 52s
Project CI / Frontend tests (push) Successful in 2m22s
Project CI / Backend tests (push) Successful in 4m55s
Project CI / Native shell tests (push) Failing after 8m37s

无活动 Runtime 时将自然语言继续转换为新执行并保留显式恢复语义
成功响应落账前按工具契约规范化项目内绝对路径
拒绝重复键隐藏路径、动态 MCP 和项目外路径的交接放宽
补齐续作分流、路径重放和失败关闭回归测试
同步更新 Runtime 技术方案与共享踩坑记录
This commit is contained in:
AIGameCreator App
2026-07-27 17:01:11 +08:00
parent 4522f9dca1
commit b18b367382
7 changed files with 412 additions and 18 deletions
@@ -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);
@@ -201,6 +201,7 @@ pub(super) fn handle_swarm_user_turn<W: Write>(
} 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<W: Write>(
}
}
pub(super) fn normalize_interaction_action_without_active_runtime(
action: AgentInteractionAction,
) -> AgentInteractionAction {
match action {
AgentInteractionAction::Resume => AgentInteractionAction::Execute,
action => action,
}
}
fn decide_interaction_action<W: Write>(
root: &Path,
parent_agent_id: &str,
@@ -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::<serde_json::Value>(value) {
Ok(json) => {
validate_json_sensitive_keys(label, &json)?;
@@ -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<AgentRuntimeToolPlanHandoffResponse, String> {
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::<Result<Vec<_>, _>>()?;
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<AgentRuntimeToolPlanHandoffToolCall, String> {
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<String, String> {
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::<serde_json::Value>(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<String> {
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()
}
@@ -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]
@@ -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 回归。
@@ -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` 且唯一 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 回归及诊断零正文。
- 自然语言 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 重放,禁止分别执行原响应和持久响应。