From b65541ce4c752f4058999aa90fbeec0f7279471f Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Fri, 10 Jul 2026 23:40:33 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90Agent=E5=A7=94=E6=B4=BE?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E5=9B=9E=E6=89=A7=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 持久化父子任务关联并将各类终态结果幂等回执给父Agent 补齐并发锁、取消抑制、恢复补漏和对话落盘失败门禁 展示委派来源并完善Runtime回归测试与技术文档 --- .../src-tauri/src/agent.rs | 1169 +++++++++++++++-- .../src-tauri/src/main.rs | 14 + .../src-tauri/src/project.rs | 67 + .../src-tauri/src/tests.rs | 1064 ++++++++++++++- apps/ai-game-creator-shell/src/App.tsx | 40 +- .../tests/appSurface.test.ts | 40 +- .../shared-memory/decision-log.md | 3 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 1 + 8 files changed, 2289 insertions(+), 109 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index e1a334b19..6a6d12785 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -13,6 +13,15 @@ const AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED: &str = "observed-re pub(crate) const AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO: &str = "auto"; pub(crate) const AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION: &str = "confirmation"; pub(crate) const AGENT_RUNTIME_ACTION_FINGERPRINT_VERSION: &str = "sha256-serde-json-v1"; +const AGENT_RUNTIME_DELEGATE_RECEIPT_SOURCE: &str = "agent-delegate-receipt"; +const AGENT_RUNTIME_DELEGATE_RECEIPT_TASK_MAX_CHARS: usize = 900; + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +struct AgentRuntimeTaskLink { + parent_agent_id: Option, + parent_run_id: Option, + delegation_id: Option, +} pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHandle) { let _ = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.set(app); @@ -444,7 +453,9 @@ pub(crate) fn resume_game_creator_agent_background_tasks_at( } AgentRuntimePendingActionResume::NotFound(runtime_lock) => runtime_lock, }; - let Some(task) = read_recoverable_game_creator_agent_runtime_task(root, &agent_id)? else { + let Some(task) = + read_recoverable_runnable_game_creator_agent_runtime_task(root, &agent_id)? + else { continue; }; let source = if task.source.trim().is_empty() { @@ -464,9 +475,11 @@ pub(crate) fn resume_game_creator_agent_background_tasks_at( ) { Ok(state) => state, Err(error) => { - let mut fallback = - default_game_creator_agent_runtime_state(&agent_id, &task.run_id); - fallback.session_id = task.session_id.clone(); + if task.source == AGENT_RUNTIME_DELEGATE_RECEIPT_SOURCE { + record_game_creator_agent_runtime_receipt_start_warning(root, &task, &error); + continue; + } + let fallback = agent_runtime_state_from_task_record(&task); let _ = fail_game_creator_agent_runtime_turn_at(root, fallback, &error); continue; } @@ -501,6 +514,7 @@ pub(crate) fn resume_game_creator_agent_background_tasks_at( }); resumed.push(result); } + reconcile_game_creator_agent_delegate_receipts_at(root)?; Ok(resumed) } @@ -703,17 +717,6 @@ pub(crate) fn start_game_creator_agent_background_task_for_session_at( .map(|(result, _run_id)| result) } -fn start_game_creator_agent_background_task_with_run_id_at( - root: &Path, - agent_id: &str, - task: &str, - run_id: &str, -) -> Result<(AgentRuntimeResult, String), String> { - start_game_creator_agent_background_task_with_run_id_for_session_at( - root, agent_id, None, task, run_id, - ) -} - fn start_game_creator_agent_background_task_with_run_id_for_session_at( root: &Path, agent_id: &str, @@ -738,6 +741,20 @@ fn start_game_creator_agent_background_task_with_source_at( task: &str, run_id: &str, source: &str, +) -> Result<(AgentRuntimeResult, String), String> { + start_game_creator_agent_background_task_with_link_at( + root, agent_id, session_id, task, run_id, source, None, + ) +} + +fn start_game_creator_agent_background_task_with_link_at( + root: &Path, + agent_id: &str, + session_id: Option<&str>, + task: &str, + run_id: &str, + source: &str, + task_link: Option<&AgentRuntimeTaskLink>, ) -> Result<(AgentRuntimeResult, String), String> { let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; validate_project_root(root)?; @@ -751,26 +768,54 @@ fn start_game_creator_agent_background_task_with_source_at( } else { source.trim() }; - let run_id = unique_game_creator_agent_runtime_run_id(root, &agent_id, run_id)?; - let pending_task = append_game_creator_agent_runtime_pending_task( + let pending_task = append_unique_game_creator_agent_runtime_pending_task( root, &agent_id, &session_id, task, - &run_id, + run_id, source, + task_link, )?; - append_local_conversation_message_for_session_at( - root, - Some(&agent_id), - Some(&session_id), - LocalConversationMessage { - role: "user".to_string(), - content: task.to_string(), - agent_id: None, - }, - )?; - append_agent_db_record( + let run_id = pending_task.run_id.clone(); + if source != AGENT_RUNTIME_DELEGATE_RECEIPT_SOURCE { + if let Err(error) = append_local_conversation_message_for_session_at( + root, + Some(&agent_id), + Some(&session_id), + LocalConversationMessage { + role: "user".to_string(), + content: task.to_string(), + agent_id: None, + }, + ) { + let error = redact_agent_runtime_project_paths(root, &error, 500); + let failed_task = AgentRuntimeTaskRecord { + status: "failed".to_string(), + phase: "conversation-write-failed".to_string(), + current_action: "用户消息未落盘,后台任务未执行".to_string(), + terminal_detail: Some(error.clone()), + error: Some(error.clone()), + updated_at: unix_timestamp(), + ..pending_task.clone() + }; + append_game_creator_agent_runtime_task_record(root, &failed_task)?; + publish_game_creator_agent_delegate_result(root, &failed_task, Some(&error)); + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.queue_warning", + "agentId": pending_task.agent_id, + "sessionId": pending_task.session_id, + "runId": pending_task.run_id, + "warningKind": "conversation-write-failed", + "error": sanitize_agent_runtime_text(&error, 240), + }), + ); + return Err(format!("后台任务用户消息落盘失败,任务未执行:{error}")); + } + } + let _ = append_agent_db_record( root, serde_json::json!({ "recordType": "agent.runtime.background_task.queued", @@ -779,11 +824,14 @@ fn start_game_creator_agent_background_task_with_source_at( "sessionId": pending_task.session_id, "runId": pending_task.run_id, "source": pending_task.source, + "parentAgentId": pending_task.parent_agent_id, + "parentRunId": pending_task.parent_run_id, + "delegationId": pending_task.delegation_id, "status": pending_task.status, "phase": pending_task.phase, "task": pending_task.task, }), - )?; + ); let Some(runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock(root, &agent_id)? else { let result = @@ -801,7 +849,7 @@ fn start_game_creator_agent_background_task_with_source_at( return read_game_creator_agent_runtime_for_session_at(root, &agent_id, Some(&session_id)) .map(|result| (result, run_id)); } - let Some(next_task) = read_next_pending_game_creator_agent_runtime_task(root, &agent_id)? + let Some(next_task) = read_next_runnable_game_creator_agent_runtime_task(root, &agent_id)? else { emit_game_creator_agent_runtime_update(root, &agent_id); return read_game_creator_agent_runtime_for_session_at(root, &agent_id, Some(&session_id)) @@ -1065,10 +1113,14 @@ fn resolve_game_creator_agent_runtime_cancel_target( session_id: current_result.state.session_id.clone(), run_id: current_result.state.run_id.clone(), source: current_result.state.source.clone(), + parent_agent_id: current_result.state.parent_agent_id.clone(), + parent_run_id: current_result.state.parent_run_id.clone(), + delegation_id: current_result.state.delegation_id.clone(), task: current_result.state.current_task.clone(), status: game_creator_agent_runtime_task_status(¤t_result.state), phase: current_result.state.phase.clone(), current_action: current_result.state.current_action.clone(), + terminal_detail: agent_runtime_terminal_detail(¤t_result.state), error: current_result.state.error.clone(), updated_at: current_result.state.updated_at, }) @@ -1082,7 +1134,7 @@ fn resolve_game_creator_agent_runtime_cancel_target( Ok((current_result, task, has_pending_action)) } -fn append_game_creator_agent_runtime_queued_cancellation( +pub(crate) fn append_game_creator_agent_runtime_queued_cancellation( root: &Path, agent_id: &str, task: &AgentRuntimeTaskRecord, @@ -1090,18 +1142,7 @@ fn append_game_creator_agent_runtime_queued_cancellation( ) -> Result<(), String> { let cancelled_task = append_game_creator_agent_runtime_cancelled_task_record(root, task, summary)?; - let mut event_state = - default_game_creator_agent_runtime_state(agent_id, &cancelled_task.run_id); - event_state.session_id = cancelled_task.session_id.clone(); - event_state.source = cancelled_task.source.clone(); - event_state.current_task = cancelled_task.task.clone(); - event_state.current_goal = cancelled_task.task.clone(); - event_state.status = "cancelled".to_string(); - event_state.phase = "cancelled".to_string(); - event_state.current_action = cancelled_task.current_action.clone(); - event_state.waiting_on = "开发者下一轮输入".to_string(); - event_state.next_step = "可重试该后台任务或提交新任务".to_string(); - event_state.updated_at = cancelled_task.updated_at; + let event_state = agent_runtime_state_from_task_record(&cancelled_task); let _ = append_game_creator_agent_runtime_event( root, &event_state, @@ -1126,6 +1167,7 @@ fn append_game_creator_agent_runtime_queued_cancellation( )?; remove_game_creator_agent_runtime_pending_tool_action(root, agent_id, &task.run_id)?; remove_game_creator_agent_runtime_confirmations(root, agent_id, &task.run_id)?; + publish_game_creator_agent_delegate_result(root, &cancelled_task, Some(summary)); emit_game_creator_agent_runtime_update(root, agent_id); Ok(()) } @@ -1165,12 +1207,40 @@ pub(crate) fn retry_game_creator_agent_runtime_task_at( } else { next_run_id.trim().to_string() }; - let result = start_game_creator_agent_background_task_for_session_at( + let retry_identity = format!( + "retry:{}:{}:{}", + task.run_id, + retry_run_id, + unix_timestamp_nanos() + ); + let retry_link = match ( + task.parent_agent_id.as_deref(), + task.parent_run_id.as_deref(), + ) { + (Some(parent_agent_id), Some(parent_run_id)) => Some(AgentRuntimeTaskLink { + parent_agent_id: Some(parent_agent_id.to_string()), + parent_run_id: Some(parent_run_id.to_string()), + delegation_id: Some(agent_runtime_delegation_id( + parent_agent_id, + parent_run_id, + &agent_id, + &retry_identity, + )), + }), + _ => None, + }; + let (result, actual_retry_run_id) = start_game_creator_agent_background_task_with_link_at( root, &agent_id, Some(&task.session_id), &task.task, &retry_run_id, + if retry_link.is_some() { + "agent-delegate-retry" + } else { + "agent-background-task" + }, + retry_link.as_ref(), )?; append_agent_db_record( root, @@ -1180,7 +1250,7 @@ pub(crate) fn retry_game_creator_agent_runtime_task_at( "taskId": task.task_id, "sessionId": task.session_id, "runId": task.run_id, - "retryRunId": normalize_game_creator_agent_runtime_run_id(&agent_id, &retry_run_id), + "retryRunId": actual_retry_run_id, "source": task.source, "task": task.task, }), @@ -1453,12 +1523,13 @@ async fn continue_game_creator_agent_pending_tool_action( &root, &pending, ); } - let observation = execute_game_creator_agent_runtime_tool_action( + let observation = execute_game_creator_agent_runtime_tool_action_with_action_id( &root, &agent_id, &pending.run_id, &pending.task, &action, + Some(&pending.action_id), ) .await; if observation.is_waiting_for_confirmation() && auto_execution { @@ -1819,7 +1890,7 @@ async fn drain_next_game_creator_agent_background_tasks(root: PathBuf, agent_id: Ok(true) | Err(_) => break, Ok(false) => {} } - let Some(next_task) = read_next_pending_game_creator_agent_runtime_task(&root, &agent_id) + let Some(next_task) = read_next_runnable_game_creator_agent_runtime_task(&root, &agent_id) .ok() .flatten() else { @@ -1837,9 +1908,13 @@ async fn drain_next_game_creator_agent_background_tasks(root: PathBuf, agent_id: ) { Ok(state) => state, Err(error) => { - let mut fallback = - default_game_creator_agent_runtime_state(&agent_id, &next_task.run_id); - fallback.session_id = next_task.session_id.clone(); + if next_task.source == AGENT_RUNTIME_DELEGATE_RECEIPT_SOURCE { + record_game_creator_agent_runtime_receipt_start_warning( + &root, &next_task, &error, + ); + break; + } + let fallback = agent_runtime_state_from_task_record(&next_task); let _ = fail_game_creator_agent_runtime_turn_at(&root, fallback, &error); break; } @@ -1926,6 +2001,7 @@ async fn run_game_creator_agent_background_task_with_context( runtime.loop_iteration = (loop_index + 1) as u32; runtime.max_loop_iterations = AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT as u32; runtime.tool_action_budget = AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT as u32; + let fallback = runtime.clone(); runtime = match advance_game_creator_agent_runtime_turn_at( &root, runtime, @@ -1939,8 +2015,6 @@ async fn run_game_creator_agent_background_task_with_context( ) { Ok(runtime) => runtime, Err(error) => { - let mut fallback = default_game_creator_agent_runtime_state(&agent_id, ""); - fallback.session_id = session_id.clone(); let _ = fail_game_creator_agent_runtime_turn_at(&root, fallback, &error); return AgentBackgroundTaskOutcome::Finished; } @@ -2084,6 +2158,7 @@ async fn run_game_creator_agent_background_task_with_context( action_index, action.reason.as_deref().unwrap_or(action.tool.as_str()), ); + let fallback = runtime.clone(); runtime = match advance_game_creator_agent_runtime_turn_at( &root, runtime, @@ -2093,8 +2168,6 @@ async fn run_game_creator_agent_background_task_with_context( ) { Ok(runtime) => runtime, Err(error) => { - let mut fallback = default_game_creator_agent_runtime_state(&agent_id, ""); - fallback.session_id = session_id.clone(); let _ = fail_game_creator_agent_runtime_turn_at(&root, fallback, &error); return AgentBackgroundTaskOutcome::Finished; } @@ -2187,12 +2260,13 @@ async fn run_game_creator_agent_background_task_with_context( &root, &pending_action, ); - let observation = execute_game_creator_agent_runtime_tool_action( + let observation = execute_game_creator_agent_runtime_tool_action_with_action_id( &root, &agent_id, runtime.run_id.as_str(), &pending_action.task, action, + Some(&pending_action.action_id), ) .await; if observation.is_waiting_for_confirmation() { @@ -2457,6 +2531,7 @@ async fn run_game_creator_agent_background_task_with_context( &mut runtime, "Agent 已完成工具观察,正在整理最终回复。", ); + let fallback = runtime.clone(); runtime = match advance_game_creator_agent_runtime_turn_at( &root, runtime, @@ -2466,8 +2541,6 @@ async fn run_game_creator_agent_background_task_with_context( ) { Ok(runtime) => runtime, Err(error) => { - let mut fallback = default_game_creator_agent_runtime_state(&agent_id, ""); - fallback.session_id = session_id.clone(); let _ = fail_game_creator_agent_runtime_turn_at(&root, fallback, &error); return AgentBackgroundTaskOutcome::Finished; } @@ -3329,6 +3402,20 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action( run_id: &str, task: &str, action: &AgentRuntimeToolAction, +) -> AgentRuntimeToolObservation { + execute_game_creator_agent_runtime_tool_action_with_action_id( + root, agent_id, run_id, task, action, None, + ) + .await +} + +pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_action_id( + root: &Path, + agent_id: &str, + run_id: &str, + task: &str, + action: &AgentRuntimeToolAction, + action_id: Option<&str>, ) -> AgentRuntimeToolObservation { let tool = action.tool.trim(); let action_fingerprint = agent_runtime_tool_action_fingerprint(action, task); @@ -3377,7 +3464,9 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action( } "blackboard.write" => observe_agent_runtime_blackboard_write(root, agent_id, &action.input), "agent.message" => observe_agent_runtime_agent_message(root, agent_id, &action.input), - "agent.delegate" => observe_agent_runtime_agent_delegate(root, agent_id, &action.input), + "agent.delegate" => { + observe_agent_runtime_agent_delegate(root, agent_id, run_id, action_id, &action.input) + } "agent.schedule_ready" => observe_agent_runtime_schedule_ready_tasks(root, &action.input), "agent.run_status" => { observe_agent_runtime_run_status(root, agent_id, run_id, &action.input) @@ -5162,6 +5251,8 @@ fn observe_agent_runtime_agent_message( fn observe_agent_runtime_agent_delegate( root: &Path, agent_id: &str, + parent_run_id: &str, + action_id: Option<&str>, input: &serde_json::Value, ) -> AgentRuntimeToolObservation { let target_agent_id = agent_runtime_tool_input_text(input, &["agentId", "targetAgentId"]); @@ -5184,6 +5275,14 @@ fn observe_agent_runtime_agent_delegate( detail: None, }; } + if parent_run_id.trim().is_empty() { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: "当前父 Agent runId 为空,不能创建可追踪委派".to_string(), + detail: None, + }; + } let task = agent_runtime_tool_input_text(input, &["task", "content", "message", "summary"]); if task.trim().is_empty() { return AgentRuntimeToolObservation { @@ -5199,11 +5298,96 @@ fn observe_agent_runtime_agent_delegate( } else { run_id_input }; - match start_game_creator_agent_background_task_with_run_id_at( + let action_identity = action_id + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .unwrap_or_else(|| { + let encoded = serde_json::to_vec(input).unwrap_or_default(); + format!("direct-{:x}", Sha256::digest(encoded)) + }); + let delegation_id = + agent_runtime_delegation_id(agent_id, parent_run_id, &target_agent_id, &action_identity); + let _dispatch_lock = match try_acquire_game_creator_agent_delegation_lock_with_wait( + root, + &delegation_id, + "dispatch", + ) { + Ok(Some(dispatch_lock)) => dispatch_lock, + Ok(None) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: "委派提交仍被另一个 Runtime worker 占用,请稍后读取 Agent 状态" + .to_string(), + detail: Some(format!("delegationId={delegation_id}")), + }; + } + Err(error) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + }; + match read_latest_game_creator_agent_runtime_task_by_delegation_id( root, &target_agent_id, + &delegation_id, + ) { + Ok(Some(existing)) => { + if existing.parent_agent_id.as_deref() != Some(agent_id) + || existing.parent_run_id.as_deref() != Some(parent_run_id) + { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: "委派 ID 已存在但父任务关联不一致".to_string(), + detail: Some(format!( + "delegationId={}, existingRunId={}", + delegation_id, existing.run_id + )), + }; + } + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "ok".to_string(), + summary: format!("委派已存在,继续等待 {target_agent_id} 结果"), + detail: Some(format!( + "targetAgentId={}, runId={}, delegationId={}, delegateStatus=existing, targetStatus={}, targetPhase={}, task={}", + target_agent_id, + existing.run_id, + delegation_id, + existing.status, + existing.phase, + sanitize_agent_runtime_text(&existing.task, 180) + )), + }; + } + Ok(None) => {} + Err(error) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + } + let task_link = AgentRuntimeTaskLink { + parent_agent_id: Some(agent_id.to_string()), + parent_run_id: Some(parent_run_id.to_string()), + delegation_id: Some(delegation_id.clone()), + }; + match start_game_creator_agent_background_task_with_link_at( + root, + &target_agent_id, + None, &task, &run_id, + "agent-delegate", + Some(&task_link), ) { Ok((runtime, delegated_run_id)) => { let target_state = runtime.state; @@ -5220,6 +5404,8 @@ fn observe_agent_runtime_agent_delegate( "targetAgentId": target_agent_id, "targetSessionId": target_state.session_id.clone(), "runId": delegated_run_id, + "parentRunId": parent_run_id, + "delegationId": delegation_id, "status": status, "task": sanitize_agent_runtime_text(&task, 180), }), @@ -5229,9 +5415,10 @@ fn observe_agent_runtime_agent_delegate( status: "ok".to_string(), summary: format!("已委派 {target_agent_id} 后台任务"), detail: Some(format!( - "targetAgentId={}, runId={}, delegateStatus={}, targetStatus={}, targetPhase={}, task={}", + "targetAgentId={}, runId={}, delegationId={}, delegateStatus={}, targetStatus={}, targetPhase={}, task={}", target_agent_id, delegated_run_id, + delegation_id, status, target_state.status, target_state.phase, @@ -5248,6 +5435,405 @@ fn observe_agent_runtime_agent_delegate( } } +fn agent_runtime_delegation_id( + parent_agent_id: &str, + parent_run_id: &str, + target_agent_id: &str, + action_identity: &str, +) -> String { + let encoded = + format!("{parent_agent_id}\n{parent_run_id}\n{target_agent_id}\n{action_identity}"); + let fingerprint = format!("{:x}", Sha256::digest(encoded.as_bytes())); + format!( + "delegation-{}", + fingerprint.chars().take(24).collect::() + ) +} + +fn agent_runtime_delegate_receipt_run_id(delegation_id: &str) -> String { + let fingerprint = format!("{:x}", Sha256::digest(delegation_id.as_bytes())); + format!( + "delegate-receipt-{}", + fingerprint.chars().take(24).collect::() + ) +} + +fn game_creator_agent_runtime_terminal_status( + task: &AgentRuntimeTaskRecord, +) -> Option<&'static str> { + match task.phase.as_str() { + "completed" => Some("completed"), + "budget-exhausted" => Some("budget-exhausted"), + "cancelled" => Some("cancelled"), + "failed" if task.status == "failed" => Some("failed"), + _ => None, + } +} + +fn game_creator_agent_runtime_parent_blocks_delegate_receipt( + parent_task: &AgentRuntimeTaskRecord, +) -> bool { + parent_task.status == "cancelled" + || (parent_task.status == "failed" && parent_task.phase != "needs-reconciliation") +} + +fn publish_game_creator_agent_delegate_result_for_state( + root: &Path, + state: &AgentRuntimeState, + result_detail: Option<&str>, +) { + let task = + read_latest_game_creator_agent_runtime_task_by_run_id(root, &state.agent_id, &state.run_id) + .ok() + .flatten(); + let Some(task) = task else { + return; + }; + publish_game_creator_agent_delegate_result(root, &task, result_detail); +} + +pub(crate) fn publish_game_creator_agent_delegate_result( + root: &Path, + child_task: &AgentRuntimeTaskRecord, + result_detail: Option<&str>, +) { + let Some(parent_agent_id) = child_task + .parent_agent_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + else { + return; + }; + let Some(parent_run_id) = child_task + .parent_run_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + else { + record_game_creator_agent_delegate_result_failure( + root, + child_task, + "委派子任务缺少 parentRunId,无法回执", + ); + return; + }; + let Some(delegation_id) = child_task + .delegation_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + else { + record_game_creator_agent_delegate_result_failure( + root, + child_task, + "委派子任务缺少 delegationId,无法回执", + ); + return; + }; + let Some(terminal_status) = game_creator_agent_runtime_terminal_status(child_task) else { + return; + }; + let _receipt_lock = match try_acquire_game_creator_agent_delegation_lock_with_wait( + root, + delegation_id, + "receipt", + ) { + Ok(Some(receipt_lock)) => receipt_lock, + Ok(None) => return, + Err(error) => { + record_game_creator_agent_delegate_result_failure(root, child_task, &error); + return; + } + }; + let receipt_run_id = agent_runtime_delegate_receipt_run_id(delegation_id); + let receipt_exists = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + parent_agent_id, + &receipt_run_id, + ) + .ok() + .flatten() + .is_some(); + if receipt_exists { + return; + } + let parent_task = match read_latest_game_creator_agent_runtime_task_by_run_id( + root, + parent_agent_id, + parent_run_id, + ) { + Ok(Some(task)) => task, + Ok(None) => { + record_game_creator_agent_delegate_result_failure( + root, + child_task, + "未找到父 Agent run,无法投递委派回执", + ); + return; + } + Err(error) => { + record_game_creator_agent_delegate_result_failure(root, child_task, &error); + return; + } + }; + let result_detail = result_detail + .filter(|value| !value.trim().is_empty()) + .or(child_task.terminal_detail.as_deref()) + .or(child_task.error.as_deref()) + .unwrap_or(child_task.current_action.as_str()); + let result_detail = redact_agent_runtime_project_paths(root, result_detail, 600); + let receipt_task = format!( + "收到委派子任务终态回执。子 Agent:{};状态:{};结果:{}。这是已完成委派的回执,不要重复委派同一任务;请整合结果并决定后续,需要原目标时调用 conversation.read。", + child_task.agent_id, + terminal_status, + result_detail, + ); + let receipt_link = AgentRuntimeTaskLink { + parent_agent_id: None, + parent_run_id: Some(parent_run_id.to_string()), + delegation_id: Some(delegation_id.to_string()), + }; + if game_creator_agent_runtime_parent_blocks_delegate_receipt(&parent_task) { + let suppressed = AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: parent_agent_id.to_string(), + task_id: parent_agent_id.to_string(), + session_id: parent_task.session_id.clone(), + run_id: receipt_run_id.clone(), + source: AGENT_RUNTIME_DELEGATE_RECEIPT_SOURCE.to_string(), + parent_agent_id: None, + parent_run_id: Some(parent_run_id.to_string()), + delegation_id: Some(delegation_id.to_string()), + task: sanitize_agent_runtime_text(&receipt_task, 180), + status: "cancelled".to_string(), + phase: "parent-terminal".to_string(), + current_action: "父任务已取消或失败,回执仅保留审计,不自动续跑".to_string(), + terminal_detail: Some(sanitize_agent_runtime_text(&result_detail, 500)), + error: None, + updated_at: unix_timestamp(), + }; + if let Err(error) = append_game_creator_agent_runtime_task_record(root, &suppressed) { + record_game_creator_agent_delegate_result_failure(root, child_task, &error); + return; + } + record_game_creator_agent_delegate_result_success( + root, + child_task, + terminal_status, + parent_agent_id, + parent_run_id, + delegation_id, + &receipt_run_id, + "suppressed-parent-terminal", + &result_detail, + ); + return; + } + let receipt_session_id = resolve_agent_conversation_session_id_at( + root, + parent_agent_id, + Some(&parent_task.session_id), + true, + ) + .or_else(|_| resolve_agent_conversation_session_id_at(root, parent_agent_id, None, true)); + let receipt_session_id = match receipt_session_id { + Ok(session_id) => session_id, + Err(error) => { + record_game_creator_agent_delegate_result_failure(root, child_task, &error); + return; + } + }; + match start_game_creator_agent_background_task_with_link_at( + root, + parent_agent_id, + Some(&receipt_session_id), + &receipt_task, + &receipt_run_id, + AGENT_RUNTIME_DELEGATE_RECEIPT_SOURCE, + Some(&receipt_link), + ) { + Ok((runtime, actual_receipt_run_id)) => { + let receipt_status = if runtime.state.run_id == actual_receipt_run_id { + "started" + } else { + "queued" + }; + record_game_creator_agent_delegate_result_success( + root, + child_task, + terminal_status, + parent_agent_id, + parent_run_id, + delegation_id, + &actual_receipt_run_id, + receipt_status, + &result_detail, + ); + } + Err(error) => record_game_creator_agent_delegate_result_failure(root, child_task, &error), + } +} + +#[allow(clippy::too_many_arguments)] +fn record_game_creator_agent_delegate_result_success( + root: &Path, + child_task: &AgentRuntimeTaskRecord, + terminal_status: &str, + parent_agent_id: &str, + parent_run_id: &str, + delegation_id: &str, + receipt_run_id: &str, + receipt_status: &str, + result_detail: &str, +) { + let event_state = agent_runtime_state_from_task_record(child_task); + let _ = append_game_creator_agent_runtime_event( + root, + &event_state, + "agent.delegate.result", + terminal_status, + terminal_status, + "委派子任务已向父 Agent 回执。", + Some(&format!( + "parentAgentId={parent_agent_id}, parentRunId={parent_run_id}, receiptRunId={receipt_run_id}, receiptStatus={receipt_status}" + )), + ); + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.agent.delegate.result", + "agentId": child_task.agent_id, + "taskId": child_task.task_id, + "sessionId": child_task.session_id, + "runId": child_task.run_id, + "parentAgentId": parent_agent_id, + "parentRunId": parent_run_id, + "delegationId": delegation_id, + "status": terminal_status, + "receiptRunId": receipt_run_id, + "receiptStatus": receipt_status, + "resultPreview": result_detail, + }), + ); +} + +fn record_game_creator_agent_delegate_result_failure( + root: &Path, + child_task: &AgentRuntimeTaskRecord, + error: &str, +) { + let error = redact_agent_runtime_project_paths(root, error, 360); + let event_state = agent_runtime_state_from_task_record(child_task); + let _ = append_game_creator_agent_runtime_event( + root, + &event_state, + "agent.delegate.result_failed", + child_task.status.as_str(), + child_task.phase.as_str(), + "委派子任务已结束,但父 Agent 回执投递失败。", + Some(&error), + ); + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.agent.delegate.result_failed", + "agentId": child_task.agent_id, + "taskId": child_task.task_id, + "sessionId": child_task.session_id, + "runId": child_task.run_id, + "parentAgentId": child_task.parent_agent_id, + "parentRunId": child_task.parent_run_id, + "delegationId": child_task.delegation_id, + "status": child_task.status, + "phase": child_task.phase, + "error": error, + }), + ); +} + +fn reconcile_game_creator_agent_delegate_receipts_at(root: &Path) -> Result<(), String> { + for agent_id in collect_game_creator_agent_runtime_agent_ids(root)? { + let task_path = game_creator_agent_runtime_task_path(root, &agent_id); + let tasks = latest_game_creator_agent_runtime_tasks( + read_all_game_creator_agent_runtime_tasks(&task_path)?, + ); + for task in tasks { + if task.parent_agent_id.is_none() + || task.parent_run_id.is_none() + || task.delegation_id.is_none() + || game_creator_agent_runtime_terminal_status(&task).is_none() + { + continue; + } + let result_detail = + if let Ok(runtime) = read_game_creator_agent_runtime_at(root, &agent_id) { + if runtime.state.run_id == task.run_id { + runtime + .state + .last_response + .or(runtime.state.error) + .unwrap_or_else(|| task.current_action.clone()) + } else { + task.terminal_detail + .clone() + .or(task.error.clone()) + .unwrap_or_else(|| task.current_action.clone()) + } + } else { + task.terminal_detail + .clone() + .or(task.error.clone()) + .unwrap_or_else(|| task.current_action.clone()) + }; + publish_game_creator_agent_delegate_result(root, &task, Some(&result_detail)); + } + } + Ok(()) +} + +fn ensure_game_creator_agent_delegate_receipt_conversation_at( + root: &Path, + agent_id: &str, + session_id: &str, + receipt_task: &str, +) -> Result<(), String> { + let history = read_local_conversation_for_session_at(root, Some(agent_id), Some(session_id))?; + if history + .messages + .iter() + .any(|message| message.role == "user" && message.content == receipt_task) + { + return Ok(()); + } + append_local_conversation_message_for_session_at( + root, + Some(agent_id), + Some(session_id), + LocalConversationMessage { + role: "user".to_string(), + content: receipt_task.to_string(), + agent_id: None, + }, + ) + .map(|_| ()) +} + +fn record_game_creator_agent_runtime_receipt_start_warning( + root: &Path, + task: &AgentRuntimeTaskRecord, + error: &str, +) { + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.queue_warning", + "agentId": task.agent_id, + "sessionId": task.session_id, + "runId": task.run_id, + "warningKind": "receipt-conversation-write-failed", + "error": redact_agent_runtime_project_paths(root, error, 240), + }), + ); +} + fn observe_agent_runtime_schedule_ready_tasks( root: &Path, input: &serde_json::Value, @@ -5488,13 +6074,23 @@ fn format_agent_runtime_task_queue_observation(queue: &AgentRuntimeTaskQueueSumm } fn format_agent_runtime_task_observation(task: &AgentRuntimeTaskRecord) -> String { - format!( + let mut output = format!( "{} / {} / {} / {}", task.run_id, task.status, task.phase, sanitize_agent_runtime_text(&task.current_action, 120) - ) + ); + if let (Some(parent_agent_id), Some(parent_run_id)) = ( + task.parent_agent_id.as_deref(), + task.parent_run_id.as_deref(), + ) { + output.push_str(&format!(" / delegatedBy={parent_agent_id}:{parent_run_id}")); + } + if task.source == AGENT_RUNTIME_DELEGATE_RECEIPT_SOURCE { + output.push_str(" / source=delegate-receipt"); + } + output } fn format_agent_runtime_tool_call_observation(call: &AgentRuntimeToolCallRecord) -> String { @@ -5721,6 +6317,30 @@ pub(crate) fn start_game_creator_agent_runtime_task_for_session_at( return Err("Agent Runtime 任务不能为空".to_string()); } let runtime_task = sanitize_agent_runtime_text(task, 180); + if source.trim() == AGENT_RUNTIME_DELEGATE_RECEIPT_SOURCE { + ensure_game_creator_agent_delegate_receipt_conversation_at( + root, + &agent_id, + &session_id, + task, + )?; + } + let task_link = + read_latest_game_creator_agent_runtime_task_by_run_id(root, &agent_id, &run_id)? + .filter(|record| { + record.session_id == session_id + && record.source == source.trim() + && matches!( + record.status.as_str(), + "pending" | "running" | "waiting-for-confirmation" + ) + }) + .map(|record| AgentRuntimeTaskLink { + parent_agent_id: record.parent_agent_id, + parent_run_id: record.parent_run_id, + delegation_id: record.delegation_id, + }) + .unwrap_or_default(); let previous_state = read_game_creator_agent_runtime_for_session_at(root, &agent_id, Some(&session_id)) .ok() @@ -5728,6 +6348,9 @@ pub(crate) fn start_game_creator_agent_runtime_task_for_session_at( let mut state = default_game_creator_agent_runtime_state(&agent_id, &run_id); state.session_id = session_id; state.source = source.trim().to_string(); + state.parent_agent_id = task_link.parent_agent_id; + state.parent_run_id = task_link.parent_run_id; + state.delegation_id = task_link.delegation_id; state.status = "running".to_string(); state.phase = "planning".to_string(); state.current_task = runtime_task.clone(); @@ -5848,6 +6471,11 @@ pub(crate) fn finish_game_creator_agent_runtime_turn_at( )?; remove_game_creator_agent_runtime_pending_tool_action(root, &state.agent_id, &state.run_id)?; remove_game_creator_agent_runtime_confirmations(root, &state.agent_id, &state.run_id)?; + publish_game_creator_agent_delegate_result_for_state( + root, + &state, + state.last_response.as_deref(), + ); Ok(state) } @@ -5880,10 +6508,11 @@ pub(crate) fn fail_game_creator_agent_runtime_turn_at( )?; remove_game_creator_agent_runtime_pending_tool_action(root, &state.agent_id, &state.run_id)?; remove_game_creator_agent_runtime_confirmations(root, &state.agent_id, &state.run_id)?; + publish_game_creator_agent_delegate_result_for_state(root, &state, state.error.as_deref()); Ok(state) } -fn fail_game_creator_agent_runtime_budget_at( +pub(crate) fn fail_game_creator_agent_runtime_budget_at( root: &Path, mut state: AgentRuntimeState, error: &str, @@ -5912,6 +6541,7 @@ fn fail_game_creator_agent_runtime_budget_at( )?; remove_game_creator_agent_runtime_pending_tool_action(root, &state.agent_id, &state.run_id)?; remove_game_creator_agent_runtime_confirmations(root, &state.agent_id, &state.run_id)?; + publish_game_creator_agent_delegate_result_for_state(root, &state, state.error.as_deref()); Ok(state) } @@ -5989,6 +6619,9 @@ fn default_game_creator_agent_runtime_state(agent_id: &str, run_id: &str) -> Age session_id: format!("agent-session-{agent_id}"), run_id: run_id.to_string(), source: "agent-chat".to_string(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, status: "idle".to_string(), phase: "idle".to_string(), current_task: String::new(), @@ -6018,6 +6651,26 @@ fn default_game_creator_agent_runtime_state(agent_id: &str, run_id: &str) -> Age } } +fn agent_runtime_state_from_task_record(record: &AgentRuntimeTaskRecord) -> AgentRuntimeState { + let mut state = default_game_creator_agent_runtime_state(&record.agent_id, &record.run_id); + state.task_id = record.task_id.clone(); + state.session_id = record.session_id.clone(); + state.source = record.source.clone(); + state.parent_agent_id = record.parent_agent_id.clone(); + state.parent_run_id = record.parent_run_id.clone(); + state.delegation_id = record.delegation_id.clone(); + state.status = record.status.clone(); + state.phase = record.phase.clone(); + state.current_task = record.task.clone(); + state.current_goal = record.task.clone(); + state.current_action = record.current_action.clone(); + state.waiting_on = agent_runtime_waiting_on_for_phase(&record.phase).to_string(); + state.next_step = agent_runtime_next_step_for_phase(&record.phase).to_string(); + state.error = record.error.clone(); + state.updated_at = record.updated_at; + state +} + pub(crate) fn default_game_creator_agent_runtime_allowed_tools() -> Vec { agent_runtime_executable_tools() .into_iter() @@ -6351,6 +7004,125 @@ pub(crate) fn try_acquire_game_creator_agent_runtime_task_lock( Ok(Some(AgentRuntimeTaskLock { file: Some(file) })) } +fn try_acquire_game_creator_agent_delegation_lock( + root: &Path, + delegation_id: &str, + purpose: &str, +) -> Result, String> { + let lock_id = agent_runtime_confirmation_path_component(delegation_id, "delegation"); + let purpose = agent_runtime_confirmation_path_component(purpose, "lock"); + let path = root + .join(".agent") + .join("runtime") + .join("locks") + .join("delegations") + .join(purpose) + .join(format!("{lock_id}.lock")); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "创建 Agent 委派回执锁目录失败:{}: {error}", + parent.display() + ) + })?; + } + let Some(mut file) = try_open_game_creator_agent_runtime_task_lock_file(&path)? else { + return Ok(None); + }; + let payload = serde_json::json!({ + "delegationId": delegation_id, + "pid": std::process::id(), + "createdAt": unix_timestamp(), + }); + let content = serde_json::to_string_pretty(&payload) + .map_err(|error| format!("生成 Agent 委派回执锁失败:{error}"))?; + file.set_len(0) + .and_then(|_| file.seek(SeekFrom::Start(0)).map(|_| ())) + .and_then(|_| file.write_all(content.as_bytes())) + .and_then(|_| file.sync_data()) + .map_err(|error| format!("写入 Agent 委派回执锁失败:{}: {error}", path.display()))?; + Ok(Some(AgentRuntimeTaskLock { file: Some(file) })) +} + +fn try_acquire_game_creator_agent_delegation_lock_with_wait( + root: &Path, + delegation_id: &str, + purpose: &str, +) -> Result, String> { + for attempt in 0..100 { + if let Some(lock) = + try_acquire_game_creator_agent_delegation_lock(root, delegation_id, purpose)? + { + return Ok(Some(lock)); + } + if attempt < 99 { + std::thread::sleep(Duration::from_millis(10)); + } + } + Ok(None) +} + +fn acquire_game_creator_agent_runtime_task_journal_lock( + root: &Path, + agent_id: &str, +) -> Result { + for attempt in 0..100 { + if let Some(lock) = + try_acquire_game_creator_agent_runtime_task_journal_lock(root, agent_id)? + { + return Ok(lock); + } + if attempt < 99 { + std::thread::sleep(Duration::from_millis(10)); + } + } + Err(format!( + "Agent Runtime 任务账本正被其他进程写入:{agent_id}" + )) +} + +fn try_acquire_game_creator_agent_runtime_task_journal_lock( + root: &Path, + agent_id: &str, +) -> Result, String> { + let agent_id = agent_runtime_confirmation_path_component(agent_id, "agent"); + let path = root + .join(".agent") + .join("runtime") + .join("locks") + .join("task-journals") + .join(format!("{agent_id}.lock")); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "创建 Agent Runtime 任务账本锁目录失败:{}: {error}", + parent.display() + ) + })?; + } + let Some(mut file) = try_open_game_creator_agent_runtime_task_lock_file(&path)? else { + return Ok(None); + }; + let payload = serde_json::json!({ + "agentId": agent_id, + "pid": std::process::id(), + "createdAt": unix_timestamp(), + }); + let content = serde_json::to_string_pretty(&payload) + .map_err(|error| format!("生成 Agent Runtime 任务账本锁失败:{error}"))?; + file.set_len(0) + .and_then(|_| file.seek(SeekFrom::Start(0)).map(|_| ())) + .and_then(|_| file.write_all(content.as_bytes())) + .and_then(|_| file.sync_data()) + .map_err(|error| { + format!( + "写入 Agent Runtime 任务账本锁失败:{}: {error}", + path.display() + ) + })?; + Ok(Some(AgentRuntimeTaskLock { file: Some(file) })) +} + fn acquire_game_creator_agent_runtime_task_lock_with_wait( root: &Path, agent_id: &str, @@ -6589,10 +7361,14 @@ pub(crate) fn append_game_creator_agent_runtime_task( session_id: state.session_id.clone(), run_id: state.run_id.clone(), source: state.source.clone(), + parent_agent_id: state.parent_agent_id.clone(), + parent_run_id: state.parent_run_id.clone(), + delegation_id: state.delegation_id.clone(), task: state.current_task.clone(), status: game_creator_agent_runtime_task_status(state), phase: state.phase.clone(), current_action: state.current_action.clone(), + terminal_detail: agent_runtime_terminal_detail(state), error: state.error.clone(), updated_at: state.updated_at, }; @@ -6611,10 +7387,14 @@ fn append_game_creator_agent_runtime_cancelled_task_record( session_id: record.session_id.clone(), run_id: record.run_id.clone(), source: record.source.clone(), + parent_agent_id: record.parent_agent_id.clone(), + parent_run_id: record.parent_run_id.clone(), + delegation_id: record.delegation_id.clone(), task: record.task.clone(), status: "cancelled".to_string(), phase: "cancelled".to_string(), current_action: current_action.to_string(), + terminal_detail: Some(sanitize_agent_runtime_text(current_action, 500)), error: None, updated_at: unix_timestamp(), }; @@ -6622,7 +7402,7 @@ fn append_game_creator_agent_runtime_cancelled_task_record( Ok(cancelled) } -fn mark_game_creator_agent_runtime_cancelled_at( +pub(crate) fn mark_game_creator_agent_runtime_cancelled_at( root: &Path, state: &mut AgentRuntimeState, summary: &str, @@ -6663,6 +7443,7 @@ fn mark_game_creator_agent_runtime_cancelled_at( )?; remove_game_creator_agent_runtime_pending_tool_action(root, &state.agent_id, &state.run_id)?; remove_game_creator_agent_runtime_confirmations(root, &state.agent_id, &state.run_id)?; + publish_game_creator_agent_delegate_result_for_state(root, state, detail.or(Some(summary))); Ok(()) } @@ -6697,35 +7478,57 @@ fn stop_game_creator_agent_runtime_if_cancel_requested( true } -fn append_game_creator_agent_runtime_pending_task( +fn append_unique_game_creator_agent_runtime_pending_task( root: &Path, agent_id: &str, session_id: &str, task: &str, - run_id: &str, + requested_run_id: &str, source: &str, + task_link: Option<&AgentRuntimeTaskLink>, ) -> Result { + let _journal_lock = acquire_game_creator_agent_runtime_task_journal_lock(root, agent_id)?; + let run_id = unique_game_creator_agent_runtime_run_id(root, agent_id, requested_run_id)?; + let task_link = task_link.cloned().unwrap_or_default(); + let task_max_chars = if source.trim() == AGENT_RUNTIME_DELEGATE_RECEIPT_SOURCE { + AGENT_RUNTIME_DELEGATE_RECEIPT_TASK_MAX_CHARS + } else { + 180 + }; let record = AgentRuntimeTaskRecord { schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: agent_id.to_string(), task_id: agent_id.to_string(), session_id: session_id.to_string(), - run_id: run_id.to_string(), + run_id, source: source.trim().to_string(), - task: sanitize_agent_runtime_text(task, 180), + parent_agent_id: task_link.parent_agent_id, + parent_run_id: task_link.parent_run_id, + delegation_id: task_link.delegation_id, + task: sanitize_agent_runtime_text(task, task_max_chars), status: "pending".to_string(), phase: "queued".to_string(), current_action: "等待当前后台任务完成".to_string(), + terminal_detail: None, error: None, updated_at: unix_timestamp(), }; - append_game_creator_agent_runtime_task_record(root, &record)?; + append_game_creator_agent_runtime_task_record_unlocked(root, &record)?; Ok(record) } fn append_game_creator_agent_runtime_task_record( root: &Path, record: &AgentRuntimeTaskRecord, +) -> Result<(), String> { + let _journal_lock = + acquire_game_creator_agent_runtime_task_journal_lock(root, &record.agent_id)?; + append_game_creator_agent_runtime_task_record_unlocked(root, record) +} + +fn append_game_creator_agent_runtime_task_record_unlocked( + root: &Path, + record: &AgentRuntimeTaskRecord, ) -> Result<(), String> { let path = game_creator_agent_runtime_task_path(root, &record.agent_id); if let Some(parent) = path.parent() { @@ -6764,6 +7567,17 @@ fn game_creator_agent_runtime_task_status(state: &AgentRuntimeState) -> String { } } +fn agent_runtime_terminal_detail(state: &AgentRuntimeState) -> Option { + let detail = match state.phase.as_str() { + "completed" => state.last_response.as_deref(), + "failed" | "budget-exhausted" => state.error.as_deref(), + "cancelled" => Some(state.current_action.as_str()), + _ => None, + }?; + let detail = sanitize_agent_runtime_text(detail, 500); + (!detail.trim().is_empty()).then_some(detail) +} + fn read_recent_game_creator_agent_runtime_events( path: &Path, ) -> Result, String> { @@ -6855,6 +7669,129 @@ fn read_next_pending_game_creator_agent_runtime_task( .find(|record| record.status == "pending")) } +fn suppress_game_creator_agent_delegate_receipt_for_terminal_parent( + root: &Path, + receipt_task: &AgentRuntimeTaskRecord, +) -> Result { + if receipt_task.source != AGENT_RUNTIME_DELEGATE_RECEIPT_SOURCE { + return Ok(false); + } + let Some(parent_run_id) = receipt_task + .parent_run_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + else { + close_game_creator_agent_delegate_receipt_before_start( + root, + receipt_task, + "failed", + "parent-link-missing", + "委派回执缺少父 run 关联,已阻止自动续跑", + "missing-parent-run-id", + None, + )?; + return Ok(true); + }; + let Some(parent_task) = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &receipt_task.agent_id, + parent_run_id, + )? + else { + close_game_creator_agent_delegate_receipt_before_start( + root, + receipt_task, + "failed", + "parent-link-missing", + "委派回执找不到父 run,已阻止自动续跑", + "parent-run-not-found", + Some(parent_run_id), + )?; + return Ok(true); + }; + if !game_creator_agent_runtime_parent_blocks_delegate_receipt(&parent_task) { + return Ok(false); + } + close_game_creator_agent_delegate_receipt_before_start( + root, + receipt_task, + "cancelled", + "parent-terminal", + "父任务已取消或失败,排队回执不再自动续跑", + "parent-became-terminal-before-receipt-start", + Some(parent_run_id), + )?; + Ok(true) +} + +#[allow(clippy::too_many_arguments)] +fn close_game_creator_agent_delegate_receipt_before_start( + root: &Path, + receipt_task: &AgentRuntimeTaskRecord, + status: &str, + phase: &str, + current_action: &str, + reason: &str, + parent_run_id: Option<&str>, +) -> Result<(), String> { + let closed = AgentRuntimeTaskRecord { + status: status.to_string(), + phase: phase.to_string(), + current_action: current_action.to_string(), + terminal_detail: Some(sanitize_agent_runtime_text(&receipt_task.task, 500)), + error: (status == "failed").then(|| current_action.to_string()), + updated_at: unix_timestamp(), + ..receipt_task.clone() + }; + append_game_creator_agent_runtime_task_record(root, &closed)?; + remove_game_creator_agent_runtime_cancel_request( + root, + &receipt_task.agent_id, + &receipt_task.run_id, + ); + let event_state = agent_runtime_state_from_task_record(&closed); + let _ = append_game_creator_agent_runtime_event( + root, + &event_state, + "agent.delegate.result_suppressed", + status, + phase, + current_action, + parent_run_id, + ); + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.agent.delegate.result_suppressed", + "agentId": receipt_task.agent_id, + "sessionId": receipt_task.session_id, + "runId": receipt_task.run_id, + "parentRunId": parent_run_id, + "delegationId": receipt_task.delegation_id, + "status": status, + "phase": phase, + "reason": reason, + }), + ); + emit_game_creator_agent_runtime_update(root, &receipt_task.agent_id); + Ok(()) +} + +fn read_next_runnable_game_creator_agent_runtime_task( + root: &Path, + agent_id: &str, +) -> Result, String> { + loop { + let Some(task) = read_next_pending_game_creator_agent_runtime_task(root, agent_id)? else { + return Ok(None); + }; + if suppress_game_creator_agent_delegate_receipt_for_terminal_parent(root, &task)? { + continue; + } + return Ok(Some(task)); + } +} + fn game_creator_agent_runtime_has_reconciliation_barrier( root: &Path, agent_id: &str, @@ -6883,6 +7820,20 @@ fn read_latest_game_creator_agent_runtime_task_by_run_id( Ok(records.into_iter().find(|record| record.run_id == run_id)) } +fn read_latest_game_creator_agent_runtime_task_by_delegation_id( + root: &Path, + agent_id: &str, + delegation_id: &str, +) -> Result, String> { + let path = game_creator_agent_runtime_task_path(root, agent_id); + let records = + latest_game_creator_agent_runtime_tasks(read_all_game_creator_agent_runtime_tasks(&path)?); + Ok(records.into_iter().find(|record| { + record.delegation_id.as_deref() == Some(delegation_id) + && record.source != AGENT_RUNTIME_DELEGATE_RECEIPT_SOURCE + })) +} + fn read_recoverable_game_creator_agent_runtime_task( root: &Path, agent_id: &str, @@ -6908,6 +7859,21 @@ fn read_recoverable_game_creator_agent_runtime_task( .find(|record| record.status == "pending")) } +fn read_recoverable_runnable_game_creator_agent_runtime_task( + root: &Path, + agent_id: &str, +) -> Result, String> { + loop { + let Some(task) = read_recoverable_game_creator_agent_runtime_task(root, agent_id)? else { + return Ok(None); + }; + if suppress_game_creator_agent_delegate_receipt_for_terminal_parent(root, &task)? { + continue; + } + return Ok(Some(task)); + } +} + fn read_all_game_creator_agent_runtime_tasks( path: &Path, ) -> Result, String> { @@ -11512,27 +12478,50 @@ pub(crate) fn truncate_prompt_context_preserving_tail(value: &str) -> String { } pub(crate) fn sanitize_prompt_context(value: &str) -> String { - value - .lines() - .filter_map(|line| { - let lower = line.to_ascii_lowercase(); - if lower.contains(".env") - || lower.contains("game-creator.config") - || lower.contains("authorization:") - || lower.contains("cookie:") - || lower.contains("api_key") - || lower.contains("apikey") - || lower.contains("api key") - || lower.contains("token=") - || lower.contains("bearer ") - { - Some("[redacted sensitive context]".to_string()) - } else { - Some(redact_secret_tokens(line)) + let mut sanitized = Vec::new(); + let mut inside_private_key = false; + for line in value.lines() { + let lower = line.to_ascii_lowercase(); + if inside_private_key { + if lower.contains("-----end") && lower.contains("private key") { + inside_private_key = false; } - }) - .collect::>() - .join("\n") + continue; + } + if lower.contains("-----begin") && lower.contains("private key") { + sanitized.push("[redacted sensitive context]".to_string()); + inside_private_key = true; + continue; + } + if lower.contains(".env") + || lower.contains("game-creator.config") + || lower.contains("authorization:") + || lower.contains("cookie:") + || lower.contains("api_key") + || lower.contains("apikey") + || lower.contains("api key") + || lower.contains("x-api-key") + || lower.contains("x_api_key") + || lower.contains("client_secret") + || lower.contains("clientsecret") + || lower.contains("access_token") + || lower.contains("accesstoken") + || lower.contains("refresh_token") + || lower.contains("refreshtoken") + || lower.contains("password=") + || lower.contains("password:") + || lower.contains("\"password\"") + || lower.contains("secret=") + || lower.contains("token=") + || lower.contains("\"token\"") + || lower.contains("bearer ") + { + sanitized.push("[redacted sensitive context]".to_string()); + } else { + sanitized.push(redact_secret_tokens(line)); + } + } + sanitized.join("\n") } pub(crate) fn redact_secret_tokens(line: &str) -> String { diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index b8ae05f47..9f26e6665 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -148,6 +148,12 @@ struct AgentRuntimeState { #[serde(default)] source: String, #[serde(default)] + parent_agent_id: Option, + #[serde(default)] + parent_run_id: Option, + #[serde(default)] + delegation_id: Option, + #[serde(default)] status: String, #[serde(default)] phase: String, @@ -357,6 +363,12 @@ struct AgentRuntimeTaskRecord { #[serde(default)] source: String, #[serde(default)] + parent_agent_id: Option, + #[serde(default)] + parent_run_id: Option, + #[serde(default)] + delegation_id: Option, + #[serde(default)] task: String, #[serde(default)] status: String, @@ -365,6 +377,8 @@ struct AgentRuntimeTaskRecord { #[serde(default)] current_action: String, #[serde(default)] + terminal_detail: Option, + #[serde(default)] error: Option, #[serde(default)] updated_at: u64, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index c470c406c..2689706c5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -633,6 +633,73 @@ fn ensure_agent_session_has_no_live_tasks( session_id, record.run_id, record.status, record.phase )); } + let parent_run_ids = latest_by_run + .values() + .filter(|record| record.session_id == session_id) + .map(|record| record.run_id.as_str()) + .collect::>(); + if parent_run_ids.is_empty() { + return Ok(()); + } + let task_dir = root.join(".agent/runtime/tasks"); + let entries = match fs::read_dir(&task_dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(format!( + "读取 Agent Runtime 任务目录失败:{}: {error}", + task_dir.display() + )); + } + }; + for entry in entries { + let entry = entry.map_err(|error| { + format!( + "读取 Agent Runtime 任务目录项失败:{}: {error}", + task_dir.display() + ) + })?; + if !entry + .file_type() + .map_err(|error| format!("读取 Agent Runtime 任务文件类型失败:{error}"))? + .is_file() + { + continue; + } + let path = entry.path(); + let mut delegated_latest_by_run = BTreeMap::::new(); + let file = File::open(&path) + .map_err(|error| format!("读取 Agent Runtime 任务失败:{}: {error}", path.display()))?; + for line in BufReader::new(file).lines() { + let line = line.map_err(|error| { + format!("读取 Agent Runtime 任务失败:{}: {error}", path.display()) + })?; + if let Ok(record) = serde_json::from_str::(line.trim()) { + delegated_latest_by_run.insert(record.run_id.clone(), record); + } + } + let delegated_child = delegated_latest_by_run.values().find(|record| { + record.parent_agent_id.as_deref() == Some(agent_id) + && record + .parent_run_id + .as_deref() + .is_some_and(|parent_run_id| parent_run_ids.contains(&parent_run_id)) + && (matches!( + record.status.as_str(), + "pending" | "running" | "waiting-for-confirmation" | "cancelling" + ) || record.phase == "needs-reconciliation") + }); + if let Some(record) = delegated_child { + return Err(format!( + "Session {} 的父任务 {} 仍有委派子任务 {}({} / {}),不能切换或归档", + session_id, + record.parent_run_id.as_deref().unwrap_or("-"), + record.run_id, + record.status, + record.phase + )); + } + } Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tests.rs index 1e2b6809c..05e0799b8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -2893,10 +2893,14 @@ async fn background_agent_runtime_recovers_stale_running_task() { session_id: "agent-session-design-director".to_string(), run_id: "design-recover-run".to_string(), source: "agent-background-task".to_string(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, task: "恢复上一进程遗留任务".to_string(), status: "running".to_string(), phase: "planning".to_string(), current_action: "上一进程正在规划".to_string(), + terminal_detail: None, error: None, updated_at: unix_timestamp().saturating_sub(600), }; @@ -2970,10 +2974,14 @@ async fn background_agent_runtime_resume_commands_distinguish_auto_and_confirmed session_id: "agent-session-design-director".to_string(), run_id: "design-confirm-run".to_string(), source: "agent-background-task".to_string(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, task: "默认确认策略下不能静默恢复".to_string(), status: "pending".to_string(), phase: "queued".to_string(), current_action: "等待恢复".to_string(), + terminal_detail: None, error: None, updated_at: unix_timestamp(), }; @@ -3068,10 +3076,14 @@ fn background_agent_runtime_legacy_waiting_task_blocks_pending_recovery() { session_id: "agent-session-design-director".to_string(), run_id: "design-after-legacy-waiting-run".to_string(), source: "agent-background-task".to_string(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, task: "不能越过旧 waiting 的排队任务".to_string(), status: "pending".to_string(), phase: "queued".to_string(), current_action: "等待后台执行".to_string(), + terminal_detail: None, error: None, updated_at: unix_timestamp(), }, @@ -3129,10 +3141,14 @@ async fn background_agent_runtime_recovers_pending_task() { session_id: "agent-session-design-director".to_string(), run_id: "design-pending-recover-run".to_string(), source: "agent-background-task".to_string(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, task: "恢复排队后台任务".to_string(), status: "pending".to_string(), phase: "queued".to_string(), current_action: "等待后台执行".to_string(), + terminal_detail: None, error: None, updated_at: unix_timestamp().saturating_sub(60), }; @@ -3206,10 +3222,14 @@ async fn background_agent_runtime_recovers_stale_running_before_pending_task() { session_id: "agent-session-design-director".to_string(), run_id: "design-stale-running-run".to_string(), source: "agent-background-task".to_string(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, task: "先处理遗留运行任务".to_string(), status: "running".to_string(), phase: "planning".to_string(), current_action: "上一进程正在规划".to_string(), + terminal_detail: None, error: None, updated_at: unix_timestamp().saturating_sub(600), }; @@ -3220,10 +3240,14 @@ async fn background_agent_runtime_recovers_stale_running_before_pending_task() { session_id: "agent-session-design-director".to_string(), run_id: "design-pending-after-stale-run".to_string(), source: "agent-background-task".to_string(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, task: "后续排队任务".to_string(), status: "pending".to_string(), phase: "queued".to_string(), current_action: "等待当前 Agent 空闲".to_string(), + terminal_detail: None, error: None, updated_at: unix_timestamp().saturating_sub(300), }; @@ -3598,10 +3622,18 @@ async fn background_agent_runtime_can_delegate_task_to_other_agent() { "response": "" }) .to_string(); + let design_receipt_json = serde_json::json!({ + "thinkingSummary": "美术 Agent 已返回角色规范结果", + "plan": ["整合美术结果", "向开发者确认下一步"], + "actions": [], + "response": "已收到美术 Agent 的角色规范结果,接下来可以进入角色图生成。" + }) + .to_string(); let design_base_url = spawn_mock_llm_server_responses_with_capture( vec![ design_plan_json, "已把角色规范图任务委派给美术 Agent。".to_string(), + design_receipt_json, ], Some(design_sender), ); @@ -3660,17 +3692,44 @@ async fn background_agent_runtime_can_delegate_task_to_other_agent() { .expect("art plan llm request"); assert!(art_plan_request.contains("请生成月光厨师主角规范图")); assert!(art_plan_request.contains("art-director")); + let art_runtime = wait_for_agent_runtime_idle(&root, "art-director"); + assert_eq!(art_runtime.status, "idle"); + assert_eq!(art_runtime.run_id, "delegated-art-director-run"); + assert_eq!(art_runtime.source, "agent-delegate"); + assert_eq!( + art_runtime.parent_agent_id.as_deref(), + Some("design-director") + ); + assert_eq!( + art_runtime.parent_run_id.as_deref(), + Some("design-delegate-run") + ); + assert!(art_runtime.delegation_id.is_some()); + assert_eq!( + art_runtime.last_response.as_deref(), + Some("我已接收月光厨师主角规范图任务,会先整理角色外观。") + ); + let design_receipt_request = design_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("design delegate receipt llm request"); + assert!(design_receipt_request.contains("收到委派子任务终态回执")); + assert!(design_receipt_request.contains("completed")); + assert!(design_receipt_request.contains("我已接收月光厨师主角规范图任务")); + assert!(design_receipt_request.contains("不要重复委派同一任务")); let design_runtime = wait_for_agent_runtime_idle(&root, "design-director"); assert_eq!(design_runtime.status, "idle"); + assert_eq!(design_runtime.source, "agent-delegate-receipt"); + assert!(design_runtime.run_id.starts_with("delegate-receipt-")); + assert_eq!(design_runtime.delegation_id, art_runtime.delegation_id); + assert_eq!( + design_runtime.last_response.as_deref(), + Some("已收到美术 Agent 的角色规范结果,接下来可以进入角色图生成。") + ); assert!(design_runtime .tool_policy .auto_tools .contains(&"agent.delegate".to_string())); - assert!(design_runtime - .observations - .iter() - .any(|item| item.contains("agent.delegate:ok · 已委派 art-director 后台任务"))); assert!(design_runtime.recent_tool_calls.iter().any(|call| { call.tool == "agent.delegate" && call.status == "ok" @@ -3679,14 +3738,6 @@ async fn background_agent_runtime_can_delegate_task_to_other_agent() { .as_deref() .is_some_and(|detail| detail.contains("delegated-art-director-run")) })); - - let art_runtime = wait_for_agent_runtime_idle(&root, "art-director"); - assert_eq!(art_runtime.status, "idle"); - assert_eq!(art_runtime.run_id, "delegated-art-director-run"); - assert_eq!( - art_runtime.last_response.as_deref(), - Some("我已接收月光厨师主角规范图任务,会先整理角色外观。") - ); let art_conversation = read_local_conversation_at(&root, Some("art-director")).expect("art conversation"); assert!(art_conversation.messages.iter().any(|message| { @@ -3697,7 +3748,892 @@ async fn background_agent_runtime_can_delegate_task_to_other_agent() { })); let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); assert!(agent_db.contains("\"recordType\":\"agent.runtime.agent.delegate\"")); + assert!(agent_db.contains("\"recordType\":\"agent.runtime.agent.delegate.result\"")); assert!(agent_db.contains("\"targetAgentId\":\"art-director\"")); + assert!(agent_db.contains("\"parentAgentId\":\"design-director\"")); + assert!(agent_db.contains("\"receiptStatus\":")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn delegated_agent_terminal_results_queue_one_parent_receipt_each() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "委派回执测试").expect("project init"); + let parent_state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "协调多个委派子任务", + "delegate-parent-run", + "agent-background-task", + "协调子任务", + vec!["等待子任务回执".to_string()], + ) + .expect("start parent runtime"); + finish_game_creator_agent_runtime_turn_at(&root, parent_state, "已发起子任务") + .expect("finish parent runtime"); + let _parent_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director") + .expect("acquire parent lock") + .expect("parent lock available"); + + for (index, terminal_status) in ["completed", "failed", "cancelled", "budget-exhausted"] + .into_iter() + .enumerate() + { + let run_id = format!("delegated-terminal-{terminal_status}"); + let delegation_id = format!("delegation-terminal-{index}"); + write_agent_runtime_task_record_for_test( + &root, + &AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "art-director".to_string(), + task_id: "art-director".to_string(), + session_id: "agent-session-art-director".to_string(), + run_id: run_id.clone(), + source: "agent-delegate".to_string(), + parent_agent_id: Some("design-director".to_string()), + parent_run_id: Some("delegate-parent-run".to_string()), + delegation_id: Some(delegation_id.clone()), + task: format!("验证 {terminal_status} 回执"), + status: "pending".to_string(), + phase: "queued".to_string(), + current_action: "等待当前后台任务完成".to_string(), + terminal_detail: None, + error: None, + updated_at: unix_timestamp(), + }, + ); + let mut child_state = start_game_creator_agent_runtime_task_for_session_at( + &root, + "art-director", + Some("agent-session-art-director"), + &format!("验证 {terminal_status} 回执"), + &run_id, + "agent-delegate", + "开始委派子任务", + vec!["进入终态".to_string()], + ) + .expect("start delegated child runtime"); + assert_eq!( + child_state.parent_agent_id.as_deref(), + Some("design-director") + ); + assert_eq!( + child_state.parent_run_id.as_deref(), + Some("delegate-parent-run") + ); + assert_eq!( + child_state.delegation_id.as_deref(), + Some(delegation_id.as_str()) + ); + match terminal_status { + "completed" => { + finish_game_creator_agent_runtime_turn_at(&root, child_state, "子任务成功结果") + .expect("complete delegated child"); + } + "failed" => { + fail_game_creator_agent_runtime_turn_at(&root, child_state, "子任务普通失败") + .expect("fail delegated child"); + } + "cancelled" => { + mark_game_creator_agent_runtime_cancelled_at( + &root, + &mut child_state, + "子任务已取消", + Some("开发者取消委派子任务"), + ) + .expect("cancel delegated child"); + } + "budget-exhausted" => { + fail_game_creator_agent_runtime_budget_at( + &root, + child_state, + "loop-budget-exhausted", + ) + .expect("exhaust delegated child budget"); + } + _ => unreachable!(), + } + } + + let queued_cancel_task = AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "art-director".to_string(), + task_id: "art-director".to_string(), + session_id: "agent-session-art-director".to_string(), + run_id: "delegated-terminal-queued-cancel".to_string(), + source: "agent-delegate".to_string(), + parent_agent_id: Some("design-director".to_string()), + parent_run_id: Some("delegate-parent-run".to_string()), + delegation_id: Some("delegation-terminal-queued-cancel".to_string()), + task: "验证排队任务取消回执".to_string(), + status: "pending".to_string(), + phase: "queued".to_string(), + current_action: "等待当前后台任务完成".to_string(), + terminal_detail: None, + error: None, + updated_at: unix_timestamp(), + }; + write_agent_runtime_task_record_for_test(&root, &queued_cancel_task); + append_game_creator_agent_runtime_queued_cancellation( + &root, + "art-director", + &queued_cancel_task, + "开发者已取消排队后台任务", + ) + .expect("cancel queued delegated child"); + + let recovered_child = AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "art-director".to_string(), + task_id: "art-director".to_string(), + session_id: "agent-session-art-director".to_string(), + run_id: "delegated-terminal-recovery".to_string(), + source: "agent-delegate".to_string(), + parent_agent_id: Some("design-director".to_string()), + parent_run_id: Some("delegate-parent-run".to_string()), + delegation_id: Some("delegation-terminal-recovery".to_string()), + task: "模拟终态落盘后进程退出".to_string(), + status: "failed".to_string(), + phase: "failed".to_string(), + current_action: "等待开发者处理失败".to_string(), + terminal_detail: Some("进程退出前尚未回执".to_string()), + error: Some("进程退出前尚未回执".to_string()), + updated_at: unix_timestamp(), + }; + write_agent_runtime_task_record_for_test(&root, &recovered_child); + resume_game_creator_agent_background_tasks_at(&root).expect("repair delegate receipts"); + resume_game_creator_agent_background_tasks_at(&root) + .expect("repeat delegate receipt repair is idempotent"); + + let reconciliation_child = AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "art-director".to_string(), + task_id: "art-director".to_string(), + session_id: "agent-session-art-director".to_string(), + run_id: "delegated-terminal-reconciliation".to_string(), + source: "agent-delegate".to_string(), + parent_agent_id: Some("design-director".to_string()), + parent_run_id: Some("delegate-parent-run".to_string()), + delegation_id: Some("delegation-terminal-reconciliation".to_string()), + task: "等待人工核对的委派任务".to_string(), + status: "failed".to_string(), + phase: "needs-reconciliation".to_string(), + current_action: "工具动作结果需要人工核对".to_string(), + terminal_detail: None, + error: Some("副作用结果未知".to_string()), + updated_at: unix_timestamp(), + }; + write_agent_runtime_task_record_for_test(&root, &reconciliation_child); + resume_game_creator_agent_background_tasks_at(&root) + .expect("needs reconciliation must not publish receipt"); + let before_reconciliation_cancel = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read receipts before reconciliation cancel") + .recent_tasks + .into_iter() + .filter(|task| task.source == "agent-delegate-receipt") + .count(); + assert_eq!(before_reconciliation_cancel, 6); + append_game_creator_agent_runtime_queued_cancellation( + &root, + "art-director", + &reconciliation_child, + "人工核对后取消委派子任务", + ) + .expect("cancel reconciled delegated child"); + + let parent_tasks = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read parent runtime") + .recent_tasks; + let receipt_tasks = parent_tasks + .iter() + .filter(|task| task.source == "agent-delegate-receipt") + .collect::>(); + assert_eq!(receipt_tasks.len(), 7); + assert!(receipt_tasks.iter().all(|task| task.status == "pending")); + assert!(receipt_tasks + .iter() + .all(|task| task.task.contains("不要重复委派同一任务"))); + assert!(receipt_tasks + .iter() + .all(|task| !task.task.contains("协调多个委派子任务"))); + let parent_conversation = read_local_conversation_at(&root, Some("design-director")) + .expect("read parent conversation while receipts are queued"); + assert!(parent_conversation + .messages + .iter() + .all(|message| { !message.content.contains("收到委派子任务终态回执") })); + for terminal_status in ["completed", "failed", "cancelled", "budget-exhausted"] { + assert!(receipt_tasks + .iter() + .any(|task| task.task.contains(&format!("状态:{terminal_status}")))); + } + assert!(receipt_tasks + .iter() + .any(|task| task.task.contains("进程退出前尚未回执"))); + let unique_receipt_run_ids = receipt_tasks + .iter() + .map(|task| task.run_id.as_str()) + .collect::>(); + assert_eq!(unique_receipt_run_ids.len(), receipt_tasks.len()); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + assert_eq!( + agent_db + .matches("\"recordType\":\"agent.runtime.agent.delegate.result\"") + .count(), + 7 + ); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn delegated_agent_replay_reuses_durable_action_identity() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "委派动作幂等测试").expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow runtime delegation"); + start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "委派角色规范任务", + "delegate-replay-parent-run", + "agent-background-task", + "准备委派", + vec!["委派美术任务".to_string()], + ) + .expect("start parent runtime"); + let _child_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, "art-director") + .expect("acquire child lock") + .expect("child lock available"); + let action = AgentRuntimeToolAction { + tool: "agent.delegate".to_string(), + reason: Some("交给美术 Agent".to_string()), + input: serde_json::json!({ + "agentId": "art-director", + "task": "整理角色规范", + "runId": "delegate-replay-child-run" + }), + }; + + let first = execute_game_creator_agent_runtime_tool_action_with_action_id( + &root, + "design-director", + "delegate-replay-parent-run", + "委派角色规范任务", + &action, + Some("durable-action-delegate-1"), + ) + .await; + let second = execute_game_creator_agent_runtime_tool_action_with_action_id( + &root, + "design-director", + "delegate-replay-parent-run", + "委派角色规范任务", + &action, + Some("durable-action-delegate-1"), + ) + .await; + assert_eq!(first.status, "ok"); + assert_eq!(second.status, "ok"); + assert!(second.summary.contains("委派已存在")); + let child_runtime = + read_game_creator_agent_runtime_at(&root, "art-director").expect("read child runtime"); + assert_eq!(child_runtime.task_queue.total, 1); + assert_eq!(child_runtime.task_queue.pending, 1); + let child_task = child_runtime + .recent_tasks + .first() + .expect("delegated child task"); + assert_eq!(child_task.run_id, "delegate-replay-child-run"); + assert!(child_task.delegation_id.is_some()); + let child_conversation = + read_local_conversation_at(&root, Some("art-director")).expect("read child conversation"); + assert_eq!(child_conversation.messages.len(), 1); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + assert_eq!( + agent_db + .matches("\"recordType\":\"agent.runtime.agent.delegate\"") + .count(), + 1 + ); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn concurrent_delegations_allocate_distinct_target_run_ids() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "并发委派 runId 测试").expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow runtime delegation"); + for (agent_id, run_id) in [ + ("design-director", "delegate-concurrent-design-parent"), + ("code-director", "delegate-concurrent-code-parent"), + ] { + start_game_creator_agent_runtime_task_at( + &root, + agent_id, + "并发委派到同一美术 Agent", + run_id, + "agent-background-task", + "准备并发委派", + vec!["委派".to_string()], + ) + .expect("start parent runtime"); + } + let _child_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, "art-director") + .expect("acquire child execution lock") + .expect("child execution lock available"); + let action = AgentRuntimeToolAction { + tool: "agent.delegate".to_string(), + reason: Some("并发委派验收".to_string()), + input: serde_json::json!({ + "agentId": "art-director", + "task": "并发整理角色规范", + "runId": "shared-delegated-run" + }), + }; + let design = execute_game_creator_agent_runtime_tool_action_with_action_id( + &root, + "design-director", + "delegate-concurrent-design-parent", + "并发委派到同一美术 Agent", + &action, + Some("concurrent-design-action"), + ); + let code = execute_game_creator_agent_runtime_tool_action_with_action_id( + &root, + "code-director", + "delegate-concurrent-code-parent", + "并发委派到同一美术 Agent", + &action, + Some("concurrent-code-action"), + ); + let (design_result, code_result) = tokio::join!(design, code); + assert_eq!(design_result.status, "ok"); + assert_eq!(code_result.status, "ok"); + + let child_runtime = + read_game_creator_agent_runtime_at(&root, "art-director").expect("read child runtime"); + assert_eq!(child_runtime.task_queue.total, 2); + assert_eq!(child_runtime.task_queue.pending, 2); + let run_ids = child_runtime + .recent_tasks + .iter() + .map(|task| task.run_id.as_str()) + .collect::>(); + assert_eq!(run_ids.len(), 2); + assert!(run_ids.contains("shared-delegated-run")); + assert!(run_ids.iter().any(|run_id| run_id.contains("-dup-"))); + let parent_agent_ids = child_runtime + .recent_tasks + .iter() + .filter_map(|task| task.parent_agent_id.as_deref()) + .collect::>(); + assert_eq!( + parent_agent_ids, + std::collections::BTreeSet::from(["code-director", "design-director"]) + ); + let delegation_ids = child_runtime + .recent_tasks + .iter() + .filter_map(|task| task.delegation_id.as_deref()) + .collect::>(); + assert_eq!(delegation_ids.len(), 2); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn delegated_agent_receipt_publication_is_concurrency_safe() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "委派回执并发测试").expect("project init"); + let parent_state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "等待并发回执", + "delegate-concurrent-parent-run", + "agent-background-task", + "等待子任务", + vec!["接收回执".to_string()], + ) + .expect("start parent runtime"); + finish_game_creator_agent_runtime_turn_at(&root, parent_state, "父任务等待中") + .expect("finish parent runtime"); + let _parent_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director") + .expect("acquire parent lock") + .expect("parent lock available"); + let child_task = AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "art-director".to_string(), + task_id: "art-director".to_string(), + session_id: "agent-session-art-director".to_string(), + run_id: "delegate-concurrent-child-run".to_string(), + source: "agent-delegate".to_string(), + parent_agent_id: Some("design-director".to_string()), + parent_run_id: Some("delegate-concurrent-parent-run".to_string()), + delegation_id: Some("delegation-concurrent-1".to_string()), + task: "并发发布同一终态".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "等待下一轮输入".to_string(), + terminal_detail: Some("并发子任务结果".to_string()), + error: None, + updated_at: unix_timestamp(), + }; + write_agent_runtime_task_record_for_test(&root, &child_task); + + std::thread::scope(|scope| { + for _ in 0..2 { + scope.spawn(|| { + publish_game_creator_agent_delegate_result( + &root, + &child_task, + Some("并发子任务结果"), + ); + }); + } + }); + + let parent_runtime = + read_game_creator_agent_runtime_at(&root, "design-director").expect("read parent runtime"); + let receipt_tasks = parent_runtime + .recent_tasks + .iter() + .filter(|task| task.source == "agent-delegate-receipt") + .collect::>(); + assert_eq!(receipt_tasks.len(), 1); + assert!(!receipt_tasks[0].run_id.contains("-dup-")); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + assert_eq!( + agent_db + .matches("\"recordType\":\"agent.runtime.agent.delegate.result\"") + .count(), + 1 + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn delegated_agent_receipt_does_not_revive_cancelled_parent() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "取消父任务回执测试").expect("project init"); + let mut parent_state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "随后会取消的父任务", + "cancelled-delegate-parent-run", + "agent-background-task", + "等待子任务", + vec!["等待回执".to_string()], + ) + .expect("start parent runtime"); + mark_game_creator_agent_runtime_cancelled_at( + &root, + &mut parent_state, + "开发者已取消父任务", + Some("取消后不应被子任务回执复活"), + ) + .expect("cancel parent runtime"); + let child_task = AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "art-director".to_string(), + task_id: "art-director".to_string(), + session_id: "agent-session-art-director".to_string(), + run_id: "cancelled-parent-child-run".to_string(), + source: "agent-delegate".to_string(), + parent_agent_id: Some("design-director".to_string()), + parent_run_id: Some("cancelled-delegate-parent-run".to_string()), + delegation_id: Some("cancelled-parent-delegation".to_string()), + task: "取消父任务后完成的子任务".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "等待下一轮输入".to_string(), + terminal_detail: Some("子任务仍正常完成".to_string()), + error: None, + updated_at: unix_timestamp(), + }; + write_agent_runtime_task_record_for_test(&root, &child_task); + publish_game_creator_agent_delegate_result(&root, &child_task, None); + + let parent_runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read cancelled parent runtime"); + assert_eq!(parent_runtime.state.run_id, "cancelled-delegate-parent-run"); + assert_eq!(parent_runtime.state.status, "cancelled"); + let receipt = parent_runtime + .recent_tasks + .iter() + .find(|task| task.source == "agent-delegate-receipt") + .expect("suppressed receipt record"); + assert_eq!(receipt.status, "cancelled"); + assert_eq!(receipt.phase, "parent-terminal"); + assert!(receipt.current_action.contains("不自动续跑")); + let parent_conversation = read_local_conversation_at(&root, Some("design-director")) + .expect("read parent conversation"); + assert!(parent_conversation.messages.is_empty()); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + assert!(agent_db.contains("\"receiptStatus\":\"suppressed-parent-terminal\"")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn delegated_agent_receipt_keeps_complete_terminal_detail_for_parent() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "完整委派回执测试").expect("project init"); + let parent_state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "接收完整子任务结果", + "complete-detail-parent-run", + "agent-background-task", + "等待子任务", + vec!["接收回执".to_string()], + ) + .expect("start parent runtime"); + finish_game_creator_agent_runtime_turn_at(&root, parent_state, "等待完整子任务回执") + .expect("finish parent runtime"); + let parent_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director") + .expect("acquire parent lock") + .expect("parent lock available"); + let tail_marker = "COMPLETE_CHILD_RESULT_TAIL"; + let terminal_detail = format!("{}{}", "角色规范细节".repeat(30), tail_marker); + let child_task = AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "art-director".to_string(), + task_id: "art-director".to_string(), + session_id: "agent-session-art-director".to_string(), + run_id: "complete-detail-child-run".to_string(), + source: "agent-delegate".to_string(), + parent_agent_id: Some("design-director".to_string()), + parent_run_id: Some("complete-detail-parent-run".to_string()), + delegation_id: Some("complete-detail-delegation".to_string()), + task: "返回完整角色规范".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "等待下一轮输入".to_string(), + terminal_detail: Some(terminal_detail), + error: None, + updated_at: unix_timestamp(), + }; + write_agent_runtime_task_record_for_test(&root, &child_task); + publish_game_creator_agent_delegate_result(&root, &child_task, None); + + let parent_runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read complete parent receipt"); + let receipt = parent_runtime + .recent_tasks + .iter() + .find(|task| task.source == "agent-delegate-receipt") + .expect("complete receipt task"); + assert_eq!( + receipt.parent_run_id.as_deref(), + Some("complete-detail-parent-run") + ); + assert!(receipt.task.contains(tail_marker)); + let receipt = receipt.clone(); + drop(parent_lock); + let running_receipt = start_game_creator_agent_runtime_task_for_session_at( + &root, + "design-director", + Some(&receipt.session_id), + &receipt.task, + &receipt.run_id, + "agent-delegate-receipt", + "开始处理完整委派回执", + vec!["整合完整委派结果".to_string()], + ) + .expect("start complete receipt runtime"); + assert_eq!( + running_receipt.parent_run_id.as_deref(), + Some("complete-detail-parent-run") + ); + assert_eq!( + running_receipt.delegation_id.as_deref(), + Some("complete-detail-delegation") + ); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn queued_delegate_receipt_is_suppressed_when_parent_cancels_before_drain() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "排队回执取消竞态测试").expect("project init"); + let mut parent_state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "运行中等待子任务", + "queued-receipt-parent-run", + "agent-background-task", + "等待子任务", + vec!["接收回执".to_string()], + ) + .expect("start parent runtime"); + let parent_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director") + .expect("acquire parent lock") + .expect("parent lock available"); + let child_task = AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "art-director".to_string(), + task_id: "art-director".to_string(), + session_id: "agent-session-art-director".to_string(), + run_id: "queued-receipt-child-run".to_string(), + source: "agent-delegate".to_string(), + parent_agent_id: Some("design-director".to_string()), + parent_run_id: Some("queued-receipt-parent-run".to_string()), + delegation_id: Some("queued-receipt-delegation".to_string()), + task: "父任务取消前完成".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "等待下一轮输入".to_string(), + terminal_detail: Some("子任务正常完成".to_string()), + error: None, + updated_at: unix_timestamp(), + }; + write_agent_runtime_task_record_for_test(&root, &child_task); + publish_game_creator_agent_delegate_result(&root, &child_task, None); + let receipt = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read queued receipt") + .recent_tasks + .into_iter() + .find(|task| task.source == "agent-delegate-receipt") + .expect("queued receipt task"); + assert_eq!(receipt.status, "pending"); + let cancel_path = root + .join(".agent/runtime/cancel/design-director") + .join(format!("{}.json", receipt.run_id)); + fs::create_dir_all(cancel_path.parent().expect("cancel parent")) + .expect("create receipt cancellation guard dir"); + fs::write( + &cancel_path, + format!( + "{{\"agentId\":\"design-director\",\"runId\":\"{}\",\"reason\":\"防止回归测试触发 LLM\",\"updatedAt\":{}}}\n", + receipt.run_id, + unix_timestamp() + ), + ) + .expect("write receipt cancellation guard"); + mark_game_creator_agent_runtime_cancelled_at( + &root, + &mut parent_state, + "开发者已取消父任务", + Some("回执排队后取消父任务"), + ) + .expect("cancel parent while receipt is queued"); + drop(parent_lock); + + spawn_next_game_creator_agent_background_task_drain(&root, "design-director") + .expect("drain queued receipt"); + let mut drained_receipt = receipt.clone(); + for _ in 0..50 { + let runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read drained receipt runtime"); + drained_receipt = runtime + .recent_tasks + .into_iter() + .find(|task| task.run_id == receipt.run_id) + .expect("receipt remains recorded"); + if drained_receipt.status != "pending" { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + let receipt = drained_receipt; + assert_eq!(receipt.status, "cancelled"); + assert_eq!(receipt.phase, "parent-terminal"); + let conversation = read_local_conversation_at(&root, Some("design-director")) + .expect("read parent conversation"); + assert!(conversation + .messages + .iter() + .all(|message| !message.content.contains("收到委派子任务终态回执"))); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn delegate_receipt_without_parent_link_fails_closed_before_execution() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "缺失父关联回执测试").expect("project init"); + let session_id = resolve_agent_conversation_session_id_at(&root, "design-director", None, true) + .expect("resolve parent session"); + let receipt = AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "design-director".to_string(), + task_id: "design-director".to_string(), + session_id, + run_id: "missing-parent-link-receipt".to_string(), + source: "agent-delegate-receipt".to_string(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: Some("missing-parent-link-delegation".to_string()), + task: "收到无法核验父 run 的委派回执".to_string(), + status: "pending".to_string(), + phase: "queued".to_string(), + current_action: "等待当前后台任务完成".to_string(), + terminal_detail: None, + error: None, + updated_at: unix_timestamp(), + }; + write_agent_runtime_task_record_for_test(&root, &receipt); + + spawn_next_game_creator_agent_background_task_drain(&root, "design-director") + .expect("drain invalid receipt"); + + let task_path = root.join(".agent/runtime/tasks/design-director.jsonl"); + let mut latest = receipt; + for _ in 0..50 { + let task_log = fs::read_to_string(&task_path).expect("read invalid receipt task log"); + latest = task_log + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|task| task.run_id == "missing-parent-link-receipt") + .last() + .expect("invalid receipt remains recorded"); + if latest.status != "pending" { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert_eq!(latest.status, "failed"); + assert_eq!(latest.phase, "parent-link-missing"); + let conversation = read_local_conversation_at(&root, Some("design-director")) + .expect("read invalid receipt conversation"); + assert!(conversation.messages.is_empty()); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn background_task_does_not_execute_when_user_message_cannot_persist() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "后台任务对话一致性测试").expect("project init"); + let session_id = resolve_agent_conversation_session_id_at(&root, "design-director", None, true) + .expect("resolve agent session"); + let (conversation_path, _, _) = + conversation_file_path_for_session(&root, Some("design-director"), Some(&session_id)) + .expect("resolve conversation path"); + fs::create_dir_all(conversation_path.parent().expect("conversation parent")) + .expect("create conversation parent"); + fs::write(&conversation_path, "").expect("create empty conversation file"); + let mut conversation_permissions = fs::metadata(&conversation_path) + .expect("read conversation permissions") + .permissions(); + conversation_permissions.set_readonly(true); + fs::set_permissions(&conversation_path, conversation_permissions) + .expect("make conversation read only"); + let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director") + .expect("acquire runtime lock") + .expect("runtime lock available"); + + let result = start_game_creator_agent_background_task_for_session_at( + &root, + "design-director", + Some(&session_id), + "这条任务必须先持久化对话", + "conversation-write-failure-run", + ); + + assert!(result.is_err()); + let task_log = fs::read_to_string(root.join(".agent/runtime/tasks/design-director.jsonl")) + .expect("read failed queued task log"); + let task = task_log + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|task| task.run_id == "conversation-write-failure-run") + .last() + .expect("failed queued task exists"); + assert_eq!(task.status, "failed"); + assert_eq!(task.phase, "conversation-write-failed"); + + drop(runtime_lock); + let mut conversation_permissions = fs::metadata(&conversation_path) + .expect("read final conversation permissions") + .permissions(); + conversation_permissions.set_readonly(false); + fs::set_permissions(&conversation_path, conversation_permissions) + .expect("restore conversation permissions"); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn delegated_agent_receipt_redacts_terminal_credentials() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "委派回执脱敏测试").expect("project init"); + let parent_state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "接收脱敏后的子任务结果", + "redacted-delegate-parent-run", + "agent-background-task", + "等待子任务", + vec!["接收回执".to_string()], + ) + .expect("start parent runtime"); + finish_game_creator_agent_runtime_turn_at(&root, parent_state, "等待子任务回执") + .expect("finish parent runtime"); + let _parent_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director") + .expect("acquire parent lock") + .expect("parent lock available"); + let child_task = AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "art-director".to_string(), + task_id: "art-director".to_string(), + session_id: "agent-session-art-director".to_string(), + run_id: "redacted-delegate-child-run".to_string(), + source: "agent-delegate".to_string(), + parent_agent_id: Some("design-director".to_string()), + parent_run_id: Some("redacted-delegate-parent-run".to_string()), + delegation_id: Some("redacted-delegation".to_string()), + task: "返回含敏感值的结果".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "等待下一轮输入".to_string(), + terminal_detail: Some( + "password=receipt-password\nclient_secret=receipt-client-secret\nx-api-key: receipt-vendor-key" + .to_string(), + ), + error: None, + updated_at: unix_timestamp(), + }; + write_agent_runtime_task_record_for_test(&root, &child_task); + publish_game_creator_agent_delegate_result(&root, &child_task, None); + + let parent_runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read redacted parent receipt"); + let receipt = parent_runtime + .recent_tasks + .iter() + .find(|task| task.source == "agent-delegate-receipt") + .expect("redacted receipt task"); + let serialized_receipt = serde_json::to_string(receipt).expect("serialize receipt task"); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + for secret in [ + "receipt-password", + "receipt-client-secret", + "receipt-vendor-key", + ] { + assert!(!serialized_receipt.contains(secret)); + assert!(!agent_db.contains(secret)); + } + assert!(receipt.task.contains("[redacted sensitive context]")); fs::remove_dir_all(root).ok(); } @@ -5963,7 +6899,6 @@ async fn background_agent_runtime_resumes_approved_pending_action_without_llm_re state.pending_tool_action = Some(pending.summary()); append_game_creator_agent_runtime_task(&root, &state).expect("append running task"); write_game_creator_agent_runtime_state(&root, &state).expect("write running state"); - let resumed = resume_game_creator_agent_background_tasks_at(&root) .expect("resume approved pending action"); assert!(resumed.iter().any(|runtime| { @@ -6282,6 +7217,27 @@ fn background_agent_runtime_does_not_replay_interrupted_tool_execution() { state.pending_tool_action = Some(pending.summary()); append_game_creator_agent_runtime_task(&root, &state).expect("append running task"); write_game_creator_agent_runtime_state(&root, &state).expect("write running state"); + write_agent_runtime_task_record_for_test( + &root, + &AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "art-director".to_string(), + task_id: "art-director".to_string(), + session_id: "agent-session-art-director".to_string(), + run_id: "reconciliation-child-terminal-run".to_string(), + source: "agent-delegate".to_string(), + parent_agent_id: Some("design-director".to_string()), + parent_run_id: Some("design-executing-recovery-run".to_string()), + delegation_id: Some("reconciliation-child-delegation".to_string()), + task: "父任务核对期间已完成的子任务".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "等待下一轮输入".to_string(), + terminal_detail: Some("子任务结果已安全落盘".to_string()), + error: None, + updated_at: unix_timestamp(), + }, + ); let resumed = resume_game_creator_agent_background_tasks_at(&root) .expect("inspect interrupted execution"); @@ -6296,6 +7252,21 @@ fn background_agent_runtime_does_not_replay_interrupted_tool_execution() { .error .as_deref() .is_some_and(|error| error.contains("不会自动重放"))); + let parent_after_receipt_reconcile = + read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read parent after receipt reconciliation"); + assert_eq!( + parent_after_receipt_reconcile.state.run_id, + "design-executing-recovery-run" + ); + assert_eq!( + parent_after_receipt_reconcile.state.phase, + "needs-reconciliation" + ); + assert!(parent_after_receipt_reconcile + .recent_tasks + .iter() + .any(|task| { task.source == "agent-delegate-receipt" && task.status == "pending" })); assert!(!root.join("game/notes.txt").exists()); let pending_path = root .join(".agent/runtime/pending-actions/design-director/design-executing-recovery-run.json"); @@ -8183,10 +9154,14 @@ async fn background_agent_runtime_starts_oldest_pending_task_after_lock_acquisit session_id: "agent-session-design-director".to_string(), run_id: "design-fifo-first-run".to_string(), source: "agent-background-task".to_string(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, task: "先进入队列的设计任务".to_string(), status: "pending".to_string(), phase: "queued".to_string(), current_action: "等待当前后台任务完成".to_string(), + terminal_detail: None, error: None, updated_at: unix_timestamp(), }, @@ -10762,6 +11737,13 @@ fn prompt_context_redacts_secrets_before_truncating() { "game-creator.config.json has apiKey", "token sk-unit-secret assets/hero.png", "tnr_sk_unit_secret", + "password=hunter2", + "client_secret: oauth-secret", + "{\"accessToken\":\"session-token\"}", + "x-api-key: vendor-key", + "-----BEGIN PRIVATE KEY-----", + "private-key-body", + "-----END PRIVATE KEY-----", ] .join("\n"); @@ -10775,6 +11757,11 @@ fn prompt_context_redacts_secrets_before_truncating() { assert!(!sanitized.contains("sk-unit-secret")); assert!(!sanitized.contains("tnr_sk_unit_secret")); assert!(!sanitized.contains("game-creator.config.json")); + assert!(!sanitized.contains("hunter2")); + assert!(!sanitized.contains("oauth-secret")); + assert!(!sanitized.contains("session-token")); + assert!(!sanitized.contains("vendor-key")); + assert!(!sanitized.contains("private-key-body")); } #[test] @@ -11631,6 +12618,49 @@ fn agent_conversation_session_archive_is_read_only_and_keeps_active_session() { ) .expect("append before archive"); + let parent_state = start_game_creator_agent_runtime_task_for_session_at( + &root, + "design-director", + Some(&session_id), + "等待委派美术结果", + "archive-parent-run", + "agent-background-task", + "等待委派", + vec!["接收美术回执".to_string()], + ) + .expect("start parent task in archived candidate session"); + finish_game_creator_agent_runtime_turn_at(&root, parent_state, "父 run 已结束,子任务仍运行") + .expect("finish parent task"); + let mut delegated_child = AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "art-director".to_string(), + task_id: "art-director".to_string(), + session_id: "agent-session-art-director".to_string(), + run_id: "archive-delegated-child-run".to_string(), + source: "agent-delegate".to_string(), + parent_agent_id: Some("design-director".to_string()), + parent_run_id: Some("archive-parent-run".to_string()), + delegation_id: Some("archive-delegation".to_string()), + task: "归档前仍在运行的委派子任务".to_string(), + status: "running".to_string(), + phase: "planning".to_string(), + current_action: "正在处理委派".to_string(), + terminal_detail: None, + error: None, + updated_at: unix_timestamp(), + }; + write_agent_runtime_task_record_for_test(&root, &delegated_child); + let archive_error = + archive_game_creator_agent_session_at(&root, "design-director", &session_id) + .expect_err("live delegated child blocks parent session archive"); + assert!(archive_error.contains("委派子任务")); + delegated_child.status = "completed".to_string(); + delegated_child.phase = "completed".to_string(); + delegated_child.current_action = "等待下一轮输入".to_string(); + delegated_child.terminal_detail = Some("委派子任务已完成".to_string()); + delegated_child.updated_at = unix_timestamp(); + write_agent_runtime_task_record_for_test(&root, &delegated_child); + let archived = archive_game_creator_agent_session_at(&root, "design-director", &session_id) .expect("archive session"); assert_eq!(archived.active_session_id, "agent-session-design-director"); @@ -11835,10 +12865,14 @@ async fn agent_runtime_conversation_tool_uses_run_session() { session_id: active.active_session_id.clone(), run_id: "other-session-task".to_string(), source: "agent-background-task".to_string(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, task: "must stay outside current run status".to_string(), status: "failed".to_string(), phase: "failed".to_string(), current_action: "failed".to_string(), + terminal_detail: Some("other session failure".to_string()), error: Some("other session failure".to_string()), updated_at: unix_timestamp(), }, @@ -11950,10 +12984,14 @@ fn agent_runtime_retry_preserves_original_session() { session_id: original_session_id.clone(), run_id: "failed-original-run".to_string(), source: "agent-background-task".to_string(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, task: "retry in original session".to_string(), status: "failed".to_string(), phase: "failed".to_string(), current_action: "failed".to_string(), + terminal_detail: Some("test failure".to_string()), error: Some("test failure".to_string()), updated_at: unix_timestamp(), }, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 022980d43..b1d3ae6ed 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -235,6 +235,9 @@ interface AgentRuntimeState { sessionId: string; runId: string; source: string; + parentAgentId?: string | null; + parentRunId?: string | null; + delegationId?: string | null; status: string; phase: string; currentTask: string; @@ -331,10 +334,14 @@ interface AgentRuntimeTaskRecord { sessionId: string; runId: string; source: string; + parentAgentId?: string | null; + parentRunId?: string | null; + delegationId?: string | null; task: string; status: string; phase: string; currentAction: string; + terminalDetail?: string | null; error: string | null; updatedAt: number; } @@ -811,6 +818,28 @@ function agentRuntimeCanConfirm(status: string) { return status === 'waiting-for-confirmation'; } +function formatAgentRuntimeDelegationSource(runtime: { + source: string; + parentAgentId?: string | null; + parentRunId?: string | null; + delegationId?: string | null; +}) { + if (runtime.source === 'agent-delegate-receipt') { + return [ + '来源:委派回执', + runtime.delegationId ? `委派:${runtime.delegationId}` : null, + ] + .filter(Boolean) + .join(' · '); + } + const parts = [ + runtime.parentAgentId ? `委派自:${runtime.parentAgentId}` : null, + runtime.parentRunId ? `父 run:${runtime.parentRunId}` : null, + runtime.delegationId ? `委派:${runtime.delegationId}` : null, + ].filter(Boolean); + return parts.length > 0 ? parts.join(' · ') : null; +} + function AgentRuntimeStatusPanel({ runtime, error, @@ -861,6 +890,7 @@ function AgentRuntimeStatusPanel({ const currentGoal = runtime.currentGoal ?? runtime.currentTask; const waitingOn = runtime.waitingOn ?? agentRuntimeWaitingOnFromPhase(runtime.phase); const pendingToolAction = runtime.pendingToolAction ?? null; + const delegationSource = formatAgentRuntimeDelegationSource(runtime); const canCancel = Boolean(runtime.runId) && (agentRuntimeCanCancel(runtime.status) || @@ -941,7 +971,7 @@ function AgentRuntimeStatusPanel({ ) : null} - {`task: ${runtime.taskId} · ${runtime.source}`} + {`task: ${runtime.taskId} · ${delegationSource ?? runtime.source}`} {runtime.runId ? {`run: ${runtime.runId}`} : null} {currentGoal ?

{`当前目标:${currentGoal}`}

: null} {runtime.currentTask ?

{runtime.currentTask}

: null} @@ -11645,10 +11675,15 @@ function sameAgentRuntimeTasks( return ( other && task.runId === other.runId && + task.source === other.source && + task.parentAgentId === other.parentAgentId && + task.parentRunId === other.parentRunId && + task.delegationId === other.delegationId && task.status === other.status && task.phase === other.phase && task.task === other.task && task.currentAction === other.currentAction && + task.terminalDetail === other.terminalDetail && task.updatedAt === other.updatedAt ); }) @@ -12430,9 +12465,10 @@ function formatAgentPolicySummary(policy: ProjectPermissionPolicy) { } function formatAgentRecentRuntimeTask(task: AgentRuntimeTaskRecord) { + const delegationSource = formatAgentRuntimeDelegationSource(task); return `${task.status} / ${task.phase} · ${ task.task || task.currentAction || task.runId - }`; + }${delegationSource ? ` · ${delegationSource}` : ''}`; } function formatAgentDialogLlmStatus( diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index 52a226a24..09735b68a 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -2448,7 +2448,10 @@ describe('AI 游戏创作 App 界面边界', () => { taskId: 'design-director', sessionId: 'agent-session-design-director', runId: 'launcher-agent-task-running', - source: 'agent-background-task', + source: 'agent-delegate', + parentAgentId: 'game-director', + parentRunId: 'game-director-run-1', + delegationId: 'delegation-design-1', status: 'running', phase: 'planning', currentTask: '正在处理上一条任务', @@ -2478,7 +2481,10 @@ describe('AI 游戏创作 App 界面边界', () => { taskId: 'design-director', sessionId: 'agent-session-design-director', runId: 'launcher-agent-task-running', - source: 'agent-background-task', + source: 'agent-delegate', + parentAgentId: 'game-director', + parentRunId: 'game-director-run-1', + delegationId: 'delegation-design-1', task: '正在处理上一条任务', status: 'running', phase: 'planning', @@ -2486,6 +2492,19 @@ describe('AI 游戏创作 App 界面边界', () => { error: null, updatedAt: 5000, }; + const delegateReceiptTask = { + ...runningTask, + runId: 'delegate-receipt-gameplay-1', + source: 'agent-delegate-receipt', + parentAgentId: null, + parentRunId: null, + delegationId: 'delegation-gameplay-1', + task: '接收 Gameplay Agent 委派结果', + status: 'completed', + phase: 'completed', + currentAction: '根据委派结果继续任务', + updatedAt: 4999, + }; const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'check_game_creator_llm_config') { @@ -2530,7 +2549,7 @@ describe('AI 游戏创作 App 界面边界', () => { '/tmp/authorized-game/.agent/runtime/tasks/design-director.jsonl', taskQueue: runningRuntimeState.taskQueue, recentEvents: [], - recentTasks: [runningTask], + recentTasks: [delegateReceiptTask, runningTask], }; } if (command === 'start_game_creator_agent_runtime_task') { @@ -2553,10 +2572,15 @@ describe('AI 游戏创作 App 界面边界', () => { }, recentEvents: [], recentTasks: [ + delegateReceiptTask, runningTask, { ...runningTask, runId: String(args?.runId ?? 'launcher-agent-task-pending'), + source: 'agent-background-task', + parentAgentId: null, + parentRunId: null, + delegationId: null, task: String(args?.task ?? ''), status: 'pending', phase: 'queued', @@ -2577,6 +2601,16 @@ describe('AI 游戏创作 App 界面边界', () => { }); fireEvent.click(screen.getByRole('button', { name: '读取历史' })); expect(await screen.findByText(/已读取 0 条/)).not.toBeNull(); + expect( + screen.getByText( + 'task: design-director · 委派自:game-director · 父 run:game-director-run-1 · 委派:delegation-design-1', + ), + ).not.toBeNull(); + expect( + screen.getByText( + 'completed / completed · 接收 Gameplay Agent 委派结果 · 来源:委派回执 · 委派:delegation-gameplay-1', + ), + ).not.toBeNull(); fireEvent.change(screen.getByLabelText('Agent 聊天内容'), { target: { value: '排队整理第二个需求' }, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a05f9431b..3b47680fd 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4136,4 +4136,5 @@ - 决策:同一 Agent 的前台聊天与后台队列共享 per-Agent OS 执行锁,前台 LLM 等待期间不持有项目写锁;同 Agent 后台投递保持 pending,前台结束后把当前锁直接移交给 drain,drain 异常不得反写已经完成的聊天结果,不同 Agent 继续并行。 - 决策:重启恢复继续遵守 `agent.resume` 默认确认策略。自动 command 只允许 auto;默认 confirm 由主工作区或独立开发 Agent 聊天窗口的 UI 明确确认后调用独立 command,确认绑定发起项目,切换项目取消旧确认且旧项目异步结果不得污染新项目状态;独立 command 只忽略 confirm、不允许绕过 deny,临时失败必须允许重试。 - 决策:后台 Agent loop 只有空 actions 才算收束;三轮预算耗尽仍有动作时写 `failed / budget-exhausted` 和 `loop-budget-exhausted`,不再生成总结后记成 completed。解析阶段保留 action 总数,超过单轮预算时写 `runtime.tool_budget` 并只执行前三个;Runtime 默认工具列表必须直接从可执行白名单派生。 -- 验证:Rust 覆盖首轮上下文不泄露、工具后 observation 可见、确认前后内容边界、前后台同 Agent 串行、前台结束后队列 drain、恢复确认 gate、预算耗尽失败和默认工具白名单一致性;前端分别覆盖主工作区和独立开发 Agent 聊天窗口的默认恢复确认条与显式恢复 command。 +- 决策:`agent.delegate` 子任务必须 durable 保存 `parentAgentId / parentRunId / delegationId`,其中 `delegationId` 从已持久化工具动作的 `actionId` 派生,不能使用执行时随机值;终态任务记录必须保存经过统一凭据清洗和安全截断的 `terminalDetail`,不能依赖可能被后续 run 覆盖的 Agent 全局 state。子任务进入 `completed / failed / cancelled / budget-exhausted` 任一终态后,Runtime 必须在 delegation 级 OS 文件锁内按固定 receipt runId 幂等生成且至多生成一次 `agent.delegate.result` 回执;不同委派并发写同一目标 Agent 时,runId 分配与 pending 追加还必须在目标 Agent 任务账本 OS 锁内原子完成。失败、排队或活跃取消、预算耗尽与成功同等需要回执,`needs-reconciliation` 只有最终取消后才回执。父 Agent 通过既有队列接收 `source=agent-delegate-receipt` 的续跑任务,回执 prompt 禁止重复同一委派,并携带完整的已清洗 `terminalDetail`,不能只保留 UI 摘要;排队期间不提前写入父会话,真正执行时才幂等落盘,用户消息或回执消息落盘失败时不得进入 LLM。回执任务必须保留父 run 关联,真正开始或恢复前再次核验父 run,关联缺失或父 run 不存在时失败关闭;父 run 已取消或普通失败时只保留 suppressed receipt 审计,不自动复活。父 Session 存在未结束委派时禁止切换或归档,极端竞态下回执回落到父 Agent 当前可写 Session。续跑继续遵守同 Agent FIFO、per-Agent OS 锁、权限确认、取消、恢复和 `needs-reconciliation` 屏障,不允许直接重入、插队或重复投递;恢复必须先恢复 pending action / reconciliation 屏障,再补齐“子终态已落盘、回执未入队”的崩溃窗口。 +- 验证:Rust 覆盖首轮上下文不泄露、工具后 observation 可见、确认前后内容边界、前后台同 Agent 串行、前台结束后队列 drain、恢复确认 gate、预算耗尽失败、默认工具白名单一致性,以及 delegate 成功 / 失败 / 取消 / 预算耗尽终态回执、`delegationId` 幂等去重和父 Agent receipt 续跑仍受 FIFO / 锁 / 确认 / 恢复门禁;前端分别覆盖主工作区和独立开发 Agent 聊天窗口的默认恢复确认条与显式恢复 command。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index d95c269bb..1d6d469dd 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -66,6 +66,7 @@ Agent Runtime 负责: - 2026-07-10 补充:后台任务工具箱已加入 `project.diff`。Agent 可在 loop 中基于已存在 checkpoint 查看当前项目新增、修改和删除摘要;Runtime 复用 `project.diff` 项目权限策略,策略要求确认或拒绝时不执行 diff,observation 只包含 checkpoint id、三类计数和项目相对路径,不返回本机绝对路径或文件正文。 - 2026-07-10 补充:后台任务工具箱已加入 `agent.run_status`。Agent 可在 loop 中读取自己、目标 Agent 或一组 Agent 的 Runtime 状态摘要,判断同伴是否正在运行、最近任务和最近工具动作;Runtime 复用 `agent.run_status` 项目权限策略,策略要求确认或拒绝时不读取状态,observation 不返回 `.agent/runtime/*` 文件绝对路径。 - 2026-07-10 补充:后台任务工具箱已加入 `agent.delegate`。Agent 可在 loop 中把明确任务投递到另一个 Agent 的独立后台队列,复用目标 Agent 原有锁和 pending drain 语义;同一目标 Agent 串行,不同目标 Agent 可并行。该工具受 `agent.delegate` 策略保护,策略要求确认或拒绝时不会写目标对话、不会启动目标后台任务,也不会写 `agent.runtime.agent.delegate` 审计记录。 +- 2026-07-10 补充:`agent.delegate` 已形成可恢复的父子任务闭环。`delegationId` 由 durable pending action 的 `actionId` 派生,子任务记录会保存 `parentAgentId / parentRunId / delegationId`,终态记录额外保存经过统一凭据清洗和安全截断的 `terminalDetail`;同一委派的提交和回执分别受 delegation 级 OS 文件锁保护,同一目标 Agent 的 runId 分配与 pending 追加还受任务账本 OS 锁保护。子任务进入 `completed / failed / cancelled / budget-exhausted` 任一终态时,Runtime 按 `delegationId` 幂等生成且至多生成一次 `agent.delegate.result` 回执,失败、排队或活跃取消、预算耗尽都必须回传,不能只覆盖成功。回执会向父 Agent 既有队列追加固定 runId、`source=agent-delegate-receipt` 的续跑任务,把完整的已清洗 `terminalDetail` 交回父 run,不再只保留 80 字符 UI 摘要;回执 prompt 明确禁止重复同一委派,排队期间不提前写入父会话,真正开始执行时才幂等落盘,用户消息或回执消息落盘失败时不会进入 LLM。回执任务保留父 run 关联,并在真正开始或恢复前再次检查父 run 状态,关联缺失或父 run 不存在时失败关闭;该续跑仍受父 Agent 原有 FIFO、per-Agent OS 锁、权限确认、取消、恢复和 `needs-reconciliation` 屏障约束,不直接重入父 run、不插队、不新增独立 worker;父 run 已取消或普通失败时只保留 suppressed receipt 审计,不自动复活,父 Session 归档与切换会被未结束委派阻止,极端归档竞态下回执回落到父 Agent 当前可写 Session。恢复先恢复 pending action / reconciliation 屏障,再扫描“子任务终态已落盘但回执未提交”的窗口并补齐缺失回执;`needs-reconciliation` 本身不回执,只有人工核对后最终取消才回传 `cancelled`。 - 2026-07-10 补充:Runtime 增加 `resume_game_creator_agent_runtime_tasks` 恢复入口。客户端读取项目 Runtime 时会对每个项目路径最多自动尝试一次恢复;恢复命令必须通过 `agent.resume` 自动权限,默认需要确认或被拒绝时不会静默启动。恢复扫描 `.agent/runtime/tasks/.jsonl` 中上一进程遗留的 `running` 或仍为 `pending` 的任务,同一 Agent 同时存在二者时先重接遗留 `running`,再由既有 drain 串行继续 `pending`;恢复动作写 `agent.runtime.background_task.recovered` 审计记录。该能力只是把本地 JSONL 队列重接到当前 App 进程,不是独立常驻 worker,也不承诺恢复已经发出的上游 LLM 请求。 - 2026-07-10 补充:后台 planning 与预算内 final reply 使用专用最小上下文,只预置 Agent 身份、sessionId、runId、执行模式和工具策略;Agent 私有记忆、项目记忆、黑板、对话、资产、项目索引与文件正文只能经对应工具通过权限 gate 后作为 observation 进入下一轮。普通前台聊天仍可使用角色上下文。长黑板、记忆和对话按尾部截断,确保最新结论与最新定向消息优先保留。 - 2026-07-10 补充:同一 Agent 的前台直接聊天、流式聊天和后台任务统一使用 `.agent/runtime/locks/.lock` OS 文件锁。前台聊天不再在整个 LLM 请求期间占用项目级写锁;同 Agent 后台任务在前台运行时只入队,前台成功或失败后把当前 Agent 锁直接移交给 drain,不重新抢锁,也不允许 drain 启动异常把已经完成的聊天结果改判为失败。不同 Agent 继续并行,真实项目写工具只在副作用执行期间短暂申请项目写锁。