修复运行续作与工具路径交接
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]