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 64b0948c6..e1a334b19 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -116,15 +116,20 @@ pub(crate) async fn chat_with_game_creator_agent_at( let asset_context = render_local_asset_prompt_context(root)?; let conversation_context = render_local_conversation_prompt_context(root, None)?; let context = [ - ("短期记忆", short_memory.as_str()), - ("长期记忆", long_memory.as_str()), - ("项目黑板", project_blackboard.as_str()), - ("资产上下文", asset_context.as_str()), - ("最近项目对话", conversation_context.as_str()), + ("短期记忆", truncate_prompt_context(&short_memory)), + ("长期记忆", truncate_prompt_context(&long_memory)), + ( + "项目黑板", + truncate_prompt_context_preserving_tail(&project_blackboard), + ), + ("资产上下文", truncate_prompt_context(&asset_context)), + ( + "最近项目对话", + truncate_prompt_context_preserving_tail(&conversation_context), + ), ] .into_iter() .filter_map(|(title, content)| { - let content = truncate_prompt_context(content); if content.trim().is_empty() { None } else { @@ -1854,7 +1859,7 @@ async fn drain_next_game_creator_agent_background_tasks(root: PathBuf, agent_id: } } -fn spawn_next_game_creator_agent_background_task_drain( +pub(crate) fn spawn_next_game_creator_agent_background_task_drain( root: &Path, agent_id: &str, ) -> Result<(), String> { @@ -1866,7 +1871,7 @@ fn spawn_next_game_creator_agent_background_task_drain( Ok(()) } -fn spawn_next_game_creator_agent_background_task_drain_with_lock( +pub(crate) fn spawn_next_game_creator_agent_background_task_drain_with_lock( root: &Path, agent_id: &str, runtime_lock: AgentRuntimeTaskLock, @@ -1911,6 +1916,7 @@ async fn run_game_creator_agent_background_task_with_context( let mut plan = initial_plan; let mut observations = initial_observations; let mut final_reply = None; + let mut converged = false; if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { return AgentBackgroundTaskOutcome::Finished; @@ -1944,6 +1950,7 @@ async fn run_game_creator_agent_background_task_with_context( &root, &agent_id, &runtime.session_id, + &runtime.run_id, &task, &observations, loop_index + 1, @@ -2023,6 +2030,7 @@ async fn run_game_creator_agent_background_task_with_context( } if plan.actions.is_empty() { + converged = true; if !plan.response.trim().is_empty() { activate_agent_runtime_response_plan_step( &mut runtime, @@ -2035,6 +2043,33 @@ async fn run_game_creator_agent_background_task_with_context( break; } + if plan.actions.len() > AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT { + let budget_observation = AgentRuntimeToolObservation { + tool: "runtime.tool_budget".to_string(), + status: "blocked".to_string(), + summary: format!( + "本轮请求了 {} 个工具动作,只执行前 {} 个", + plan.actions.len(), + AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT + ), + detail: Some("请在下一轮根据已有 observation 重新排序剩余动作。".to_string()), + }; + let budget_summary = budget_observation.summary(); + runtime.observations.push(budget_summary.clone()); + runtime.updated_at = unix_timestamp(); + let _ = write_game_creator_agent_runtime_state(&root, &runtime); + let _ = append_game_creator_agent_runtime_event( + &root, + &runtime, + "observation", + runtime.status.as_str(), + runtime.phase.as_str(), + &budget_summary, + budget_observation.detail.as_deref(), + ); + observations.push(budget_observation); + } + for (action_index, action) in plan .actions .iter() @@ -2372,6 +2407,49 @@ async fn run_game_creator_agent_background_task_with_context( return AgentBackgroundTaskOutcome::Finished; } + if !converged { + let last_non_ok_observation = observations + .iter() + .rev() + .find(|observation| observation.status != "ok") + .map(AgentRuntimeToolObservation::summary); + let error = match last_non_ok_observation { + Some(observation) => format!( + "loop-budget-exhausted: 已执行 {AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT} 轮仍有未收束动作;最近异常 observation:{observation}" + ), + None => format!( + "loop-budget-exhausted: 已执行 {AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT} 轮仍有未收束动作" + ), + }; + let failed_runtime = fail_game_creator_agent_runtime_budget_at(&root, runtime, &error); + let _ = append_local_conversation_message_for_session_at( + &root, + Some(&agent_id), + Some(&session_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: format!("后台任务未完成:{error}"), + agent_id: None, + }, + ); + if let Ok(runtime) = failed_runtime { + let _ = append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.failed", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "source": runtime.source, + "failureKind": "loop-budget-exhausted", + "error": runtime.error, + }), + ); + } + return AgentBackgroundTaskOutcome::Finished; + } + let final_reply = if let Some(reply) = final_reply { reply } else { @@ -2401,6 +2479,7 @@ async fn run_game_creator_agent_background_task_with_context( &root, &agent_id, &runtime.session_id, + &runtime.run_id, &task, &plan, &observations, @@ -3081,6 +3160,7 @@ async fn request_game_creator_agent_background_tool_plan_at( root: &Path, agent_id: &str, session_id: &str, + run_id: &str, task: &str, observations: &[AgentRuntimeToolObservation], loop_index: usize, @@ -3089,6 +3169,7 @@ async fn request_game_creator_agent_background_tool_plan_at( root, agent_id, session_id, + run_id, task, observations, loop_index, @@ -3104,6 +3185,7 @@ async fn request_game_creator_agent_background_final_reply_at( root: &Path, agent_id: &str, session_id: &str, + run_id: &str, task: &str, plan: &AgentRuntimeToolPlan, observations: &[AgentRuntimeToolObservation], @@ -3112,6 +3194,7 @@ async fn request_game_creator_agent_background_final_reply_at( root, agent_id, session_id, + run_id, task, plan, observations, @@ -3131,12 +3214,13 @@ fn build_game_creator_agent_background_tool_plan_request( root: &Path, agent_id: &str, session_id: &str, + run_id: &str, task: &str, observations: &[AgentRuntimeToolObservation], loop_index: usize, ) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), String> { let (llm, config_path, context) = - build_game_creator_role_agent_context_for_session(root, agent_id, Some(session_id))?; + build_game_creator_background_agent_context(root, agent_id, session_id, run_id)?; let observations_json = if observations.is_empty() { "[]".to_string() } else { @@ -3147,7 +3231,7 @@ fn build_game_creator_agent_background_tool_plan_request( let tool_policy_json = serde_json::to_string_pretty(&tool_policy) .map_err(|error| format!("序列化 Agent 工具策略失败:{error}"))?; let prompt = format!( - "当前工具策略:\n{tool_policy_json}\n\n项目上下文如下。你正在执行后台 Agent loop 第 {loop_index} 轮。请基于目标、已有工具观察和当前上下文修正计划,再决定是否调用最多 {AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT} 个白名单工具。只输出 JSON 对象,不要 markdown。\n\n{context}\n\n后台任务:\n{task}\n\n已有工具观察:\n{observations_json}\n\nJSON schema:{{\"thinkingSummary\":\"一句话理解\",\"plan\":[\"步骤\"],\"actions\":[{{\"tool\":\"memory.read|memory.write|conversation.read|asset.list|project.index|project.checkpoint|project.restore|project.diff|file.list|file.read|file.write|task.list|task.create|task.update|command.run_limited|preview.start|canvas.asset_generate|blackboard.write|agent.message|agent.delegate|agent.schedule_ready|agent.run_status\",\"reason\":\"为什么需要\",\"input\":{{}}}}],\"response\":\"如果无需继续调用工具,可直接给最终回复\"}}\n\n工具输入约定:memory.read 使用 {{\"scope\":\"session|project|blackboard|agent\"}};memory.write 使用 {{\"scope\":\"agent|project|session|blackboard\",\"title\":\"标题\",\"content\":\"要沉淀的稳定结论\",\"mode\":\"append|overwrite\"}},其中 agent scope 只能写当前 Agent 自己的私有记忆,跨 Agent 共享请用 blackboard.write 或 agent.message;project.checkpoint input 可为空,用于在写文件或批量修改前创建本地 checkpoint;project.restore 使用 {{\"checkpointId\":\"checkpoint id\"}},用于在确认后把当前项目恢复到指定 checkpoint;project.diff 使用 {{\"checkpointId\":\"checkpoint id\"}},用于读取当前项目相对路径 diff 摘要;file.list 使用 {{\"path\":\"可选项目内相对目录或文件\"}},path 为空时列出项目摘要;file.read 使用 {{\"path\":\"项目内相对路径\"}};file.write 使用 {{\"path\":\"项目内相对路径\",\"content\":\"文件内容\"}};task.list input 可为空,用于读取 manifest 任务图、状态和 readyTaskIds;task.create 使用 {{\"taskId\":\"可选自定义 taskId\",\"title\":\"任务标题\",\"group\":\"design|art|code|balance|audio|publishing\",\"role\":\"角色名\",\"dependencies\":[\"已有 taskId\"],\"artifacts\":[\"预期产物\"],\"acceptanceCriteria\":[\"验收标准\"],\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}},用于把 Agent 拆出的新任务追加到 manifest;task.update 使用 {{\"taskId\":\"manifest taskId\",\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}};command.run_limited 使用 {{\"commandId\":\"game.static_smoke\"}},只支持本地静态自检;preview.start input 可为空,用于启动当前项目的 127.0.0.1 本地 HTTP 预览;canvas.asset_generate 使用 {{\"prompt\":\"要生成的美术素材描述\"}},通过配置的 External Editor API 生成首版素材并登记到 assets;blackboard.write 使用 {{\"title\":\"标题\",\"content\":\"要共享给所有 Agent 的稳定结论\"}};agent.message 使用 {{\"agentId\":\"目标 taskId\",\"content\":\"给目标 Agent 的定向消息\"}};agent.delegate 使用 {{\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"runId\":\"可选 run id\"}},用于把任务投递到另一个 Agent 的独立队列;agent.schedule_ready input 可为空或 {{\"limit\":1}},用于把 manifest 中依赖已完成的 ready task 投递到对应 Agent 后台队列;agent.run_status 使用 {{\"agentId\":\"可选目标 taskId\",\"scope\":\"self|all\"}},用于读取自己或其他 Agent 的 Runtime 状态摘要;如果已有观察足够,请返回空 actions 并填写 response。其他工具 input 可为空。" + "当前工具策略:\n{tool_policy_json}\n\n运行上下文如下。你正在执行后台 Agent loop 第 {loop_index} 轮。项目记忆、对话、资产和文件内容不会预加载,只能依据已获准工具返回的 observation 使用;未出现在 observation 里的项目事实不得自行假设。请基于目标和已有工具观察修正计划,再决定是否调用最多 {AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT} 个白名单工具。只输出 JSON 对象,不要 markdown。\n\n{context}\n\n后台任务:\n{task}\n\n已有工具观察:\n{observations_json}\n\nJSON schema:{{\"thinkingSummary\":\"一句话理解\",\"plan\":[\"步骤\"],\"actions\":[{{\"tool\":\"memory.read|memory.write|conversation.read|asset.list|project.index|project.checkpoint|project.restore|project.diff|file.list|file.read|file.write|task.list|task.create|task.update|command.run_limited|preview.start|canvas.asset_generate|blackboard.write|agent.message|agent.delegate|agent.schedule_ready|agent.run_status\",\"reason\":\"为什么需要\",\"input\":{{}}}}],\"response\":\"如果无需继续调用工具,可直接给最终回复\"}}\n\n工具输入约定:memory.read 使用 {{\"scope\":\"session|project|blackboard|agent\"}};memory.write 使用 {{\"scope\":\"agent|project|session|blackboard\",\"title\":\"标题\",\"content\":\"要沉淀的稳定结论\",\"mode\":\"append|overwrite\"}},其中 agent scope 只能写当前 Agent 自己的私有记忆,跨 Agent 共享请用 blackboard.write 或 agent.message;project.checkpoint input 可为空,用于在写文件或批量修改前创建本地 checkpoint;project.restore 使用 {{\"checkpointId\":\"checkpoint id\"}},用于在确认后把当前项目恢复到指定 checkpoint;project.diff 使用 {{\"checkpointId\":\"checkpoint id\"}},用于读取当前项目相对路径 diff 摘要;file.list 使用 {{\"path\":\"可选项目内相对目录或文件\"}},path 为空时列出项目摘要;file.read 使用 {{\"path\":\"项目内相对路径\"}};file.write 使用 {{\"path\":\"项目内相对路径\",\"content\":\"文件内容\"}};task.list input 可为空,用于读取 manifest 任务图、状态和 readyTaskIds;task.create 使用 {{\"taskId\":\"可选自定义 taskId\",\"title\":\"任务标题\",\"group\":\"design|art|code|balance|audio|publishing\",\"role\":\"角色名\",\"dependencies\":[\"已有 taskId\"],\"artifacts\":[\"预期产物\"],\"acceptanceCriteria\":[\"验收标准\"],\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}},用于把 Agent 拆出的新任务追加到 manifest;task.update 使用 {{\"taskId\":\"manifest taskId\",\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}};command.run_limited 使用 {{\"commandId\":\"game.static_smoke\"}},只支持本地静态自检;preview.start input 可为空,用于启动当前项目的 127.0.0.1 本地 HTTP 预览;canvas.asset_generate 使用 {{\"prompt\":\"要生成的美术素材描述\"}},通过配置的 External Editor API 生成首版素材并登记到 assets;blackboard.write 使用 {{\"title\":\"标题\",\"content\":\"要共享给所有 Agent 的稳定结论\"}};agent.message 使用 {{\"agentId\":\"目标 taskId\",\"content\":\"给目标 Agent 的定向消息\"}};agent.delegate 使用 {{\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"runId\":\"可选 run id\"}},用于把任务投递到另一个 Agent 的独立队列;agent.schedule_ready input 可为空或 {{\"limit\":1}},用于把 manifest 中依赖已完成的 ready task 投递到对应 Agent 后台队列;agent.run_status 使用 {{\"agentId\":\"可选目标 taskId\",\"scope\":\"self|all\"}},用于读取自己或其他 Agent 的 Runtime 状态摘要;如果已有观察足够,请返回空 actions 并填写 response。其他工具 input 可为空。" ); let request = LlmRunRequest::new(vec![ LlmMessage::system(game_creator_agent_runtime_tool_plan_system_prompt()), @@ -3162,18 +3246,19 @@ fn build_game_creator_agent_background_final_reply_request( root: &Path, agent_id: &str, session_id: &str, + run_id: &str, task: &str, plan: &AgentRuntimeToolPlan, observations: &[AgentRuntimeToolObservation], ) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), String> { let (llm, config_path, context) = - build_game_creator_role_agent_context_for_session(root, agent_id, Some(session_id))?; + build_game_creator_background_agent_context(root, agent_id, session_id, run_id)?; let observations_json = serde_json::to_string_pretty(observations) .map_err(|error| format!("序列化 Agent 工具观察失败:{error}"))?; let plan_json = serde_json::to_string_pretty(plan) .map_err(|error| format!("序列化 Agent 工具计划失败:{error}"))?; let prompt = format!( - "项目上下文如下。请结合后台任务、你的计划和工具观察,给开发者一个正常中文回复。不要输出 JSON,不要假装执行未执行的工具。\n\n{context}\n\n后台任务:\n{task}\n\n计划:\n{plan_json}\n\n工具观察:\n{observations_json}" + "运行上下文如下。请只依据后台任务、计划和已获准工具返回的 observation,给开发者一个正常中文回复。不要输出 JSON,不要假装执行未执行的工具,也不要补充 observation 中不存在的项目事实。\n\n{context}\n\n后台任务:\n{task}\n\n计划:\n{plan_json}\n\n工具观察:\n{observations_json}" ); let request = LlmRunRequest::new(vec![ LlmMessage::system(game_creator_role_agent_chat_system_prompt()), @@ -3184,6 +3269,31 @@ fn build_game_creator_agent_background_final_reply_request( Ok((llm, config_path, request)) } +fn build_game_creator_background_agent_context( + root: &Path, + agent_id: &str, + session_id: &str, + run_id: &str, +) -> Result<(GameCreatorLlmConfig, String, String), String> { + let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; + validate_project_root(root)?; + let session_id = + resolve_agent_conversation_session_id_at(root, &agent_id, Some(session_id), false)?; + let (group_definition, role_definition) = game_creator_agent_role_definition(&agent_id) + .ok_or_else(|| format!("未知 Agent:{agent_id}"))?; + let context = format!( + "# Agent 身份\n\n你当前是 {} / {},taskId={},角色代号={}。请只以这个专业 Agent 的身份行动。\n\n# Runtime 元数据\n\n- sessionId: {session_id}\n- runId: {}\n- executionMode: background-tool-loop\n- contextBoundary: project-data-via-approved-tool-observations-only", + group_definition.label, + role_definition.role, + role_definition.task_id, + role_definition.id, + sanitize_agent_runtime_text(run_id, 160) + ); + let app_config = load_game_creator_app_config()?; + let llm = resolve_game_creator_llm_config_for_agent(&app_config, &agent_id); + Ok((llm, format!("agentLlm.{agent_id}"), context)) +} + fn parse_game_creator_agent_tool_plan_response( content: &str, ) -> Result { @@ -3208,7 +3318,6 @@ fn parse_game_creator_agent_tool_plan_response( .actions .into_iter() .filter(|action| !action.tool.trim().is_empty()) - .take(AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT) .collect(); plan.response = truncate_agent_runtime_text(&plan.response, 1_200); Ok(plan) @@ -3691,7 +3800,7 @@ fn consume_game_creator_agent_runtime_tool_confirmation( Ok(!action_fingerprint.trim().is_empty() && confirmed_fingerprint == action_fingerprint) } -fn agent_runtime_executable_tools() -> Vec<&'static str> { +pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> { vec![ "memory.read", "memory.write", @@ -3905,7 +4014,7 @@ fn observe_agent_runtime_memory( "agent" => read_local_agent_memory_at(root, agent_id).map(|result| result.content), _ => Err(format!("不支持的记忆 scope:{scope}")), }; - observation_from_text_result("memory.read", result, "已读取记忆") + observation_from_text_result_preserving_tail("memory.read", result, "已读取记忆") } fn observe_agent_runtime_memory_write( @@ -4090,7 +4199,11 @@ fn observe_agent_runtime_conversation( Some(&session_id), ) }); - observation_from_text_result("conversation.read", result, "已读取本 Agent 最近对话") + observation_from_text_result_preserving_tail( + "conversation.read", + result, + "已读取本 Agent 最近对话", + ) } fn observe_agent_runtime_assets(root: &Path) -> AgentRuntimeToolObservation { @@ -5480,13 +5593,38 @@ fn observation_from_text_result( tool: &str, result: Result, success_summary: &str, +) -> AgentRuntimeToolObservation { + observation_from_text_result_with_truncation(tool, result, success_summary, false) +} + +fn observation_from_text_result_preserving_tail( + tool: &str, + result: Result, + success_summary: &str, +) -> AgentRuntimeToolObservation { + observation_from_text_result_with_truncation(tool, result, success_summary, true) +} + +fn observation_from_text_result_with_truncation( + tool: &str, + result: Result, + success_summary: &str, + preserve_tail: bool, ) -> AgentRuntimeToolObservation { match result { Ok(content) => { - let detail = truncate_agent_runtime_text( - sanitize_prompt_context(&content).as_str(), - AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS, - ); + let sanitized = sanitize_prompt_context(&content); + let detail = if preserve_tail { + truncate_agent_runtime_text_preserving_tail( + sanitized.as_str(), + AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS, + ) + } else { + truncate_agent_runtime_text( + sanitized.as_str(), + AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS, + ) + }; AgentRuntimeToolObservation { tool: tool.to_string(), status: "ok".to_string(), @@ -5745,6 +5883,38 @@ pub(crate) fn fail_game_creator_agent_runtime_turn_at( Ok(state) } +fn fail_game_creator_agent_runtime_budget_at( + root: &Path, + mut state: AgentRuntimeState, + error: &str, +) -> Result { + state.pending_tool_action = None; + state.status = "failed".to_string(); + state.phase = "budget-exhausted".to_string(); + state.current_action = "Agent loop 预算耗尽".to_string(); + 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)?; + refresh_game_creator_agent_runtime_task_queue(root, &mut state)?; + write_game_creator_agent_runtime_state(root, &state)?; + append_game_creator_agent_runtime_event( + root, + &state, + "turn.budget_exhausted", + "failed", + "budget-exhausted", + "Agent Runtime 达到 loop 预算但任务仍未收束。", + state.error.as_deref(), + )?; + 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)?; + Ok(state) +} + pub(crate) fn normalize_game_creator_runtime_agent_id(agent_id: &str) -> Result { let agent_id = agent_id.trim(); if agent_id.is_empty() { @@ -5848,34 +6018,11 @@ fn default_game_creator_agent_runtime_state(agent_id: &str, run_id: &str) -> Age } } -fn default_game_creator_agent_runtime_allowed_tools() -> Vec { - [ - "conversation.read", - "conversation.write", - "memory.read", - "memory.write", - "blackboard.write", - "agent.message", - "agent.delegate", - "agent.schedule_ready", - "asset.list", - "project.checkpoint", - "project.restore", - "project.diff", - "file.list", - "file.read", - "file.write", - "task.list", - "task.create", - "task.update", - "command.run_limited", - "preview.start", - "canvas.asset_generate", - "agent.run_status", - ] - .into_iter() - .map(str::to_string) - .collect() +pub(crate) fn default_game_creator_agent_runtime_allowed_tools() -> Vec { + agent_runtime_executable_tools() + .into_iter() + .map(str::to_string) + .collect() } fn normalize_game_creator_agent_runtime_state(state: &mut AgentRuntimeState, agent_id: &str) { @@ -6848,6 +6995,17 @@ fn truncate_agent_runtime_text(value: &str, max_chars: usize) -> String { output } +fn truncate_agent_runtime_text_preserving_tail(value: &str, max_chars: usize) -> String { + let value = value.trim(); + let count = value.chars().count(); + if count <= max_chars { + return value.to_string(); + } + let mut characters = value.chars().rev().take(max_chars).collect::>(); + characters.reverse(); + format!("…{}", characters.into_iter().collect::()) +} + fn sanitize_agent_runtime_text(value: &str, max_chars: usize) -> String { truncate_agent_runtime_text(sanitize_prompt_context(value).trim(), max_chars) } @@ -7251,18 +7409,26 @@ fn build_game_creator_role_agent_context_for_session( group_definition.label, role_definition.role, role_definition.task_id, role_definition.id ); let context = [ - ("Agent 身份", identity.as_str()), - ("Agent 私有记忆", agent_memory.as_str()), - ("Agent Runtime 连续上下文", runtime_context.as_str()), - ("短期记忆", short_memory.as_str()), - ("长期记忆", long_memory.as_str()), - ("项目黑板", project_blackboard.as_str()), - ("资产上下文", asset_context.as_str()), - ("最近项目与本 Agent 对话", conversation_context.as_str()), + ("Agent 身份", truncate_prompt_context(&identity)), + ("Agent 私有记忆", truncate_prompt_context(&agent_memory)), + ( + "Agent Runtime 连续上下文", + truncate_prompt_context(&runtime_context), + ), + ("短期记忆", truncate_prompt_context(&short_memory)), + ("长期记忆", truncate_prompt_context(&long_memory)), + ( + "项目黑板", + truncate_prompt_context_preserving_tail(&project_blackboard), + ), + ("资产上下文", truncate_prompt_context(&asset_context)), + ( + "最近项目与本 Agent 对话", + truncate_prompt_context_preserving_tail(&conversation_context), + ), ] .into_iter() .filter_map(|(title, content)| { - let content = truncate_prompt_context(content); if content.trim().is_empty() { None } else { @@ -11330,6 +11496,21 @@ pub(crate) fn truncate_prompt_context(value: &str) -> String { output } +pub(crate) fn truncate_prompt_context_preserving_tail(value: &str) -> String { + const MAX_CHARS: usize = 2400; + let sanitized = sanitize_prompt_context(value); + let trimmed = sanitized.trim(); + if trimmed.chars().count() <= MAX_CHARS { + return trimmed.to_string(); + } + let mut characters = trimmed.chars().rev().take(MAX_CHARS).collect::>(); + characters.reverse(); + format!( + "...\n{}", + characters.into_iter().collect::() + ) +} + pub(crate) fn sanitize_prompt_context(value: &str) -> String { value .lines() diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index c933ca02a..a5b5f3047 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -248,8 +248,9 @@ pub(crate) async fn chat_with_game_creator_role_agent( let agent_id = normalize_game_creator_runtime_agent_id(agent_id.trim())?; enforce_project_permission_policy(root, "conversation.read")?; enforce_project_permission_policy(root, "conversation.write")?; - let _lock = acquire_project_write_lock(root, "conversation.write")?; - chat_with_game_creator_role_agent_runtime_for_session_at( + let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(root, &agent_id)? + .ok_or_else(|| format!("Agent 正在执行其他前台或后台任务:{agent_id}"))?; + let result = chat_with_game_creator_role_agent_runtime_for_session_at( root, &agent_id, session_id.as_deref(), @@ -257,7 +258,12 @@ pub(crate) async fn chat_with_game_creator_role_agent( "", ) .await - .map(|(reply, _runtime)| reply) + .map(|(reply, _runtime)| reply); + spawn_next_game_creator_agent_background_task_drain_with_lock(root, &agent_id, runtime_lock); + match result { + Ok(reply) => Ok(reply), + Err(error) => Err(error), + } } #[tauri::command] @@ -277,121 +283,133 @@ pub(crate) async fn chat_with_game_creator_role_agent_stream( resolve_agent_conversation_session_id_at(root, &agent_id, session_id.as_deref(), true)?; enforce_project_permission_policy(root, "conversation.read")?; enforce_project_permission_policy(root, "conversation.write")?; - let _lock = acquire_project_write_lock(root, "conversation.write")?; + let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(root, &agent_id)? + .ok_or_else(|| format!("Agent 正在执行其他前台或后台任务:{agent_id}"))?; let emit_app = app.clone(); let event_project_path = project_path.clone(); let event_agent_id = agent_id.clone(); let event_run_id = run_id.clone(); - let mut runtime_state = start_game_creator_agent_runtime_turn_for_session_at( - root, - agent_id.as_str(), - Some(&session_id), - prompt.trim(), - &run_id, - )?; - runtime_state = advance_game_creator_agent_runtime_turn_at( - root, - runtime_state, - "llm", - "请求 Agent LLM", - "已读取项目上下文,正在让 Agent 独立推理。", - )?; - let mut streaming_runtime_state = runtime_state.clone(); - streaming_runtime_state.current_action = "正在接收 Agent 回复".to_string(); - let _ = app.emit( - "game-creator-role-agent-chat-stream", - GameCreatorRoleAgentChatStreamEvent { - project_path: event_project_path.clone(), - agent_id: event_agent_id.clone(), - run_id: event_run_id.clone(), - status: "started".to_string(), - delta_text: String::new(), - accumulated_text: String::new(), - finish_reason: None, - session_id: Some(runtime_state.session_id.clone()), - runtime_status: Some(runtime_state.status.clone()), - runtime_phase: Some(runtime_state.phase.clone()), - runtime_summary: Some(runtime_state.current_action.clone()), - runtime_state: Some(runtime_state.clone()), - }, - ); - let result = chat_with_game_creator_role_agent_stream_for_session_at( - root, - agent_id.as_str(), - Some(&session_id), - prompt.trim(), - |delta| { - let _ = emit_app.emit( - "game-creator-role-agent-chat-stream", - GameCreatorRoleAgentChatStreamEvent { - project_path: event_project_path.clone(), - agent_id: event_agent_id.clone(), - run_id: event_run_id.clone(), - status: "delta".to_string(), - delta_text: delta.delta_text.clone(), - accumulated_text: delta.accumulated_text.clone(), - finish_reason: delta.finish_reason.clone(), - session_id: Some(streaming_runtime_state.session_id.clone()), - runtime_status: Some("running".to_string()), - runtime_phase: Some("llm".to_string()), - runtime_summary: Some("正在接收 Agent 回复".to_string()), - runtime_state: Some(streaming_runtime_state.clone()), - }, - ); - }, - ) + let command_result = async { + let mut runtime_state = start_game_creator_agent_runtime_turn_for_session_at( + root, + agent_id.as_str(), + Some(&session_id), + prompt.trim(), + &run_id, + )?; + runtime_state = advance_game_creator_agent_runtime_turn_at( + root, + runtime_state, + "llm", + "请求 Agent LLM", + "已读取项目上下文,正在让 Agent 独立推理。", + )?; + let mut streaming_runtime_state = runtime_state.clone(); + streaming_runtime_state.current_action = "正在接收 Agent 回复".to_string(); + let _ = app.emit( + "game-creator-role-agent-chat-stream", + GameCreatorRoleAgentChatStreamEvent { + project_path: event_project_path.clone(), + agent_id: event_agent_id.clone(), + run_id: event_run_id.clone(), + status: "started".to_string(), + delta_text: String::new(), + accumulated_text: String::new(), + finish_reason: None, + session_id: Some(runtime_state.session_id.clone()), + runtime_status: Some(runtime_state.status.clone()), + runtime_phase: Some(runtime_state.phase.clone()), + runtime_summary: Some(runtime_state.current_action.clone()), + runtime_state: Some(runtime_state.clone()), + }, + ); + let result = chat_with_game_creator_role_agent_stream_for_session_at( + root, + agent_id.as_str(), + Some(&session_id), + prompt.trim(), + |delta| { + let _ = emit_app.emit( + "game-creator-role-agent-chat-stream", + GameCreatorRoleAgentChatStreamEvent { + project_path: event_project_path.clone(), + agent_id: event_agent_id.clone(), + run_id: event_run_id.clone(), + status: "delta".to_string(), + delta_text: delta.delta_text.clone(), + accumulated_text: delta.accumulated_text.clone(), + finish_reason: delta.finish_reason.clone(), + session_id: Some(streaming_runtime_state.session_id.clone()), + runtime_status: Some("running".to_string()), + runtime_phase: Some("llm".to_string()), + runtime_summary: Some("正在接收 Agent 回复".to_string()), + runtime_state: Some(streaming_runtime_state.clone()), + }, + ); + }, + ) + .await; + match result { + Ok(reply) => { + let completed_runtime = finish_game_creator_agent_runtime_turn_at( + root, + runtime_state, + &reply.reply_text, + )?; + let _ = app.emit( + "game-creator-role-agent-chat-stream", + GameCreatorRoleAgentChatStreamEvent { + project_path: project_path.clone(), + agent_id: agent_id.clone(), + run_id: run_id.clone(), + status: "completed".to_string(), + delta_text: String::new(), + accumulated_text: reply.reply_text.clone(), + finish_reason: None, + session_id: Some(completed_runtime.session_id.clone()), + runtime_status: Some(completed_runtime.status.clone()), + runtime_phase: Some(completed_runtime.phase.clone()), + runtime_summary: Some(completed_runtime.current_action.clone()), + runtime_state: Some(completed_runtime), + }, + ); + Ok(reply) + } + Err(error) => { + let failed_runtime = + fail_game_creator_agent_runtime_turn_at(root, runtime_state, &error).ok(); + let _ = app.emit( + "game-creator-role-agent-chat-stream", + GameCreatorRoleAgentChatStreamEvent { + project_path: project_path.clone(), + agent_id: agent_id.clone(), + run_id: run_id.clone(), + status: "failed".to_string(), + delta_text: String::new(), + accumulated_text: String::new(), + finish_reason: None, + session_id: failed_runtime + .as_ref() + .map(|runtime| runtime.session_id.clone()), + runtime_status: failed_runtime + .as_ref() + .map(|runtime| runtime.status.clone()), + runtime_phase: failed_runtime.as_ref().map(|runtime| runtime.phase.clone()), + runtime_summary: failed_runtime + .as_ref() + .map(|runtime| runtime.current_action.clone()), + runtime_state: failed_runtime, + }, + ); + Err(error) + } + } + } .await; - match result { - Ok(reply) => { - let completed_runtime = - finish_game_creator_agent_runtime_turn_at(root, runtime_state, &reply.reply_text)?; - let _ = app.emit( - "game-creator-role-agent-chat-stream", - GameCreatorRoleAgentChatStreamEvent { - project_path, - agent_id, - run_id, - status: "completed".to_string(), - delta_text: String::new(), - accumulated_text: reply.reply_text.clone(), - finish_reason: None, - session_id: Some(completed_runtime.session_id.clone()), - runtime_status: Some(completed_runtime.status.clone()), - runtime_phase: Some(completed_runtime.phase.clone()), - runtime_summary: Some(completed_runtime.current_action.clone()), - runtime_state: Some(completed_runtime), - }, - ); - Ok(reply) - } - Err(error) => { - let failed_runtime = - fail_game_creator_agent_runtime_turn_at(root, runtime_state, &error).ok(); - let _ = app.emit( - "game-creator-role-agent-chat-stream", - GameCreatorRoleAgentChatStreamEvent { - project_path, - agent_id, - run_id, - status: "failed".to_string(), - delta_text: String::new(), - accumulated_text: String::new(), - finish_reason: None, - session_id: failed_runtime - .as_ref() - .map(|runtime| runtime.session_id.clone()), - runtime_status: failed_runtime - .as_ref() - .map(|runtime| runtime.status.clone()), - runtime_phase: failed_runtime.as_ref().map(|runtime| runtime.phase.clone()), - runtime_summary: failed_runtime - .as_ref() - .map(|runtime| runtime.current_action.clone()), - runtime_state: failed_runtime, - }, - ); - Err(error) - } + spawn_next_game_creator_agent_background_task_drain_with_lock(root, &agent_id, runtime_lock); + match command_result { + Ok(reply) => Ok(reply), + Err(error) => Err(error), } } @@ -527,6 +545,19 @@ pub(crate) fn resume_game_creator_agent_runtime_tasks( resume_game_creator_agent_background_tasks_at(root) } +#[tauri::command] +pub(crate) fn confirm_resume_game_creator_agent_runtime_tasks( + project_path: String, +) -> Result, String> { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + enforce_project_permission_policy(root, "agent.run_status")?; + // 该命令只由开发者在 agent.resume 确认卡中明确批准后调用。 + enforce_project_permission_policy(root, "agent.resume")?; + resume_game_creator_agent_background_tasks_at(root) +} + #[tauri::command] pub(crate) fn schedule_game_creator_agent_ready_tasks( project_path: 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 3c258ba11..b8ae05f47 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1253,6 +1253,7 @@ fn main() { read_game_creator_agent_runtime, read_game_creator_agent_runtimes, resume_game_creator_agent_runtime_tasks, + confirm_resume_game_creator_agent_runtime_tasks, schedule_game_creator_agent_ready_tasks, check_game_creator_llm_config, read_game_creator_app_config, 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 caf9b5a85..1e2b6809c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -2302,6 +2302,8 @@ async fn background_agent_runtime_task_executes_plan_tool_observation_loop() { assert!(plan_request.contains("task.update")); assert!(plan_request.contains("只输出 JSON 对象")); assert!(plan_request.contains("后台分析当前玩法循环")); + assert!(!plan_request.contains("核心循环:收集月光食材并躲避暗影")); + assert!(!plan_request.contains("黑板:必须先确认核心循环")); let final_request = receiver .recv_timeout(Duration::from_secs(2)) .expect("final reply llm request"); @@ -2490,7 +2492,10 @@ async fn background_agent_runtime_can_replan_after_observation() { .expect("write notes"); fs::write( root.join(PROJECT_BLACKBOARD_MEMORY_PATH), - "黑板:角色必须有月光围裙和暗影厨具对手。", + format!( + "{}\n黑板最新约束:角色必须有月光围裙和暗影厨具对手。", + "旧黑板记录:等待归档。\n".repeat(240) + ), ) .expect("write blackboard"); let (sender, receiver) = mpsc::channel(); @@ -2556,18 +2561,21 @@ async fn background_agent_runtime_can_replan_after_observation() { assert!(first_request.contains("第 1 轮")); assert!(first_request.contains("已有工具观察")); assert!(first_request.contains("[]")); + assert!(!first_request.contains("项目笔记:已有月光厨房核心循环")); + assert!(!first_request.contains("黑板最新约束")); let second_request = receiver .recv_timeout(Duration::from_secs(2)) .expect("second plan llm request"); assert!(second_request.contains("第 2 轮")); assert!(second_request.contains("file.read")); assert!(second_request.contains("项目笔记:已有月光厨房核心循环")); + assert!(!second_request.contains("黑板最新约束")); let third_request = receiver .recv_timeout(Duration::from_secs(2)) .expect("third plan llm request"); assert!(third_request.contains("第 3 轮")); assert!(third_request.contains("memory.read")); - assert!(third_request.contains("月光围裙和暗影厨具对手")); + assert!(third_request.contains("黑板最新约束:角色必须有月光围裙和暗影厨具对手")); let mut runtime = read_game_creator_agent_runtime_at(&root, "design-director") .expect("read runtime") @@ -2615,6 +2623,182 @@ async fn background_agent_runtime_can_replan_after_observation() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn background_agent_runtime_marks_unconverged_loop_budget_exhausted() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let (sender, receiver) = mpsc::channel(); + let responses = (1..=3) + .map(|iteration| { + serde_json::json!({ + "thinkingSummary": format!("第 {iteration} 轮仍要求继续读取项目索引"), + "plan": ["读取项目索引", "继续行动"], + "actions": [{ + "tool": "project.index", + "reason": "故意保持未收束以验证 loop 预算", + "input": {} + }], + "response": "" + }) + .to_string() + }) + .collect::>(); + let base_url = spawn_mock_llm_server_responses_with_capture(responses, Some(sender)); + 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", + "验证未收束 loop 预算", + "design-budget-exhausted-run", + ) + .expect("start background task"); + for iteration in 1..=3 { + let request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("planning request"); + assert!(request.contains(&format!("第 {iteration} 轮"))); + } + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + + let mut result = + read_game_creator_agent_runtime_at(&root, "design-director").expect("read budget runtime"); + for _ in 0..50 { + if result.state.status == "failed" { + break; + } + std::thread::sleep(Duration::from_millis(20)); + result = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read budget runtime"); + } + assert_eq!(result.state.status, "failed"); + assert_eq!(result.state.phase, "budget-exhausted"); + assert!(result + .state + .error + .as_deref() + .is_some_and(|error| error.contains("loop-budget-exhausted"))); + assert_eq!(result.task_queue.failed, 1); + assert!(result.recent_tasks.iter().any(|task| { + task.run_id == "design-budget-exhausted-run" + && task.status == "failed" + && task.phase == "budget-exhausted" + })); + let conversation = read_local_conversation_at(&root, Some("design-director")) + .expect("read budget conversation"); + assert!(conversation.messages.iter().any(|message| { + message.role == "assistant" + && message + .content + .contains("后台任务未完成:loop-budget-exhausted") + })); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + assert!(agent_db.contains("\"failureKind\":\"loop-budget-exhausted\"")); + assert!(!agent_db.contains( + "\"recordType\":\"agent.runtime.background_task.completed\",\"agentId\":\"design-director\",\"taskId\":\"design-director\",\"sessionId\":\"agent-session-design-director\",\"runId\":\"design-budget-exhausted-run\"" + )); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn background_agent_runtime_reports_and_truncates_excess_tool_actions() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec![ + serde_json::json!({ + "thinkingSummary": "一次请求四个工具以验证单轮预算", + "plan": ["读取项目索引", "读取任务", "读取资产", "读取记忆"], + "actions": [ + {"tool": "project.index", "reason": "先看项目", "input": {}}, + {"tool": "task.list", "reason": "再看任务", "input": {}}, + {"tool": "asset.list", "reason": "再看资产", "input": {}}, + {"tool": "memory.read", "reason": "最后看记忆", "input": {"scope": "agent"}} + ], + "response": "" + }) + .to_string(), + serde_json::json!({ + "thinkingSummary": "已根据预算内观察收束", + "plan": ["回复开发者"], + "actions": [], + "response": "已执行前三个工具,剩余动作应在下一轮重新排序。" + }) + .to_string(), + ], + Some(sender), + ); + 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-tool-budget-run", + ) + .expect("start background task"); + receiver + .recv_timeout(Duration::from_secs(2)) + .expect("first planning request"); + let second_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("second planning request"); + assert!(second_request.contains("runtime.tool_budget")); + assert!(second_request.contains("本轮请求了 4 个工具动作,只执行前 3 个")); + assert!(second_request.contains("project.index")); + assert!(second_request.contains("task.list")); + assert!(second_request.contains("asset.list")); + + let completed = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(completed.phase, "completed"); + assert!(completed + .observations + .iter() + .any(|item| item.contains("本轮请求了 4 个工具动作,只执行前 3 个"))); + assert_eq!(completed.recent_tool_calls.len(), 3); + assert!(completed + .recent_tool_calls + .iter() + .all(|action| action.tool != "memory.read")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_runtime_default_allowed_tools_match_executable_whitelist() { + let expected = agent_runtime_executable_tools() + .into_iter() + .map(str::to_string) + .collect::>(); + assert_eq!(default_game_creator_agent_runtime_allowed_tools(), expected); + assert!(expected.contains(&"project.index".to_string())); + assert!(!expected.contains(&"conversation.write".to_string())); +} + #[test] fn background_agent_runtime_resume_does_not_mutate_pending_action_while_lock_is_busy() { let root = unique_project_path(); @@ -2775,8 +2959,8 @@ async fn background_agent_runtime_recovers_stale_running_task() { fs::remove_dir_all(root).ok(); } -#[test] -fn background_agent_runtime_resume_command_requires_auto_resume_policy() { +#[tokio::test] +async fn background_agent_runtime_resume_commands_distinguish_auto_and_confirmed_paths() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); let task_record = AgentRuntimeTaskRecord { @@ -2806,6 +2990,52 @@ fn background_agent_runtime_resume_command_requires_auto_resume_policy() { .iter() .any(|task| task.run_id == "design-confirm-run" && task.status == "pending")); + let base_url = spawn_mock_llm_server( + serde_json::json!({ + "thinkingSummary": "恢复后可以直接收束", + "plan": [], + "actions": [], + "response": "已确认恢复默认策略下的后台任务。" + }) + .to_string(), + ); + 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" + }} + }} +}}"# + )); + let resumed = + confirm_resume_game_creator_agent_runtime_tasks(root.to_string_lossy().into_owned()) + .expect("explicit confirmation may resume under confirm policy"); + assert_eq!(resumed.len(), 1); + let completed = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(completed.run_id, "design-confirm-run"); + assert_eq!( + completed.last_response.as_deref(), + Some("已确认恢复默认策略下的后台任务。") + ); + + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["agent.resume".to_string()], + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("deny resume policy"); + let denied = + confirm_resume_game_creator_agent_runtime_tasks(root.to_string_lossy().into_owned()) + .expect_err("explicit confirmation must not bypass deny policy"); + assert!(denied.contains("项目权限策略拒绝执行:agent.resume")); + fs::remove_dir_all(root).ok(); } @@ -3035,7 +3265,7 @@ async fn background_agent_runtime_recovers_stale_running_before_pending_task() { } #[tokio::test] -async fn background_agent_runtime_plan_request_includes_same_agent_continuity_context() { +async fn background_agent_runtime_loads_same_agent_continuity_through_tool_observation() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); fs::write( @@ -3065,8 +3295,26 @@ async fn background_agent_runtime_plan_request_includes_same_agent_continuity_co "response": "美术无关任务完成。" }) .to_string(); - let second_design_plan_json = serde_json::json!({ - "thinkingSummary": "第二轮应参考本 Agent 上一轮 runtime 上下文", + let second_design_read_plan_json = serde_json::json!({ + "thinkingSummary": "第二轮需要通过工具读取本 Agent runtime 上下文", + "plan": ["读取本 Agent Runtime", "读取本 Agent 对话", "继续设计建议"], + "actions": [ + { + "tool": "agent.run_status", + "reason": "读取本 Agent 上一轮运行结果和工具观察", + "input": { "scope": "self" } + }, + { + "tool": "conversation.read", + "reason": "读取本 Agent 上一轮对话回复", + "input": {} + } + ], + "response": "" + }) + .to_string(); + let second_design_final_plan_json = serde_json::json!({ + "thinkingSummary": "已通过工具拿到本 Agent 上一轮 runtime 上下文", "plan": ["复用上一轮工具观察", "继续设计建议"], "actions": [], "response": "第二轮设计任务完成。" @@ -3077,7 +3325,8 @@ async fn background_agent_runtime_plan_request_includes_same_agent_continuity_co first_plan_json, "首轮完成:已经读取连续上下文笔记。".to_string(), art_plan_json, - second_design_plan_json, + second_design_read_plan_json, + second_design_final_plan_json, ], Some(sender), ); @@ -3153,24 +3402,34 @@ async fn background_agent_runtime_plan_request_includes_same_agent_continuity_co let second_design_request = receiver .recv_timeout(Duration::from_secs(2)) .expect("second design plan request"); - assert!(second_design_request.contains("# Agent Runtime 连续上下文")); - 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 waiting=0 cancelled=0 completed=1 failed=0 latest=design-continuity-second" + assert!(second_design_request + .contains("contextBoundary: project-data-via-approved-tool-observations-only")); + assert!(second_design_request.contains("runId: design-continuity-second")); + assert!(!second_design_request.contains("# Agent Runtime 连续上下文")); + assert!(!second_design_request.contains("首轮完成:已经读取连续上下文笔记。")); + assert!(!second_design_request.contains("连续上下文笔记:第一轮已经确认月光厨房核心循环")); + assert!(!second_design_request.contains("design-continuity-first [completed / completed]")); + let second_design_replan_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("second design replan request after runtime observation"); + assert!(second_design_replan_request.contains("agent.run_status")); + assert!(second_design_replan_request.contains("conversation.read")); + assert!(second_design_replan_request.contains("status: running")); + assert!(second_design_replan_request.contains("runId: design-continuity-second")); + assert!(second_design_replan_request.contains("循环轮次: 1/3")); + assert!(second_design_replan_request.contains("每轮工具预算: 3")); + assert!(second_design_replan_request.contains("当前计划步骤: #1 [active] 读取本 Agent Runtime")); + assert!(second_design_replan_request.contains( + "任务队列: total=2 pending=0 running=1 waiting=0 cancelled=0 completed=1 failed=0 latest=design-continuity-second" )); - assert!(second_design_request.contains("最近回复:首轮完成:已经读取连续上下文笔记。")); - assert!(second_design_request.contains("最近工具动作")); - assert!(second_design_request.contains("file.read [ok]")); - assert!(second_design_request.contains("为连续上下文留下工具证据")); - assert!(second_design_request.contains("连续上下文笔记:第一轮已经确认月光厨房核心循环")); - assert!(second_design_request.contains("最近任务")); - assert!(second_design_request.contains("design-continuity-first [completed / completed]")); - assert!(second_design_request.contains("首轮检查连续上下文")); - assert!(!second_design_request.contains(root.to_string_lossy().as_ref())); + assert!(second_design_replan_request.contains("首轮完成:已经读取连续上下文笔记。")); + assert!(second_design_replan_request.contains("最近工具: file.read / ok")); + assert!(second_design_replan_request.contains("最近任务: design-continuity-second / running")); + assert!(second_design_replan_request.contains("首轮检查连续上下文")); + assert!( + !second_design_replan_request.contains("连续上下文笔记:第一轮已经确认月光厨房核心循环") + ); + assert!(!second_design_replan_request.contains(root.to_string_lossy().as_ref())); let second_runtime = wait_for_agent_runtime_idle(&root, "design-director"); assert_eq!(second_runtime.run_id, "design-continuity-second"); assert_eq!( @@ -3180,7 +3439,7 @@ async fn background_agent_runtime_plan_request_includes_same_agent_continuity_co assert!(second_runtime .recent_tool_calls .iter() - .any(|call| call.tool == "file.read" && call.status == "ok")); + .any(|call| call.tool == "conversation.read" && call.status == "ok")); fs::remove_dir_all(root).ok(); } @@ -3993,9 +4252,10 @@ async fn background_agent_runtime_blocks_cross_agent_private_memory_write() { let second_request = receiver .recv_timeout(Duration::from_secs(2)) .expect("second llm request"); - assert!(second_request.contains( - "memory.write:blocked · Agent 私有记忆只能由本人写入:design-director 不能写入 art-asset-plan" - )); + assert!(second_request.contains("\\\"tool\\\": \\\"memory.write\\\"")); + assert!(second_request.contains("\\\"status\\\": \\\"blocked\\\"")); + assert!(second_request + .contains("Agent 私有记忆只能由本人写入:design-director 不能写入 art-asset-plan")); assert!(second_request.contains("blackboard.write")); assert!(second_request.contains("agent.message")); @@ -6539,9 +6799,10 @@ async fn background_agent_runtime_can_confirm_and_continue_waiting_tool_action() ) .expect("start background task"); - receiver + let first_request = receiver .recv_timeout(Duration::from_secs(2)) .expect("first plan llm request"); + assert!(!first_request.contains("核心循环笔记")); let waiting_runtime = wait_for_agent_runtime_confirmation(&root, "design-director"); assert_eq!(waiting_runtime.status, "waiting-for-confirmation"); let pending_action = waiting_runtime @@ -6598,9 +6859,10 @@ async fn background_agent_runtime_can_confirm_and_continue_waiting_tool_action() .expect("confirm waiting task"); assert_eq!(confirmed.state.run_id, "design-confirm-run"); assert_eq!(confirmed.state.status, "running"); - receiver + let continued_request = receiver .recv_timeout(Duration::from_secs(2)) .expect("continued plan request"); + assert!(continued_request.contains("核心循环笔记")); assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); let runtime = wait_for_agent_runtime_idle(&root, "design-director"); @@ -8569,6 +8831,158 @@ async fn background_agent_runtime_cancellation_wins_over_inflight_llm_error() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn foreground_agent_chat_rejects_same_agent_background_lane_overlap() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let (request_sender, request_receiver) = mpsc::channel(); + let (release_sender, release_receiver) = mpsc::channel(); + let base_url = spawn_releasable_mock_llm_server_responses_with_capture( + vec![serde_json::json!({ + "thinkingSummary": "后台任务占用同 Agent lane", + "plan": [], + "actions": [], + "response": "后台任务完成。" + }) + .to_string()], + request_sender, + release_receiver, + ); + 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-background-lane-run", + ) + .expect("start background task"); + request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("background request in flight"); + + let error = chat_with_game_creator_role_agent( + root.to_string_lossy().into_owned(), + "design-director".to_string(), + None, + "这条前台消息不能并发覆盖状态".to_string(), + ) + .await + .expect_err("foreground chat must respect the background lane"); + assert!(error.contains("Agent 正在执行其他前台或后台任务:design-director")); + let running = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read background runtime"); + assert_eq!(running.state.run_id, "design-background-lane-run"); + assert_eq!(running.state.status, "running"); + + release_sender + .send(()) + .expect("release background response"); + let completed = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(completed.run_id, "design-background-lane-run"); + fs::remove_dir_all(root).ok(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn foreground_agent_chat_releases_lane_then_drains_queued_background_task() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let (request_sender, request_receiver) = mpsc::channel(); + let (release_sender, release_receiver) = mpsc::channel(); + let base_url = spawn_releasable_mock_llm_server_responses_with_capture( + vec![ + "前台 Agent 已完成回复。".to_string(), + serde_json::json!({ + "thinkingSummary": "前台结束后处理排队任务", + "plan": [], + "actions": [], + "response": "排队后台任务完成。" + }) + .to_string(), + ], + request_sender, + release_receiver, + ); + 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" + }} + }} +}}"# + )); + + let project_path = root.to_string_lossy().into_owned(); + let foreground = tokio::spawn(async move { + chat_with_game_creator_role_agent( + project_path, + "design-director".to_string(), + None, + "先完成前台对话".to_string(), + ) + .await + }); + tokio::task::yield_now().await; + let foreground_request = request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("foreground request in flight"); + assert!(foreground_request.contains("先完成前台对话")); + let project_lock = acquire_project_write_lock(&root, "test.concurrent-project-write") + .expect("foreground LLM wait must not hold the project write lock"); + drop(project_lock); + + let queued = start_game_creator_agent_background_task_at( + &root, + "design-director", + "前台结束后执行的后台任务", + "design-after-foreground-run", + ) + .expect("queue background task while foreground runs"); + assert!(queued + .recent_tasks + .iter() + .any(|task| { task.run_id == "design-after-foreground-run" && task.status == "pending" })); + assert!(request_receiver + .recv_timeout(Duration::from_millis(200)) + .is_err()); + + release_sender + .send(()) + .expect("release foreground response"); + let foreground_reply = foreground + .await + .expect("foreground join") + .expect("foreground reply"); + assert_eq!(foreground_reply.reply_text, "前台 Agent 已完成回复。"); + let background_request = request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("queued background request after foreground release"); + assert!(background_request.contains("前台结束后执行的后台任务")); + let completed = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(completed.run_id, "design-after-foreground-run"); + assert_eq!( + completed.last_response.as_deref(), + Some("排队后台任务完成。") + ); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_tasks_can_run_in_parallel_and_persist_replies() { let root = unique_project_path(); @@ -10363,6 +10777,47 @@ fn prompt_context_redacts_secrets_before_truncating() { assert!(!sanitized.contains("game-creator.config.json")); } +#[test] +fn prompt_context_tail_truncation_keeps_latest_blackboard_and_agent_message() { + let blackboard = format!( + "{}\n黑板最新结论:主角使用月光围裙。", + "旧黑板结论:等待归档。\n".repeat(240) + ); + let blackboard_context = truncate_prompt_context_preserving_tail(&blackboard); + assert!(blackboard_context.starts_with("...")); + assert!(blackboard_context.contains("黑板最新结论:主角使用月光围裙")); + + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + append_local_conversation_message_at( + &root, + Some("art-asset-plan"), + LocalConversationMessage { + role: "assistant".to_string(), + content: "旧对话内容。".repeat(600), + agent_id: None, + }, + ) + .expect("append long agent conversation"); + append_local_conversation_message_at( + &root, + Some("art-asset-plan"), + LocalConversationMessage { + role: "tool".to_string(), + content: "最新定向消息:优先完成主角规范图。".to_string(), + agent_id: None, + }, + ) + .expect("append latest agent conversation"); + let conversation = render_local_conversation_prompt_context(&root, Some("art-asset-plan")) + .expect("render agent conversation context"); + let conversation_context = truncate_prompt_context_preserving_tail(&conversation); + assert!(conversation_context.starts_with("...")); + assert!(conversation_context.contains("最新定向消息:优先完成主角规范图")); + + fs::remove_dir_all(root).ok(); +} + #[test] fn upload_local_asset_rejects_empty_file() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 417b004ee..022980d43 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -1272,6 +1272,7 @@ export type PendingCommand = interface PendingUiConfirmation { commandId: GameCreationAppCommandDescriptor['id']; detail: string; + projectPath: string | null; } type TauriInvoke = ( @@ -1297,6 +1298,20 @@ function isMissingAgentSessionCommandError(error: unknown) { ); } +function isMissingAgentRuntimeResumeCommandError(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const normalized = message.toLowerCase(); + return ( + normalized.includes('resume_game_creator_agent_runtime_tasks') && + (normalized.includes('not found') || + normalized.includes('unknown command') || + normalized.includes('unexpected invoke') || + normalized.includes('unexpected command') || + normalized.includes('不存在') || + normalized.includes('未找到')) + ); +} + function createDefaultChatMessages(): ChatMessage[] { return [ { @@ -2888,7 +2903,10 @@ export function WorkspaceLauncher({ const [agentChatActiveRuntime, setAgentChatActiveRuntime] = useState(null); const [agentChatRuntimeError, setAgentChatRuntimeError] = useState(''); + const [agentChatResumeConfirmation, setAgentChatResumeConfirmation] = + useState<{ projectPath: string; detail: string } | null>(null); const agentChatLoadVersionRef = useRef(0); + const agentChatRuntimeResumeProjectPathRef = useRef(null); const agentChatProjectPathRef = useRef(agentChatProjectPath); agentChatProjectPathRef.current = agentChatProjectPath; const agentChatSelectedAgentIdRef = useRef(agentChatSelectedAgentId); @@ -3650,6 +3668,91 @@ export function WorkspaceLauncher({ } } + function cancelAgentChatRuntimeResume() { + setAgentChatResumeConfirmation(null); + setAgentChatStatus('已取消恢复 Agent Runtime 任务'); + } + + async function confirmAgentChatRuntimeResume() { + const pending = agentChatResumeConfirmation; + const invoke = resolveTauriInvoke(); + if (!pending || !invoke) { + return; + } + setAgentChatResumeConfirmation(null); + setAgentChatStatus('正在恢复 Agent Runtime 任务'); + try { + await invoke( + 'confirm_resume_game_creator_agent_runtime_tasks', + { projectPath: pending.projectPath }, + ); + if (agentChatProjectPathRef.current.trim() !== pending.projectPath) { + return; + } + agentChatRuntimeResumeProjectPathRef.current = pending.projectPath; + await loadAgentChatConversation( + agentChatSelectedAgentIdRef.current, + pending.projectPath, + agentChatSelectedSessionIdRef.current, + ); + setAgentChatStatus('已确认恢复 Agent Runtime 任务'); + } catch (error) { + if (agentChatProjectPathRef.current.trim() !== pending.projectPath) { + return; + } + agentChatRuntimeResumeProjectPathRef.current = null; + setAgentChatStatus( + `Agent Runtime 恢复失败:${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + async function resumeAgentChatRuntimeTasksIfNeeded( + invoke: TauriInvoke, + projectPathForChat: string, + ) { + if ( + agentChatRuntimeResumeProjectPathRef.current === projectPathForChat + ) { + return ''; + } + try { + await invoke( + 'resume_game_creator_agent_runtime_tasks', + { projectPath: projectPathForChat }, + ); + agentChatRuntimeResumeProjectPathRef.current = projectPathForChat; + return ''; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (isMissingAgentRuntimeResumeCommandError(error)) { + agentChatRuntimeResumeProjectPathRef.current = projectPathForChat; + return ''; + } + if (message.includes('项目权限策略要求用户确认:agent.resume')) { + agentChatRuntimeResumeProjectPathRef.current = projectPathForChat; + setAgentChatResumeConfirmation({ + projectPath: projectPathForChat, + detail: `恢复 ${projectPathForChat} 中未完成的 Agent Runtime 任务`, + }); + return ';等待确认恢复 Agent Runtime 任务'; + } + if (message.includes('项目权限策略拒绝执行:agent.resume')) { + agentChatRuntimeResumeProjectPathRef.current = projectPathForChat; + return ';项目策略禁止恢复 Agent Runtime 任务'; + } + agentChatRuntimeResumeProjectPathRef.current = null; + return `;Agent Runtime 恢复检查失败:${message}`; + } + } + + useEscapeToClose( + cancelAgentChatRuntimeResume, + agentChatResumeConfirmation !== null, + ); + async function handleAgentChatPickProjectDirectory() { if (agentChatBusy || agentChatBackgroundBusy) { return; @@ -3670,6 +3773,8 @@ export function WorkspaceLauncher({ } setAgentChatStatus('已选择项目目录'); resetAgentChatSessionView(); + agentChatRuntimeResumeProjectPathRef.current = null; + setAgentChatResumeConfirmation(null); setAgentChatProjectPath(selectedPath); void loadAgentChatConversation(agentChatSelectedAgentId, selectedPath); } catch (error) { @@ -3698,6 +3803,13 @@ export function WorkspaceLauncher({ setAgentChatBusy(true); setAgentChatStatus('正在读取'); try { + const runtimeResumeStatus = await resumeAgentChatRuntimeTasksIfNeeded( + invoke, + projectPathForChat, + ); + if (agentChatLoadVersionRef.current !== loadVersion) { + return; + } let sessionList = knownSessions; let sessionListError = ''; if (sessionList === undefined) { @@ -3821,7 +3933,9 @@ export function WorkspaceLauncher({ ); } } - setAgentChatStatus(`已读取 ${result.messages.length} 条:${result.path}`); + setAgentChatStatus( + `已读取 ${result.messages.length} 条:${result.path}${runtimeResumeStatus}`, + ); } catch (error) { if (agentChatLoadVersionRef.current !== loadVersion) { return; @@ -5244,8 +5358,11 @@ export function WorkspaceLauncher({ value={agentChatProjectPath} onChange={(event) => { agentChatLoadVersionRef.current += 1; + agentChatRuntimeResumeProjectPathRef.current = null; + setAgentChatResumeConfirmation(null); setAgentChatProjectPath(event.currentTarget.value); resetAgentChatSessionView(); + setAgentChatStatus('请选择并读取会话'); }} /> @@ -5438,6 +5555,26 @@ export function WorkspaceLauncher({ } /> + {agentChatResumeConfirmation ? ( +
+ + agent.resume + {agentChatResumeConfirmation.detail} + + + +
+ ) : null}
{agentChatMessages.length > 0 ? ( agentChatMessages.map((message, index) => ( @@ -13051,6 +13188,16 @@ export function App() { pendingNonEmptyProjectCreate !== null, ); + useEffect(() => { + if ( + pendingUiConfirmation?.projectPath && + pendingUiConfirmation.projectPath !== + resolveCurrentUiConfirmationProjectPath() + ) { + cancelUiCommandConfirmation(); + } + }, [localProject?.projectPath, projectPath]); + useEffect(() => { if (!initialProjectPath || initialProjectOpenedRef.current) { return; @@ -13319,6 +13466,22 @@ export function App() { return projectPath; } + function resolveCurrentUiConfirmationProjectPath() { + const openedProjectPath = localProjectPathRef.current?.trim(); + if (openedProjectPath) { + return openedProjectPath; + } + const draftProjectPath = projectPath.trim(); + if ( + !draftProjectPath || + !isAbsoluteProjectPath(draftProjectPath) || + projectPathHasControlCharacter(draftProjectPath) + ) { + return null; + } + return draftProjectPath; + } + function queuePendingCommand(command: PendingCommand) { setPendingCommand(command); setCommandLog((current) => [ @@ -13347,7 +13510,7 @@ export function App() { } if (permission === 'confirm') { pendingUiConfirmationActionRef.current = onConfirm; - setPendingUiConfirmation({ commandId, detail }); + setPendingUiConfirmation({ commandId, detail, projectPath: null }); setCommandLog((current) => [ ...current, `permission.pending ${commandId}`, @@ -13369,7 +13532,7 @@ export function App() { onConfirm: () => void, ) { pendingUiConfirmationActionRef.current = onConfirm; - setPendingUiConfirmation({ commandId, detail }); + setPendingUiConfirmation({ commandId, detail, projectPath }); setCommandLog((current) => [...current, `permission.pending ${commandId}`]); appendLocalPermissionLog(projectPath, 'permission.pending', commandId); } @@ -13474,10 +13637,20 @@ export function App() { if (!pending) { return; } + if ( + pending.projectPath && + pending.projectPath !== resolveCurrentUiConfirmationProjectPath() + ) { + cancelUiCommandConfirmation(); + return; + } + const permissionProjectPath = + pending.projectPath ?? + resolveUiPermissionLogProjectPath(pending.commandId); if ( await denyPendingCommandIfNeeded( pending.commandId, - resolveUiPermissionLogProjectPath(pending.commandId), + permissionProjectPath, ) ) { pendingUiConfirmationActionRef.current = null; @@ -13492,7 +13665,7 @@ export function App() { `permission.confirm ${pending.commandId}`, ]); appendLocalPermissionLog( - resolveUiPermissionLogProjectPath(pending.commandId), + permissionProjectPath, 'permission.confirm', pending.commandId, ); @@ -13626,6 +13799,12 @@ export function App() { : '已取消读取 Agent run 状态', ); } + if ( + pending.commandId === 'agent.resume' && + pending.detail.includes('未完成的 Agent Runtime 任务') + ) { + setAgentRunStatus('已取消恢复 Agent Runtime 任务'); + } if ( pending.commandId === 'project.index' || pending.commandId === 'project.diff' || @@ -13669,7 +13848,8 @@ export function App() { `permission.cancel ${pending.commandId}`, ]); appendLocalPermissionLog( - resolveUiPermissionLogProjectPath(pending.commandId), + pending.projectPath ?? + resolveUiPermissionLogProjectPath(pending.commandId), 'permission.cancel', pending.commandId, ); @@ -13962,6 +14142,13 @@ export function App() { const openedProject = { ...result, projectPath: openedProjectPath }; const conversationMessages = createDefaultChatMessages(); + if ( + pendingUiConfirmation?.projectPath && + pendingUiConfirmation.projectPath !== openedProject.projectPath + ) { + cancelUiCommandConfirmation(); + } + localProjectPathRef.current = openedProject.projectPath; setProjectPath(openedProject.projectPath); setLocalProject(openedProject); setManifest(openedProject.manifest); @@ -21168,23 +21355,80 @@ export function App() { } try { if (agentRuntimeResumeProjectPathRef.current !== nextProjectPath) { - agentRuntimeResumeProjectPathRef.current = nextProjectPath; try { const resumedRuntimes = await invoke( 'resume_game_creator_agent_runtime_tasks', { projectPath: nextProjectPath }, ); + if (localProjectPathRef.current !== nextProjectPath) { + return; + } + agentRuntimeResumeProjectPathRef.current = nextProjectPath; for (const runtimeResult of resumedRuntimes) { rememberAgentRuntimeState(agentRuntimeStateFromResult(runtimeResult)); } - } catch { - // Runtime recovery is best-effort; reading current status below remains authoritative. + } catch (error) { + if (localProjectPathRef.current !== nextProjectPath) { + return; + } + const message = error instanceof Error ? error.message : String(error); + if (isMissingAgentRuntimeResumeCommandError(error)) { + agentRuntimeResumeProjectPathRef.current = nextProjectPath; + } else if (message.includes('项目权限策略要求用户确认:agent.resume')) { + agentRuntimeResumeProjectPathRef.current = nextProjectPath; + requestProjectPolicyConfirmation( + 'agent.resume', + nextProjectPath, + `恢复 ${nextProjectPath} 中未完成的 Agent Runtime 任务`, + () => { + void invoke( + 'confirm_resume_game_creator_agent_runtime_tasks', + { projectPath: nextProjectPath }, + ) + .then((resumedRuntimes) => { + if (localProjectPathRef.current !== nextProjectPath) { + return; + } + agentRuntimeResumeProjectPathRef.current = nextProjectPath; + for (const runtimeResult of resumedRuntimes) { + rememberAgentRuntimeState( + agentRuntimeStateFromResult(runtimeResult), + ); + } + setAgentRunStatus('已确认恢复 Agent Runtime 任务'); + }) + .catch((resumeError) => { + if (localProjectPathRef.current !== nextProjectPath) { + return; + } + agentRuntimeResumeProjectPathRef.current = null; + setAgentRunStatus( + `Agent Runtime 恢复失败:${ + resumeError instanceof Error + ? resumeError.message + : String(resumeError) + }`, + ); + }); + }, + ); + setAgentRunStatus('等待确认恢复 Agent Runtime 任务'); + } else if (message.includes('项目权限策略拒绝执行:agent.resume')) { + agentRuntimeResumeProjectPathRef.current = nextProjectPath; + markProjectPolicyDenied('agent.resume', message); + } else { + agentRuntimeResumeProjectPathRef.current = null; + setAgentRunStatus(`Agent Runtime 恢复失败:${message}`); + } } } const runtimes = await invoke( 'read_game_creator_agent_runtimes', { projectPath: nextProjectPath }, ); + if (localProjectPathRef.current !== nextProjectPath) { + return; + } const nextRuntimeById: Record = {}; for (const runtimeResult of runtimes) { @@ -21194,7 +21438,9 @@ export function App() { } setAgentRuntimeById(nextRuntimeById); } catch { - setAgentRuntimeById({}); + if (localProjectPathRef.current === nextProjectPath) { + setAgentRuntimeById({}); + } } } diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index 3f5f0981f..52a226a24 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -1063,6 +1063,124 @@ describe('AI 游戏创作 App 界面边界', () => { }); }); + it('asks before recovering interrupted runtime tasks in the developer Agent chat', async () => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'resume_game_creator_agent_runtime_tasks') { + throw new Error('项目权限策略要求用户确认:agent.resume'); + } + if (command === 'confirm_resume_game_creator_agent_runtime_tasks') { + return []; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl', + agentId: args?.agentId, + messages: [], + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAgentChatAt('/?agent-chat'); + + fireEvent.change(screen.getByLabelText('Agent 聊天项目目录'), { + target: { value: '/tmp/authorized-game' }, + }); + fireEvent.click(screen.getByRole('button', { name: '读取历史' })); + + const detail = await screen.findByText( + '恢复 /tmp/authorized-game 中未完成的 Agent Runtime 任务', + ); + const confirmation = detail.closest('.pending-command'); + expect(confirmation).not.toBeNull(); + expect(screen.getByText('agent.resume')).not.toBeNull(); + expect( + invoke.mock.calls.filter( + ([command]) => + command === 'confirm_resume_game_creator_agent_runtime_tasks', + ), + ).toHaveLength(0); + + fireEvent.click( + within(confirmation as HTMLElement).getByRole('button', { + name: '确认', + }), + ); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'confirm_resume_game_creator_agent_runtime_tasks', + { projectPath: '/tmp/authorized-game' }, + ); + expect( + screen.queryByText( + '恢复 /tmp/authorized-game 中未完成的 Agent Runtime 任务', + ), + ).toBeNull(); + expect(screen.getByText('已确认恢复 Agent Runtime 任务')).not.toBeNull(); + }); + }); + + it('does not reload an old developer Agent project after recovery returns late', async () => { + let resolveConfirmedResume: ((value: unknown[]) => void) | null = null; + const confirmedResume = new Promise((resolve) => { + resolveConfirmedResume = resolve; + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'resume_game_creator_agent_runtime_tasks') { + throw new Error('项目权限策略要求用户确认:agent.resume'); + } + if (command === 'confirm_resume_game_creator_agent_runtime_tasks') { + return confirmedResume; + } + if (command === 'read_local_conversation') { + return { + path: `${String(args?.projectPath ?? '')}/.agent/conversations/agents/design-director.jsonl`, + agentId: args?.agentId, + messages: [], + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAgentChatAt('/?agent-chat'); + + fireEvent.change(screen.getByLabelText('Agent 聊天项目目录'), { + target: { value: '/tmp/project-a' }, + }); + fireEvent.click(screen.getByRole('button', { name: '读取历史' })); + const detail = await screen.findByText( + '恢复 /tmp/project-a 中未完成的 Agent Runtime 任务', + ); + fireEvent.click( + within(detail.closest('.pending-command') as HTMLElement).getByRole( + 'button', + { name: '确认' }, + ), + ); + fireEvent.change(screen.getByLabelText('Agent 聊天项目目录'), { + target: { value: '/tmp/project-b' }, + }); + + await act(async () => { + resolveConfirmedResume?.([]); + await confirmedResume; + }); + + expect(screen.getAllByText('请选择并读取会话').length).toBeGreaterThan(0); + expect( + invoke.mock.calls.filter( + ([command, args]) => + command === 'read_local_conversation' && + args?.projectPath === '/tmp/project-a', + ), + ).toHaveLength(1); + }); + it('creates, switches, archives, and isolates developer Agent sessions', async () => { type SessionRecord = { sessionId: string; @@ -14029,6 +14147,285 @@ describe('AI 游戏创作 App 界面边界', () => { expect(screen.queryByText('旧 Agent 历史消息')).toBeNull(); }); + it('asks for explicit confirmation before recovering runtime tasks under the default policy', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'resume_game_creator_agent_runtime_tasks') { + throw new Error('项目权限策略要求用户确认:agent.resume'); + } + if (command === 'confirm_resume_game_creator_agent_runtime_tasks') { + return []; + } + if (command === 'read_game_creator_agent_runtimes') { + return []; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + const detail = await screen.findByText( + '恢复 /tmp/authorized-game 中未完成的 Agent Runtime 任务', + ); + expect(screen.getByText(/run:/).textContent).toContain( + '等待确认恢复 Agent Runtime 任务', + ); + const confirmation = detail.closest('.pending-command'); + expect(confirmation).not.toBeNull(); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'confirm_resume_game_creator_agent_runtime_tasks', + ), + ).toHaveLength(0); + + fireEvent.click( + within(confirmation as HTMLElement).getByRole('button', { name: '确认' }), + ); + + await waitFor(() => { + expect( + invoke.mock.calls.filter( + ([command]) => command === 'confirm_resume_game_creator_agent_runtime_tasks', + ), + ).toHaveLength(1); + expect(screen.queryByText( + '恢复 /tmp/authorized-game 中未完成的 Agent Runtime 任务', + )).toBeNull(); + expect(screen.getByText(/run:/).textContent).toContain( + '已确认恢复 Agent Runtime 任务', + ); + }); + }); + + it('cancels a stale runtime recovery confirmation when the project changes', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'resume_game_creator_agent_runtime_tasks') { + throw new Error('项目权限策略要求用户确认:agent.resume'); + } + if (command === 'confirm_resume_game_creator_agent_runtime_tasks') { + return []; + } + if (command === 'read_game_creator_agent_runtimes') { + return []; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'read_local_conversation') { + return { + path: `${String(args?.projectPath ?? '')}/.agent/conversations/project.jsonl`, + agentId: null, + messages: [], + }; + } + if (command === 'read_local_project_file') { + throw new Error('run trace 不存在'); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + fireEvent.change(screen.getByLabelText('本地项目目录'), { + target: { value: '/tmp/project-a' }, + }); + fireEvent.click(screen.getByRole('button', { name: '初始化' })); + const createA = screen + .getByText('创建 /tmp/project-a') + .closest('.pending-command'); + fireEvent.click( + within(createA as HTMLElement).getByRole('button', { name: '确认' }), + ); + expect( + await screen.findByText( + '恢复 /tmp/project-a 中未完成的 Agent Runtime 任务', + ), + ).not.toBeNull(); + + fireEvent.change(screen.getByLabelText('本地项目目录'), { + target: { value: '/tmp/project-b' }, + }); + fireEvent.click(screen.getByRole('button', { name: '初始化' })); + const createB = screen + .getByText('创建 /tmp/project-b') + .closest('.pending-command'); + fireEvent.click( + within(createB as HTMLElement).getByRole('button', { name: '确认' }), + ); + + expect( + await screen.findByText( + '恢复 /tmp/project-b 中未完成的 Agent Runtime 任务', + ), + ).not.toBeNull(); + expect( + screen.queryByText( + '恢复 /tmp/project-a 中未完成的 Agent Runtime 任务', + ), + ).toBeNull(); + expect( + invoke.mock.calls.filter( + ([command]) => + command === 'confirm_resume_game_creator_agent_runtime_tasks', + ), + ).toHaveLength(0); + expect(invoke).toHaveBeenCalledWith( + 'append_local_permission_log', + expect.objectContaining({ + projectPath: '/tmp/project-a', + event: 'permission.cancel', + commandId: 'agent.resume', + }), + ); + }); + + it('ignores delayed runtime recovery results from a previously opened project', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + let resolveProjectAResume: ((value: unknown[]) => void) | null = null; + const projectAResume = new Promise((resolve) => { + resolveProjectAResume = resolve; + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + const targetProjectPath = String(args?.projectPath ?? ''); + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + return { + projectPath: targetProjectPath, + manifestPath: `${targetProjectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'resume_game_creator_agent_runtime_tasks') { + return targetProjectPath === '/tmp/project-a' + ? projectAResume + : []; + } + if (command === 'read_game_creator_agent_runtimes') { + return []; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'read_local_conversation') { + return { + path: `${targetProjectPath}/.agent/conversations/project.jsonl`, + agentId: null, + messages: [], + }; + } + if (command === 'read_local_project_file') { + throw new Error('run trace 不存在'); + } + if (command === 'list_local_project_files') { + return { projectPath: targetProjectPath, files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + fireEvent.change(screen.getByLabelText('本地项目目录'), { + target: { value: '/tmp/project-a' }, + }); + fireEvent.click(screen.getByRole('button', { name: '初始化' })); + const createA = screen + .getByText('创建 /tmp/project-a') + .closest('.pending-command'); + fireEvent.click( + within(createA as HTMLElement).getByRole('button', { name: '确认' }), + ); + expect(await screen.findByText('已打开:/tmp/project-a')).not.toBeNull(); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'resume_game_creator_agent_runtime_tasks', + { projectPath: '/tmp/project-a' }, + ); + }); + + fireEvent.change(screen.getByLabelText('本地项目目录'), { + target: { value: '/tmp/project-b' }, + }); + fireEvent.click(screen.getByRole('button', { name: '初始化' })); + const createB = screen + .getByText('创建 /tmp/project-b') + .closest('.pending-command'); + fireEvent.click( + within(createB as HTMLElement).getByRole('button', { name: '确认' }), + ); + expect(await screen.findByText('已打开:/tmp/project-b')).not.toBeNull(); + + await act(async () => { + resolveProjectAResume?.([]); + await projectAResume; + }); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('read_game_creator_agent_runtimes', { + projectPath: '/tmp/project-b', + }); + }); + expect(invoke).not.toHaveBeenCalledWith( + 'read_game_creator_agent_runtimes', + { projectPath: '/tmp/project-a' }, + ); + expect(screen.getByText('已打开:/tmp/project-b')).not.toBeNull(); + }); + it('shows runtime status and recent tasks in the main agent status list', async () => { const manifest = createGameCreationAppManifest( 'local-project-draft', diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c8ac93873..a05f9431b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4129,3 +4129,11 @@ - 决策补充:画布 Agent 侧边栏的“规范图 / 视觉规范图 / 风格规范图 / 素材规范展板”是 Agent 规划 prompt 和 function-calling 工具选择约束,不是侧边栏 UI 说明文案。此类请求默认走 `generate_image`,prompt 必须要求规范展板包含统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等视觉规范元素;角色规范图若是规范展板也走 `generate_image`,只有实际角色立绘才走 `generate_character`,多个图标素材 / 图集才走 `generate_icon_spritesheet`。 - 影响范围:`server-rs/crates/platform-agent`、`server-rs/crates/api-server/src/config.rs`、`src/services/llmClient.ts`、`.env.example`、`deploy/env/api-server.env.example`、`scripts/test-ve-llm.mjs`。 - 验证方式:`npm run test -- src/services/llmClient.test.ts`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml from_env_reads_non_public_models_and_urls app_state_builds_creative_agent_gpt5_client_from_vector_engine_settings llm_chat_completions editor_agent_llm_request_uses_vector_engine_chat_model`、`cargo test -p platform-agent --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。 + +## 2026-07-10 AI 游戏创作 Agent Runtime 执行边界 + +- 决策:后台 Agent 首轮不得预加载任何需要工具权限控制的项目内容。planning 与 final reply 只拿身份、session/run 元数据、任务、工具策略和已获准 observation;记忆、黑板、对话、资产和文件内容必须通过对应工具进入。最新黑板、记忆和对话采用尾部保留截断。 +- 决策:同一 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。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 9e841476c..d95c269bb 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -67,6 +67,10 @@ Agent Runtime 负责: - 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 补充: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 继续并行,真实项目写工具只在副作用执行期间短暂申请项目写锁。 +- 2026-07-10 补充:默认 `agent.resume=confirm` 时,客户端自动恢复命令只做 auto gate 并返回待确认错误;主工作区和独立开发 Agent 聊天窗口在首次读取项目 Runtime 时都必须显示 `agent.resume` 确认条,确认对象绑定发起时的项目路径,切换项目会取消旧确认,异步返回后也不得把旧项目 Runtime 合并到新项目 UI。开发者确认后调用独立 `confirm_resume_game_creator_agent_runtime_tasks`,该命令仍执行 deny-only 权限检查后才接回 durable queue。临时调用失败不锁死项目路径,允许后续刷新重试;明确 deny 或取消都不恢复任务。 +- 2026-07-10 补充:后台 Agent 只有返回空 `actions` 才视为本轮 loop 已收束。三轮后仍请求工具时终态为 `status=failed / phase=budget-exhausted`,error 使用 `loop-budget-exhausted` 机器可读前缀,不再调用 final reply 后写 completed 审计;解析阶段保留过滤后的 action 总数,每轮超过 3 个 action 时写入 `runtime.tool_budget` observation 并只执行前三个,要求下一轮重新排序。Runtime 默认 `allowedTools` 直接由实际可执行工具白名单派生,避免 UI 观测与执行边界漂移。 - 任务图能力:每轮 Orchestrator agenda、ready / active task 选择、Evaluator 结构化返工路由、返工轮 carry-over。 - 记忆能力:短期记忆 `memory/session.md`、长期记忆 `memory/project.md`、项目级黑板 `memory/blackboard.md` 和角色私有记忆 `memory/agents//.md`;黑板用于共享重要跨 agent 记忆,角色私有记忆只给对应角色 brief 读取和追加。最近 project / agent conversation 会作为短期 prompt 上下文读取,不替代正式 memory 文件。 - 对话能力:结构化对话记录统一落在 `.agent/conversations/` 的 append-only JSONL;普通聊天写 `.agent/conversations/project.jsonl`,进入单个 agent 后只写对应 `.agent/conversations/agents/.jsonl`,不把原始对话混进项目黑板或角色私有记忆。