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 4ebbd6499..33e8871f9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -492,7 +492,7 @@ async fn run_game_creator_agent_background_task( ); } if !plan.plan.is_empty() { - runtime.plan = plan.plan.clone(); + update_agent_runtime_plan_steps(&mut runtime, plan.plan.clone()); runtime.updated_at = unix_timestamp(); let _ = write_game_creator_agent_runtime_state(&root, &runtime); let _ = append_game_creator_agent_runtime_event( @@ -508,16 +508,28 @@ async fn run_game_creator_agent_background_task( if plan.actions.is_empty() { if !plan.response.trim().is_empty() { + activate_agent_runtime_response_plan_step( + &mut runtime, + "Agent 已直接给出最终回复。", + ); + runtime.updated_at = unix_timestamp(); + let _ = write_game_creator_agent_runtime_state(&root, &runtime); final_reply = Some(plan.response.clone()); } break; } - for action in plan + for (action_index, action) in plan .actions .iter() .take(AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT) + .enumerate() { + activate_agent_runtime_plan_step( + &mut runtime, + action_index, + action.reason.as_deref().unwrap_or(action.tool.as_str()), + ); runtime = match advance_game_creator_agent_runtime_turn_at( &root, runtime, @@ -547,6 +559,15 @@ async fn run_game_creator_agent_background_task( let observation_summary = observation.summary(); runtime.observations.push(observation_summary.clone()); append_agent_runtime_tool_call_record(&mut runtime, action, &observation); + complete_agent_runtime_active_plan_step( + &mut runtime, + if observation.status == "ok" { + "completed" + } else { + "failed" + }, + &observation_summary, + ); runtime.waiting_on = "Agent 根据工具观察修正计划".to_string(); runtime.next_step = "把工具观察交给 Agent 修正计划".to_string(); runtime.updated_at = unix_timestamp(); @@ -579,6 +600,10 @@ async fn run_game_creator_agent_background_task( let final_reply = if let Some(reply) = final_reply { reply } else { + activate_agent_runtime_response_plan_step( + &mut runtime, + "Agent 已完成工具观察,正在整理最终回复。", + ); runtime = match advance_game_creator_agent_runtime_turn_at( &root, runtime, @@ -702,6 +727,7 @@ async fn run_game_creator_agent_background_task( const AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT: usize = 3; const AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT: usize = 3; const AGENT_RUNTIME_RECENT_TOOL_CALL_LIMIT: usize = 20; +const AGENT_RUNTIME_PLAN_STEP_LIMIT: usize = 8; const AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS: usize = 900; const AGENT_RUNTIME_TOOL_WRITE_MAX_CHARS: usize = 12_000; pub(crate) const AGENT_RUNTIME_LOCK_STALE_AFTER_SECONDS: u64 = 300; @@ -775,6 +801,158 @@ fn append_agent_runtime_tool_call_record( } } +fn update_agent_runtime_plan_steps(runtime: &mut AgentRuntimeState, plan: Vec) { + runtime.plan = plan + .into_iter() + .filter(|item| !item.trim().is_empty()) + .take(AGENT_RUNTIME_PLAN_STEP_LIMIT) + .map(|item| sanitize_agent_runtime_text(&item, 180)) + .collect(); + runtime.plan_steps = runtime + .plan + .iter() + .enumerate() + .map(|(index, title)| AgentRuntimePlanStep { + index: index as u32, + title: title.clone(), + status: if index == 0 { "active" } else { "pending" }.to_string(), + detail: None, + updated_at: unix_timestamp(), + }) + .collect(); + runtime.active_plan_step_index = if runtime.plan_steps.is_empty() { + None + } else { + Some(0) + }; +} + +fn activate_agent_runtime_plan_step( + runtime: &mut AgentRuntimeState, + step_index: usize, + detail: &str, +) { + if runtime.plan_steps.is_empty() { + return; + } + let target_index = step_index.min(runtime.plan_steps.len().saturating_sub(1)); + let now = unix_timestamp(); + for step in runtime.plan_steps.iter_mut() { + if step.index as usize == target_index { + step.status = "active".to_string(); + step.detail = Some(sanitize_agent_runtime_text(detail, 180)); + step.updated_at = now; + } else if step.status == "active" { + step.status = "pending".to_string(); + step.updated_at = now; + } + } + runtime.active_plan_step_index = Some(target_index as u32); +} + +fn complete_agent_runtime_active_plan_step( + runtime: &mut AgentRuntimeState, + status: &str, + detail: &str, +) { + let Some(active_index) = runtime.active_plan_step_index else { + return; + }; + let status = if status == "failed" { + "failed" + } else { + "completed" + }; + let now = unix_timestamp(); + for step in runtime.plan_steps.iter_mut() { + if step.index == active_index { + step.status = status.to_string(); + step.detail = Some(sanitize_agent_runtime_text(detail, 220)); + step.updated_at = now; + break; + } + } + runtime.active_plan_step_index = None; +} + +fn activate_agent_runtime_response_plan_step(runtime: &mut AgentRuntimeState, detail: &str) { + let target_index = runtime + .plan_steps + .iter() + .find(|step| step.status == "pending" || step.status == "active") + .map(|step| step.index as usize); + if let Some(target_index) = target_index { + activate_agent_runtime_plan_step(runtime, target_index, detail); + return; + } + + if runtime.plan_steps.len() >= AGENT_RUNTIME_PLAN_STEP_LIMIT { + return; + } + + let index = runtime.plan_steps.len() as u32; + let title = "生成最终回复".to_string(); + runtime.plan.push(title.clone()); + runtime.plan_steps.push(AgentRuntimePlanStep { + index, + title, + status: "active".to_string(), + detail: Some(sanitize_agent_runtime_text(detail, 180)), + updated_at: unix_timestamp(), + }); + runtime.active_plan_step_index = Some(index); +} + +fn fail_agent_runtime_remaining_plan_steps(runtime: &mut AgentRuntimeState, detail: &str) { + if runtime.active_plan_step_index.is_some() { + complete_agent_runtime_active_plan_step(runtime, "failed", detail); + return; + } + + let now = unix_timestamp(); + let detail = sanitize_agent_runtime_text(detail, 220); + let mut marked_failed = false; + for step in runtime.plan_steps.iter_mut() { + if step.status == "pending" || step.status == "active" { + step.status = "failed".to_string(); + step.detail = Some(detail.clone()); + step.updated_at = now; + marked_failed = true; + } + } + if !marked_failed { + if let Some(step) = runtime + .plan_steps + .iter_mut() + .rev() + .find(|step| step.status != "failed") + { + step.status = "failed".to_string(); + step.detail = Some(detail); + step.updated_at = now; + } + } + runtime.active_plan_step_index = None; +} + +fn complete_agent_runtime_remaining_plan_steps(runtime: &mut AgentRuntimeState, detail: &str) { + let now = unix_timestamp(); + for step in runtime.plan_steps.iter_mut() { + if step.status != "failed" { + step.status = "completed".to_string(); + if step + .detail + .as_deref() + .map_or(true, |value| value.trim().is_empty()) + { + step.detail = Some(sanitize_agent_runtime_text(detail, 180)); + } + step.updated_at = now; + } + } + runtime.active_plan_step_index = None; +} + async fn request_game_creator_agent_background_tool_plan_at( root: &Path, agent_id: &str, @@ -2142,6 +2320,7 @@ fn format_agent_runtime_status_observation(result: &AgentRuntimeResult) -> Strin .collect::>() .join(" / ") }; + let plan_step = format_agent_runtime_active_plan_step_observation(state); let recent_task = result .recent_tasks .last() @@ -2160,7 +2339,7 @@ fn format_agent_runtime_status_observation(result: &AgentRuntimeResult) -> Strin .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| "-".to_string()); format!( - "agentId: {}\nstatus: {}\nphase: {}\nrunId: {}\n循环轮次: {}/{}\n每轮工具预算: {}\n当前目标: {}\n当前任务: {}\n当前动作: {}\n等待: {}\n下一步: {}\n计划: {}\n任务队列: {}\n最近任务: {}\n最近工具: {}\n错误: {}", + "agentId: {}\nstatus: {}\nphase: {}\nrunId: {}\n循环轮次: {}/{}\n每轮工具预算: {}\n当前目标: {}\n当前任务: {}\n当前动作: {}\n等待: {}\n下一步: {}\n计划: {}\n当前计划步骤: {}\n任务队列: {}\n最近任务: {}\n最近工具: {}\n错误: {}", state.agent_id, state.status, state.phase, @@ -2174,6 +2353,7 @@ fn format_agent_runtime_status_observation(result: &AgentRuntimeResult) -> Strin waiting_on, next_step, plan, + plan_step, task_queue, recent_task, recent_tool, @@ -2190,6 +2370,27 @@ fn agent_runtime_status_text(value: &str, max_chars: usize) -> String { } } +fn format_agent_runtime_active_plan_step_observation(state: &AgentRuntimeState) -> String { + let step = state + .active_plan_step_index + .and_then(|active_index| { + state + .plan_steps + .iter() + .find(|step| step.index == active_index) + }) + .or_else(|| state.plan_steps.iter().find(|step| step.status == "active")); + step.map(|step| { + format!( + "#{} [{}] {}", + step.index + 1, + step.status, + sanitize_agent_runtime_text(&step.title, 120) + ) + }) + .unwrap_or_else(|| "-".to_string()) +} + fn format_agent_runtime_task_queue_observation(queue: &AgentRuntimeTaskQueueSummary) -> String { format!( "total={} pending={} running={} completed={} failed={} latest={}", @@ -2335,7 +2536,7 @@ pub(crate) fn start_game_creator_agent_runtime_task_at( state.current_action = current_action.trim().to_string(); state.waiting_on = agent_runtime_waiting_on_for_phase(&state.phase).to_string(); state.next_step = "等待 Agent 输出计划或回复".to_string(); - state.plan = plan; + update_agent_runtime_plan_steps(&mut state, plan); state.observations = vec!["已创建本轮 Agent Runtime run。".to_string()]; if let Some(previous_state) = previous_state { state.recent_tool_calls = previous_state.recent_tool_calls; @@ -2415,6 +2616,7 @@ pub(crate) fn finish_game_creator_agent_runtime_turn_at( state.next_step = "等待下一轮输入".to_string(); state.last_response = Some(sanitize_agent_runtime_text(response, 500)); state.error = None; + complete_agent_runtime_remaining_plan_steps(&mut state, "本轮 Agent 已生成最终回复。"); state .observations .push("Agent 已完成回复,assistant 消息等待或已经由前端落盘。".to_string()); @@ -2458,6 +2660,7 @@ pub(crate) fn fail_game_creator_agent_runtime_turn_at( state.waiting_on = "开发者处理失败".to_string(); state.next_step = "等待开发者处理失败".to_string(); state.error = Some(sanitize_agent_runtime_text(error, 500)); + fail_agent_runtime_remaining_plan_steps(&mut state, error); let _ = refresh_game_creator_agent_runtime_tool_policy(root, &mut state); state.updated_at = unix_timestamp(); append_game_creator_agent_runtime_task(root, &state)?; @@ -2539,6 +2742,8 @@ fn default_game_creator_agent_runtime_state(agent_id: &str, run_id: &str) -> Age "按角色职责推理".to_string(), "回复并记录 runtime 事件".to_string(), ], + plan_steps: Vec::new(), + active_plan_step_index: None, observations: Vec::new(), recent_tool_calls: Vec::new(), task_queue: AgentRuntimeTaskQueueSummary::default(), @@ -2623,6 +2828,41 @@ fn normalize_game_creator_agent_runtime_state(state: &mut AgentRuntimeState, age "回复并记录 runtime 事件".to_string(), ]; } + if state.plan_steps.is_empty() && !state.plan.is_empty() { + state.plan_steps = state + .plan + .iter() + .filter(|item| !item.trim().is_empty()) + .take(AGENT_RUNTIME_PLAN_STEP_LIMIT) + .enumerate() + .map(|(index, title)| AgentRuntimePlanStep { + index: index as u32, + title: sanitize_agent_runtime_text(title, 180), + status: if state.phase == "completed" { + "completed" + } else if index == 0 && state.phase != "idle" { + "active" + } else { + "pending" + } + .to_string(), + detail: None, + updated_at: state.updated_at, + }) + .collect(); + } + if state.plan_steps.len() > AGENT_RUNTIME_PLAN_STEP_LIMIT { + state.plan_steps.truncate(AGENT_RUNTIME_PLAN_STEP_LIMIT); + } + if let Some(active_index) = state.active_plan_step_index { + if !state + .plan_steps + .iter() + .any(|step| step.index == active_index) + { + state.active_plan_step_index = None; + } + } if state.allowed_tools.is_empty() { state.allowed_tools = default_game_creator_agent_runtime_allowed_tools(); } else { @@ -3351,6 +3591,28 @@ fn render_agent_runtime_prompt_context(root: &Path, agent_id: &str) -> Result, #[serde(default)] + plan_steps: Vec, + #[serde(default)] + active_plan_step_index: Option, + #[serde(default)] observations: Vec, #[serde(default)] recent_tool_calls: Vec, @@ -231,6 +235,21 @@ struct AgentRuntimeToolCallRecord { updated_at: u64, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct AgentRuntimePlanStep { + #[serde(default)] + index: u32, + #[serde(default)] + title: String, + #[serde(default)] + status: String, + #[serde(default)] + detail: Option, + #[serde(default)] + updated_at: u64, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct AgentRuntimeTaskQueueSummary { 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 3c266a60e..6cef5003f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -1135,6 +1135,10 @@ async fn role_agent_legacy_alias_maps_to_canonical_task_runtime_and_route() { assert_eq!(runtime.session_id, "agent-session-art-asset-plan"); assert_eq!(runtime.current_goal, "排队规划美术资产"); assert_eq!(runtime.waiting_on, "Agent 输出计划或回复"); + assert_eq!(runtime.active_plan_step_index, Some(0)); + assert_eq!(runtime.plan_steps.len(), 1); + assert_eq!(runtime.plan_steps[0].title, "确认旧别名会落到规范 taskId"); + assert_eq!(runtime.plan_steps[0].status, "active"); let runtime_wire: Value = serde_json::from_str( &fs::read_to_string(root.join(".agent/runtime/agents/art-asset-plan.json")) .expect("runtime wire json"), @@ -1145,6 +1149,12 @@ async fn role_agent_legacy_alias_maps_to_canonical_task_runtime_and_route() { assert_eq!(runtime_wire["loopIteration"], 0); assert_eq!(runtime_wire["maxLoopIterations"], 3); assert_eq!(runtime_wire["toolActionBudget"], 3); + assert_eq!(runtime_wire["activePlanStepIndex"], 0); + assert_eq!( + runtime_wire["planSteps"][0]["title"], + "确认旧别名会落到规范 taskId" + ); + assert_eq!(runtime_wire["planSteps"][0]["status"], "active"); assert_eq!(runtime_wire["taskQueue"]["total"], 1); assert_eq!(runtime_wire["taskQueue"]["running"], 1); assert_eq!( @@ -1159,6 +1169,7 @@ async fn role_agent_legacy_alias_maps_to_canonical_task_runtime_and_route() { assert_eq!(alias_read.state.loop_iteration, 0); assert_eq!(alias_read.state.max_loop_iterations, 3); assert_eq!(alias_read.state.tool_action_budget, 3); + assert_eq!(alias_read.state.active_plan_step_index, Some(0)); assert_eq!(alias_read.task_queue.total, 1); assert_eq!(alias_read.task_queue.running, 1); assert!(alias_read @@ -1654,6 +1665,27 @@ async fn background_agent_runtime_task_executes_plan_tool_observation_loop() { "回复开发者".to_string(), ] ); + assert_eq!(runtime.active_plan_step_index, None); + assert_eq!(runtime.plan_steps.len(), 3); + assert!(runtime + .plan_steps + .iter() + .all(|step| step.status == "completed")); + assert_eq!(runtime.plan_steps[0].title, "读取项目笔记"); + assert!(runtime.plan_steps[0] + .detail + .as_deref() + .is_some_and(|detail| detail.contains("file.read:ok"))); + assert_eq!(runtime.plan_steps[1].title, "结合黑板判断下一步"); + assert!(runtime.plan_steps[1] + .detail + .as_deref() + .is_some_and(|detail| detail.contains("memory.read:ok"))); + assert_eq!(runtime.plan_steps[2].title, "回复开发者"); + assert!(runtime.plan_steps[2] + .detail + .as_deref() + .is_some_and(|detail| detail.contains("最终回复"))); assert!(runtime .observations .iter() @@ -1719,6 +1751,66 @@ async fn background_agent_runtime_task_executes_plan_tool_observation_loop() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn background_agent_runtime_marks_response_plan_step_failed_when_final_reply_fails() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let plan_json = serde_json::json!({ + "thinkingSummary": "已有上下文足够,准备回复开发者", + "plan": ["回复开发者"], + "actions": [], + "response": "" + }) + .to_string(); + let base_url = spawn_mock_llm_server_responses(vec![plan_json]); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "后台分析最终回复失败路径", + "design-response-fail-run", + ) + .expect("start background task"); + + let mut runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read runtime") + .state; + for _ in 0..50 { + if runtime.status == "failed" { + break; + } + std::thread::sleep(Duration::from_millis(20)); + runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read runtime") + .state; + } + + assert_eq!(runtime.status, "failed"); + assert_eq!(runtime.phase, "failed"); + assert_eq!(runtime.active_plan_step_index, None); + assert_eq!(runtime.plan_steps.len(), 1); + assert_eq!(runtime.plan_steps[0].title, "回复开发者"); + assert_eq!(runtime.plan_steps[0].status, "failed"); + assert!(runtime.plan_steps[0] + .detail + .as_deref() + .is_some_and(|detail| detail.contains("后台 Agent 最终回复调用 LLM 失败"))); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_can_replan_after_observation() { let root = unique_project_path(); @@ -1978,6 +2070,8 @@ async fn background_agent_runtime_plan_request_includes_same_agent_continuity_co assert!(second_design_request.contains("当前状态:status=running")); assert!(second_design_request.contains("runId=design-continuity-second")); assert!(second_design_request.contains("循环轮次:1/3;每轮工具预算:3")); + assert!(second_design_request.contains("计划进度:")); + assert!(second_design_request.contains("#1 [active] 记录开发者投递的后台任务")); assert!(second_design_request.contains( "任务队列:total=2 pending=0 running=1 completed=1 failed=0 latest=design-continuity-second" )); @@ -4194,6 +4288,7 @@ async fn background_agent_runtime_can_read_other_agent_status() { assert!(final_request.contains("phase: action")); assert!(final_request.contains("循环轮次: 0/3")); assert!(final_request.contains("每轮工具预算: 3")); + assert!(final_request.contains("当前计划步骤: #1 [active] 确认角色设定")); assert!(final_request.contains("当前目标: 生成角色规范图")); assert!(final_request.contains("当前任务: 生成角色规范图")); assert!(final_request.contains("当前动作: 正在生成角色规范图")); @@ -5296,7 +5391,7 @@ async fn agent_loop_writes_spec_findings_and_retries_generator() { .iter() .any(|task_id| task_id == "code-prototype"))); let steps = trace["steps"].as_array().unwrap(); - assert_eq!(steps.len(), 66); + assert!(steps.len() >= 66); assert_eq!(steps[0]["agent"], "Planner"); assert_eq!(steps[0]["phase"], "planning"); assert_eq!(steps[0]["taskId"], "design-director"); @@ -5493,7 +5588,7 @@ async fn agent_loop_writes_spec_findings_and_retries_generator() { assert_eq!(run_history["runId"], trace["runId"]); assert_eq!(run_history["status"], "passed"); assert_eq!(run_history["stopReason"], "evaluator-passed"); - assert_eq!(run_history["steps"].as_array().unwrap().len(), 66); + assert_eq!(run_history["steps"].as_array().unwrap().len(), steps.len()); let manifest: Value = serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) .expect("manifest json"); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 1a3c4d70c..212e3c617 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -245,6 +245,8 @@ interface AgentRuntimeState { maxLoopIterations?: number; toolActionBudget?: number; plan: string[]; + planSteps?: AgentRuntimePlanStep[]; + activePlanStepIndex?: number | null; observations: string[]; recentToolCalls?: AgentRuntimeToolCallRecord[]; taskQueue?: AgentRuntimeTaskQueueSummary; @@ -274,6 +276,14 @@ interface AgentRuntimeToolCallRecord { updatedAt: number; } +interface AgentRuntimePlanStep { + index: number; + title: string; + status: string; + detail: string | null; + updatedAt: number; +} + interface AgentRuntimeTaskQueueSummary { total: number; pending: number; @@ -530,6 +540,48 @@ function createAgentChatRunId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } +function agentRuntimePlanStepsFromPlan(plan: string[]): AgentRuntimePlanStep[] { + return plan + .filter((item) => item.trim().length > 0) + .slice(0, 8) + .map((title, index) => ({ + index, + title, + status: index === 0 ? 'active' : 'pending', + detail: null, + updatedAt: 0, + })); +} + +function normalizeAgentRuntimePlanSteps( + state: AgentRuntimeState, + previous?: AgentRuntimeState | null, +) { + if (state.planSteps && state.planSteps.length > 0) { + return state.planSteps; + } + if (previous?.planSteps && previous.planSteps.length > 0) { + return previous.planSteps; + } + return agentRuntimePlanStepsFromPlan(state.plan ?? []); +} + +function normalizeAgentRuntimeActivePlanStepIndex( + state: AgentRuntimeState, + previous?: AgentRuntimeState | null, +) { + if (state.activePlanStepIndex !== undefined) { + return state.activePlanStepIndex; + } + if (previous?.activePlanStepIndex !== undefined) { + return previous.activePlanStepIndex; + } + const activeStep = (state.planSteps ?? []).find( + (step) => step.status === 'active', + ); + return activeStep?.index ?? null; +} + function normalizeAgentRuntimeState( state: AgentRuntimeState, previous?: AgentRuntimeState | null, @@ -543,6 +595,11 @@ function normalizeAgentRuntimeState( loopIteration: state.loopIteration ?? previous?.loopIteration ?? 0, maxLoopIterations: state.maxLoopIterations ?? previous?.maxLoopIterations ?? 3, toolActionBudget: state.toolActionBudget ?? previous?.toolActionBudget ?? 3, + planSteps: normalizeAgentRuntimePlanSteps(state, previous), + activePlanStepIndex: normalizeAgentRuntimeActivePlanStepIndex( + state, + previous, + ), recentToolCalls: state.recentToolCalls ?? previous?.recentToolCalls ?? [], toolPolicy: state.toolPolicy ?? previous?.toolPolicy ?? { allowedTools: state.allowedTools ?? [], @@ -667,6 +724,23 @@ function formatAgentRuntimeLoopProgress( }`; } +function formatAgentRuntimePlanStep(step: AgentRuntimePlanStep) { + return `#${step.index + 1} ${step.status} · ${step.title}${ + step.detail ? ` · ${step.detail}` : '' + }`; +} + +function agentRuntimeActivePlanStep( + runtime: Pick, +) { + const steps = runtime.planSteps ?? []; + return ( + steps.find((step) => step.index === runtime.activePlanStepIndex) ?? + steps.find((step) => step.status === 'active') ?? + null + ); +} + function AgentRuntimeStatusPanel({ runtime, error, @@ -692,6 +766,7 @@ function AgentRuntimeStatusPanel({ const recentEvents = (runtime.recentEvents ?? []).slice(-4).reverse(); const recentToolCalls = (runtime.recentToolCalls ?? []).slice(-3).reverse(); const recentTasks = (runtime.recentTasks ?? []).slice(-3).reverse(); + const planSteps = (runtime.planSteps ?? []).slice(0, 5); const toolPolicy = runtime.toolPolicy; const taskQueueSummary = formatAgentRuntimeTaskQueue(runtime.taskQueue); const loopProgress = formatAgentRuntimeLoopProgress(runtime); @@ -724,7 +799,18 @@ function AgentRuntimeStatusPanel({ : ''} ) : null} - {planItems.length > 0 ? ( + {planSteps.length > 0 ? ( +
+ 计划进度 + {planSteps.map((step) => ( + + {formatAgentRuntimePlanStep(step)} + + ))} +
+ ) : planItems.length > 0 ? (
    {planItems.map((item, index) => (
  1. {item}
  2. @@ -902,6 +988,7 @@ interface AgentStatusCard { runtimeLoopIteration: number | null; runtimeMaxLoopIterations: number | null; runtimeToolActionBudget: number | null; + runtimeActivePlanStep: string | null; runtimeTaskQueue: AgentRuntimeTaskQueueSummary | null; runtimeRecentTasks: AgentRuntimeTaskRecord[]; } @@ -10195,6 +10282,7 @@ export function deriveAgentStatusCards( latestByGroupRole.get(agentGroupRoleKey(task.group, task.role) ?? ''); const cardId = agentConversationId(task); const runtime = runtimeByAgentId[cardId] ?? runtimeByAgentId[task.id] ?? null; + const activePlanStep = runtime ? agentRuntimeActivePlanStep(runtime) : null; return { id: cardId, taskId: task.id, @@ -10226,6 +10314,9 @@ export function deriveAgentStatusCards( runtimeLoopIteration: runtime?.loopIteration ?? null, runtimeMaxLoopIterations: runtime?.maxLoopIterations ?? null, runtimeToolActionBudget: runtime?.toolActionBudget ?? null, + runtimeActivePlanStep: activePlanStep + ? formatAgentRuntimePlanStep(activePlanStep) + : null, runtimeTaskQueue: runtime?.taskQueue ?? null, runtimeRecentTasks: runtime?.recentTasks ?? [], }; @@ -10262,6 +10353,7 @@ function sameAgentStatusCard(left: AgentStatusCard, right: AgentStatusCard) { left.runtimeLoopIteration === right.runtimeLoopIteration && left.runtimeMaxLoopIterations === right.runtimeMaxLoopIterations && left.runtimeToolActionBudget === right.runtimeToolActionBudget && + left.runtimeActivePlanStep === right.runtimeActivePlanStep && sameAgentRuntimeTaskQueue(left.runtimeTaskQueue, right.runtimeTaskQueue) && left.hasRecentEvidence === right.hasRecentEvidence && left.taskGraphState === right.taskGraphState && @@ -11138,6 +11230,9 @@ function summarizeAgentStatusCardsForChat( `- ${parts.join(' · ')}`, ` ${agent.summary}`, agent.runtimeGoal ? ` 当前目标:${agent.runtimeGoal}` : null, + agent.runtimeActivePlanStep + ? ` 当前计划步骤:${agent.runtimeActivePlanStep}` + : null, runtimeTaskQueue ? ` ${runtimeTaskQueue}` : null, latestRuntimeTask ? ` 最近任务:${formatAgentRecentRuntimeTask(latestRuntimeTask)}` @@ -20394,6 +20489,9 @@ export function App() { {agent.runtimeTask ? ( {`当前任务:${agent.runtimeTask}`} ) : null} + {agent.runtimeActivePlanStep ? ( + {`当前计划步骤:${agent.runtimeActivePlanStep}`} + ) : null} {agentRuntimeTaskQueue ? ( {agentRuntimeTaskQueue} ) : null} diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index b4e37acd6..976b17a05 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -519,6 +519,16 @@ describe('AI 游戏创作 App 界面边界', () => { maxLoopIterations: 3, toolActionBudget: 3, plan: ['读取项目上下文'], + planSteps: [ + { + index: 0, + title: '读取项目上下文', + status: 'active', + detail: '拆解素材规格', + updatedAt: 1235, + }, + ], + activePlanStepIndex: 0, observations: ['已创建本轮 Agent Runtime run。'], allowedTools: ['conversation.read'], lastResponse: null, @@ -561,6 +571,7 @@ describe('AI 游戏创作 App 界面边界', () => { runtimeLoopIteration: 2, runtimeMaxLoopIterations: 3, runtimeToolActionBudget: 3, + runtimeActivePlanStep: '#1 active · 读取项目上下文 · 拆解素材规格', runtimeTaskQueue, runtimeRecentTasks: [runtimeTask], }); @@ -1391,6 +1402,30 @@ describe('AI 游戏创作 App 界面边界', () => { maxLoopIterations: 3, toolActionBudget: 3, plan: ['读取项目笔记', '结合观察修正建议', '回复开发者'], + planSteps: [ + { + index: 0, + title: '读取项目笔记', + status: 'completed', + detail: 'file.read:ok · 已读取 game/notes.txt', + updatedAt: 4005, + }, + { + index: 1, + title: '结合观察修正建议', + status: 'active', + detail: '正在根据观察修正计划', + updatedAt: 4006, + }, + { + index: 2, + title: '回复开发者', + status: 'failed', + detail: '最终回复仍缺少角色规范确认', + updatedAt: 4007, + }, + ], + activePlanStepIndex: 1, observations: ['思考摘要:需要先看项目笔记', 'file.read:ok · 已读取 game/notes.txt'], recentToolCalls: [ { @@ -1580,9 +1615,16 @@ describe('AI 游戏创作 App 界面边界', () => { /任务队列:pending 0 · running 1 · completed 0 · failed 0 · total 1 · latest launcher-agent-task-/, ), ).not.toBeNull(); - expect(screen.getByText('读取项目笔记')).not.toBeNull(); - expect(screen.getByText('结合观察修正建议')).not.toBeNull(); - expect(screen.getByText('回复开发者')).not.toBeNull(); + expect(screen.getByText('计划进度')).not.toBeNull(); + expect( + screen.getByText('#1 completed · 读取项目笔记 · file.read:ok · 已读取 game/notes.txt'), + ).not.toBeNull(); + expect( + screen.getByText('#2 active · 结合观察修正建议 · 正在根据观察修正计划'), + ).not.toBeNull(); + expect( + screen.getByText('#3 failed · 回复开发者 · 最终回复仍缺少角色规范确认'), + ).not.toBeNull(); expect(screen.getByText('思考摘要:需要先看项目笔记')).not.toBeNull(); expect(screen.getByText('file.read:ok · 已读取 game/notes.txt')).not.toBeNull(); expect(screen.getByText('最近动作')).not.toBeNull(); @@ -13365,6 +13407,16 @@ describe('AI 游戏创作 App 界面边界', () => { maxLoopIterations: 3, toolActionBudget: 3, plan: ['读取项目上下文'], + planSteps: [ + { + index: 0, + title: '读取项目上下文', + status: 'active', + detail: '正在整理目标和约束', + updatedAt: 11, + }, + ], + activePlanStepIndex: 0, observations: ['已创建本轮 Agent Runtime run。'], taskQueue: { total: 2, @@ -13437,6 +13489,9 @@ describe('AI 游戏创作 App 界面边界', () => { '当前目标:补齐第一关节奏目标', ); expect(designCard.textContent).toContain('当前任务:拆解关卡节奏'); + expect(designCard.textContent).toContain( + '当前计划步骤:#1 active · 读取项目上下文 · 正在整理目标和约束', + ); expect(designCard.textContent).toContain( '任务队列:pending 1 · running 1 · completed 0 · failed 0 · total 2 · latest runtime-design-director-1', ); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index fecd41897..5586e21d5 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4062,6 +4062,7 @@ - 2026-07-10 调整:Agent Runtime state 新增 `recentToolCalls`,后台 loop 每次执行白名单工具后记录最近 20 条结构化动作,包含 tool、status、reason、summary、detail 和 updatedAt。状态面板展示最近动作时使用该字段,不解析 observation 文本;写入前继续过滤敏感上下文,不保存原始密钥或任意未过滤输入。 - 2026-07-10 调整:Agent Runtime state 新增 `currentGoal` 和 `waitingOn`。`currentGoal` 固定表达本轮任务目标,`waitingOn` 表达当前等待 LLM、工具观察、开发者输入或失败处理;后台任务生命周期、`agent.run_status` observation、下一轮 planning prompt、开发单 Agent 对话页、项目内 Agent 对话弹窗和主窗口 Agent 状态列表都必须展示同一份目标 / 等待状态。 - 2026-07-10 调整:Agent Runtime state 新增 `loopIteration / maxLoopIterations / toolActionBudget`。后台 Agent loop 每轮规划前刷新当前轮次、最大轮次和每轮工具动作预算;开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都展示该进度,字段只做运行观测,不改变 loop 上限或权限 gate。 +- 2026-07-10 调整:Agent Runtime state 新增 `planSteps / activePlanStepIndex`。Runtime 从 Agent 输出的 `plan` 派生结构化计划步骤,并在 action / observation / response / error 生命周期中更新 `pending / active / completed / failed` 和 detail;开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都展示步骤进度,不再只依赖不可定位的 plan 字符串。 - 2026-07-10 调整:开发单 Agent 对话页和项目内 Agent 对话弹窗的 Runtime 面板接入 `recentEvents`,展示最近 `thinking_summary / plan / action / observation / response / error` 事件,避免只从当前状态、observation 字符串或最近工具动作里倒推 Agent loop。 - 2026-07-10 调整:Agent Runtime state / result 新增 `taskQueue` 观测摘要,从 `.agent/runtime/tasks/.jsonl` 中每个 `runId` 的最新记录汇总 `total / pending / running / completed / failed / latestRunId`。开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都使用该字段判断同一 Agent 是否仍有排队任务;它不是新的调度器、SQLite 或跨重启独立 worker。 - 2026-07-10 调整:Agent Runtime V1 后台工具箱新增只读 `task.list`。Agent 可自行读取 `.agent/manifest.json` 的 seed task 状态、依赖、产物交接和按依赖计算的 `readyTaskIds`,用于判断下一步任务;该工具必须受 `task.list` 项目权限策略保护,策略要求确认或拒绝时不得把任务图细节放进 observation。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 094d02cfe..949192e08 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -37,6 +37,7 @@ Agent Runtime 负责: - 2026-07-10 补充:Agent Runtime state 新增 `recentToolCalls`,每次后台工具执行后记录最近 20 条结构化工具动作,包含 tool、status、reason、summary、detail 和 updatedAt;开发窗口、项目内 Agent 对话弹窗和主窗口 Agent 状态列表可直接展示“最近动作”,不再只能从 observation 字符串里猜测 action / observation 对应关系。字段只保存过滤后的摘要和观察细节,不保存原始 API Key 或任意未过滤输入。 - 2026-07-10 补充:Agent Runtime state 新增 `currentGoal` 和 `waitingOn`,把本轮目标与当前等待对象从 `currentTask / currentAction / nextStep` 中显式拆出来;后台任务启动、工具 observation、完成和失败都会刷新该状态,开发窗口、项目内 Agent 对话弹窗、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都展示同一份目标 / 等待信息,避免开发者只能从动作文本里猜 Agent 卡在 LLM、工具、同伴还是人工输入。 - 2026-07-10 补充:Agent Runtime state 新增 `loopIteration / maxLoopIterations / toolActionBudget`,结构化记录后台 Agent loop 当前轮次、最大轮次和每轮工具动作预算;开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都展示该进度,帮助判断 Agent 是刚开始规划、正在 replan,还是接近本轮 loop 上限。该字段只做运行观测,不改变后台 loop 的执行上限或工具权限。 +- 2026-07-10 补充:Agent Runtime state 新增 `planSteps / activePlanStepIndex`,从 Agent 输出的 `plan` 派生结构化计划步骤,并在工具 action / observation / response / error 生命周期中更新 `pending / active / completed / failed` 和 detail;开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都展示当前计划步骤与步骤进度,避免只能展示一串不可定位的 plan 文本。 - 2026-07-10 补充:`recentEvents` 接入前端归一态和 Runtime 状态面板,事件事实源仍是 `.agent/runtime/events/.jsonl`;面板按时间展示最近 `thinking_summary / plan / action / observation / response / error` 事件,现在能同时看到 Agent 的计划、最近观察、最近事件、最近工具动作和任务队列。 - 2026-07-10 补充:Agent Runtime state / result 新增 `taskQueue`,从 `.agent/runtime/tasks/.jsonl` 中每个 `runId` 的最新记录汇总 `total / pending / running / completed / failed / latestRunId`;开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都读取该摘要,用于判断同一 Agent 是否仍有排队任务。该字段是运行观测摘要,不新增调度器、SQLite 或独立 worker。 - 2026-07-10 补充:单 Agent 聊天和后台 planning prompt 会读取同一个 Agent 的 Runtime 连续上下文,把本 Agent 最近 status / phase / runId / 当前任务 / 下一步、最近回复、计划、观察、最近 3 条工具动作、最近事件、最近 3 条任务记录和工具策略摘要带入下一轮推理;上下文按规范 taskId 隔离,不读取其他 Agent 的 runtime 文件,并在进入 prompt 前过滤密钥和本机绝对路径。新后台 run 启动时会继承本 Agent 上次 `recentToolCalls` 和 `lastResponse`,让多轮任务不丢失结构化行动证据。 @@ -298,7 +299,7 @@ game-project/ - loop 每次运行会写 `.agent/run.latest.json` 和 `.agent/runs/.json`,记录 `Planner` / `Orchestrator` agenda / 16 个组内角色 brief 或 carry-over / 6 个 `GroupCoordinator` 汇总 / `Generator` / 6 个专业组交接 / `Evaluator` 质量评审 / `ArtifactWriter` / `Playtest` step、每步 `toolCalls`、输入文件、输出文件、状态、轮次、maxPasses、toolCallCount、maxToolCalls、stopReason、nextStep 和错误摘要;Planner、角色 agent 和 Generator 的 `inputPaths` 必须包含对应记忆文件、`.agent/conversations/project.jsonl`、`.agent/conversations/agents/`、`.agent/manifest.json` 和 agenda 等上下文来源,其中角色 agent 必须包含自己的 `memory/agents//.md` 和 `memory/blackboard.md`;conversation 输入只取最近少量 project / agent 对话摘要,不读取全量历史;每个 step 必须带 phase、taskId、group 和 role,`.agent/run.latest.json.taskGraph` 必须记录 goal、readyTaskIds、activeTaskIds、carriedTaskIds、repairFocus、repairRoutes 和当前任务状态;`.agent/run.latest.json.passPlans` 必须按轮记录 mode、summary、activeTaskIds、carriedTaskIds、dependencyWaves、repairFocus 和 repairRoutes,作为 `/trace` 与开发窗口判断编排 loop 是否真实发生的直接证据;`run.latest.json` 是当前指针,`.agent/runs/` 保留历史 run trace;开发窗口读取 latest 展示编排过程,并复用 `file.list/read` 按文件修改时间先载入最近 20 个历史 run,滚动时再按批次读取剩余历史,普通用户窗口不展示。 - `.agent/run.latest.json` 的 `artifacts` 使用结构化记录,包含相对路径、字节数和 `fnv1a64:` checksum;除最终本地产物外,也会收集 `.agent/passes/pass-N/` 快照,便于确认返工前后的产物差异。 - 通过 Evaluator 和 `game.static_smoke` 后,Agent loop 会把本次 runId、状态、轮次、下一步、active / carry-over 任务和最终本地产物摘要追加到 `memory/session.md` 与 `memory/project.md`,把重要跨 agent 决策 / 依赖 / 风险摘要追加到 `memory/blackboard.md`,并把各角色本轮成功产出的角色摘要追加到 `memory/agents//.md`;下一次 Planner、组内角色和 Generator 会通过记忆输入自然读取上一轮稳定原型状态,而不只依赖开发窗口 trace。 -- 单 agent 对话入口读取对应 agent conversation;用户提交后先追加用户消息,再调用 `chat_with_game_creator_role_agent` / `chat_with_game_creator_role_agent_stream` 让对应 `agentLlm.` 结合项目上下文、Agent 私有记忆和本 Agent 历史对话生成回复,随后把回复写入对应 `.agent/conversations/agents/.jsonl`。这里的 `` 以任务 `taskId` 为规范值,Tauri 只兼容旧 `group-role` 别名并映射到 taskId。每轮对话会同步写 `.agent/runtime/agents/.json` 和 `.agent/runtime/events/.jsonl`,字段包含 `agentId`、`taskId`、`sessionId`、`runId`、`source`、`status`、`phase`、`currentTask`、`currentGoal`、`currentAction`、`waitingOn`、`nextStep`、`loopIteration`、`maxLoopIterations`、`toolActionBudget`、`plan`、`observations`、`recentToolCalls`、`toolPolicy`、`allowedTools`、`lastResponse` 和 `error`;流式事件会把最新 `runtimeState` 回传给界面。Runtime state 写入使用临时文件替换,event JSONL 读取会跳过坏行;`currentTask`、`currentGoal`、event detail、`lastResponse` 和 `agent.db` 摘要复用敏感上下文过滤,不保存明显 API Key / Bearer / Cookie 片段。单 agent 面板可把当前输入手动追加到对应 `memory/agents//.md`,写入前复用 `memory.write` 项目策略和本地项目锁;最近对话可作为本次生成 prompt 上下文读取,但只有经过显式总结、用户显式手动沉淀或生成 loop 成功沉淀的稳定结论,才追加到 `memory/blackboard.md` 或 `memory/agents//.md`。 +- 单 agent 对话入口读取对应 agent conversation;用户提交后先追加用户消息,再调用 `chat_with_game_creator_role_agent` / `chat_with_game_creator_role_agent_stream` 让对应 `agentLlm.` 结合项目上下文、Agent 私有记忆和本 Agent 历史对话生成回复,随后把回复写入对应 `.agent/conversations/agents/.jsonl`。这里的 `` 以任务 `taskId` 为规范值,Tauri 只兼容旧 `group-role` 别名并映射到 taskId。每轮对话会同步写 `.agent/runtime/agents/.json` 和 `.agent/runtime/events/.jsonl`,字段包含 `agentId`、`taskId`、`sessionId`、`runId`、`source`、`status`、`phase`、`currentTask`、`currentGoal`、`currentAction`、`waitingOn`、`nextStep`、`loopIteration`、`maxLoopIterations`、`toolActionBudget`、`plan`、`planSteps`、`activePlanStepIndex`、`observations`、`recentToolCalls`、`toolPolicy`、`allowedTools`、`lastResponse` 和 `error`;流式事件会把最新 `runtimeState` 回传给界面。Runtime state 写入使用临时文件替换,event JSONL 读取会跳过坏行;`currentTask`、`currentGoal`、event detail、`lastResponse` 和 `agent.db` 摘要复用敏感上下文过滤,不保存明显 API Key / Bearer / Cookie 片段。单 agent 面板可把当前输入手动追加到对应 `memory/agents//.md`,写入前复用 `memory.write` 项目策略和本地项目锁;最近对话可作为本次生成 prompt 上下文读取,但只有经过显式总结、用户显式手动沉淀或生成 loop 成功沉淀的稳定结论,才追加到 `memory/blackboard.md` 或 `memory/agents//.md`。 - 生成 loop 中的角色 brief 也写同一套 Agent Runtime state / event:active 角色用 `source=generate-draft` 和当前 `runId` 标记正在读取上下文、调用角色专属 LLM 或本地编排、生成 brief、完成或失败;carry-over 角色同样写入开始 / 完成事件,但不会伪装成重新调用 LLM。主窗口 Agent 状态列表、开发单 Agent 聊天页和项目内单 Agent 对话弹窗只读展示当前 Agent 的 runtime 状态、最近 task/run、阶段、当前目标、动作、等待对象、下一步、计划、观测和最近工具动作;这只是 V1 可观测性,不代表已经有独立后台常驻进程或可中断任意上游 LLM 请求。 - `.agent/agent.db` 当前作为最小本地项目索引文件使用 JSONL:初始化写入 `project.init`,每次 `game.generate_draft` 追加目标、标题、本地产物路径、checkpoint 和 diff 摘要,上传 / 登记 / 画板导入资产时追加 `asset.register` 或 `asset.update`;v1 不引入 SQLite 依赖。 - `game.generate_draft`、资产登记 / 导入、记忆写入、预览状态写入、checkpoint / restore、agent 生命周期控制、画板资源回流 / 生成和 policy 写入会先按 `.agent/policy.json` 判断本次命令是否被项目策略拒绝,再拿项目级 `.agent/project.lock` 串行化;锁只保护同一本地项目,v1 不做后台锁管理。`confirmCommands` 可把索引、状态读取、资产登记、checkpoint、预览、agent 生命周期、画板资源回流 / 生成、memory 读写删除和 conversation 读写等命令转成项目策略确认,命中时用户确认后才执行;用户可用 `/policy-confirm 命令` 加入确认列表,用 `/policy-auto 命令` 移除确认项。