合并 codex/ai-game-creator-app 工具计划响应交接误判修复
This commit is contained in:
@@ -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,
|
||||
|
||||
+102
-10
@@ -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(())
|
||||
}
|
||||
|
||||
+6
-6
@@ -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 命中敏感规则 #0:PRIVATE_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)],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user