修复运行续作与工具路径交接
无活动 Runtime 时将自然语言继续转换为新执行并保留显式恢复语义 成功响应落账前按工具契约规范化项目内绝对路径 拒绝重复键隐藏路径、动态 MCP 和项目外路径的交接放宽 补齐续作分流、路径重放和失败关闭回归测试 同步更新 Runtime 技术方案与共享踩坑记录
This commit is contained in:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user