合并 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)],
);