From 3237ad140a3ced666c605c52d20df7c41ff23e10 Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Fri, 24 Jul 2026 21:44:07 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=99=BA=E8=83=BD=E4=BD=93?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E4=B8=8E=E7=94=BB=E5=B8=83=E7=BE=8E=E6=9C=AF?= =?UTF-8?q?=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 Supervisor 与部门 Director 接入统一 Interaction Loop 和持久 Runtime 调度 在配置画布 API Key 时强制委派美术 Agent 并复用规范素材 收紧画布生成角色权限、图片内容校验和项目锁并发边界 完善专业 Agent revision 验证、任务恢复与 Provider 兼容处理 补充自主协作、真实画布生成、运行时收束测试及技术文档 --- .../src-tauri/src/agent.rs | 2 + .../src-tauri/src/agent/generation.rs | 6 +- .../src/agent/generation/canvas_generation.rs | 94 +++- .../src-tauri/src/agent/interaction.rs | 391 ++++++++++++++++ .../src-tauri/src/agent/prompt.rs | 2 +- .../runtime_actions/autonomous_policy.rs | 27 ++ .../agent/runtime_actions/project_gates.rs | 23 +- .../runtime_actions/provider_tool_plan.rs | 2 +- .../runtime_actions/tool_policy_snapshot.rs | 21 + .../src-tauri/src/agent/runtime_driver.rs | 1 + .../agent/runtime_protocol/verification.rs | 7 +- .../src-tauri/src/agent/runtime_state.rs | 130 +++++- .../src/agent/runtime_tools/media.rs | 73 ++- .../src/agent/runtime_tools/policy.rs | 168 +++++++ .../src-tauri/src/assets.rs | 193 +++++++- .../src-tauri/src/collaboration.rs | 79 +++- .../src-tauri/src/commands.rs | 2 - .../src-tauri/src/project/manifest.rs | 10 + .../src-tauri/src/swarm_cli.rs | 2 + .../src-tauri/src/swarm_cli/input.rs | 192 ++------ .../src-tauri/src/swarm_cli/tests.rs | 26 ++ .../src-tauri/src/swarm_cli/turn_dispatch.rs | 421 ++++++++++++++++++ .../src-tauri/src/swarm_cli/turn_wait.rs | 4 + .../tests/collaboration/static_deliveries.rs | 4 + .../collaboration/supervisor_planning.rs | 323 +++++++++++++- .../src-tauri/src/tests/command_runtime.rs | 10 +- .../src-tauri/src/tests/mod.rs | 30 +- .../src-tauri/src/tests/project.rs | 129 +++++- .../planning_strategy/autonomous_build.rs | 63 ++- .../src-tauri/src/tests/runtime_state.rs | 127 ++++++ .../shared-memory/decision-log.md | 19 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 + server-rs/crates/platform-llm/src/lib.rs | 8 +- 33 files changed, 2357 insertions(+), 236 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs 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 98d239348..c4d44ccfd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -10,6 +10,7 @@ use std::io::{Seek, SeekFrom}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; mod generation; +mod interaction; mod prompt; mod runtime_actions; mod runtime_driver; @@ -17,6 +18,7 @@ mod runtime_protocol; mod runtime_state; mod runtime_tools; pub(crate) use generation::*; +pub(crate) use interaction::*; pub(crate) use prompt::*; pub(crate) use runtime_actions::*; pub(crate) use runtime_driver::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index a528574d8..9046ab892 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -12,11 +12,15 @@ mod run_lifecycle; mod tests; mod trace; -pub(in crate::agent) use canvas_generation::generate_platform_art_asset_with_options_at; +pub(in crate::agent) use canvas_generation::{ + commit_prepared_platform_art_asset_at, request_platform_art_asset_with_options_at, +}; pub(in crate::agent) use draft_validation::validate_closed_game_script_blocks; pub(in crate::agent) use loop_orchestration::build_game_creator_agent_runtime_llm_client; pub(in crate::agent) use trace::game_creation_agent_group_id; +#[cfg(test)] +pub(crate) use canvas_generation::request_platform_art_asset_with_options_for_test; #[allow(unused_imports)] pub(crate) use canvas_generation::{ build_platform_art_asset_prompt, editor_api_key_is_configured, generate_platform_art_asset_at, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 08df7d4d1..12a0f1b98 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -227,7 +227,7 @@ async fn prepare_external_canvas_generation_context( api_base_url: &str, api_key: &str, ) -> Result { - let manifest = read_manifest_for_project(root)?; + let manifest = read_existing_manifest_for_project(root)?; let canvas_name = manifest.name.trim().chars().take(80).collect::(); let canvas_name = if canvas_name.is_empty() { "未命名游戏原型".to_string() @@ -344,16 +344,43 @@ pub(crate) async fn generate_platform_art_asset_at( .await } +pub(in crate::agent) struct PreparedPlatformArtAssetGeneration { + requested_output_path: Option, + download: CanvasResourceDownload, + canvas_context: ExternalCanvasGenerationContext, + resource_id: Option, + task_id: Option, + asset_object_id: Option, + canvas_project_id: Option, + generated_prompt: Option, + model: Option, + provider: Option, + extension: String, +} + pub(in crate::agent) async fn generate_platform_art_asset_with_options_at( root: &Path, prompt: &str, briefs: &[AgentGroupBrief], options: &PlatformArtAssetGenerationOptions, ) -> Result { + let prepared = + request_platform_art_asset_with_options_at(root, prompt, briefs, options).await?; + let _lock = acquire_project_write_lock(root, "canvas.asset_generate")?; + advance_agent_runtime_project_revision_locked(root)?; + commit_prepared_platform_art_asset_at(root, prepared, options) +} + +pub(in crate::agent) async fn request_platform_art_asset_with_options_at( + root: &Path, + prompt: &str, + briefs: &[AgentGroupBrief], + options: &PlatformArtAssetGenerationOptions, +) -> Result { enforce_project_permission_policy(root, "canvas.asset_generate")?; - init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?; - let requested_output = - prepare_platform_art_asset_output_path(root, options.output_path.as_deref())?; + let requested_output_path = + prepare_platform_art_asset_output_path(root, options.output_path.as_deref())? + .map(|(local_path, _)| local_path); let api_base_url = resolve_canvas_sync_api_base_url(None)?; let api_key = resolve_canvas_sync_api_key(None)?; let client = reqwest::Client::new(); @@ -412,6 +439,9 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_options_at( let asset_object_id = json_string_field(generated, "assetObjectId") .or_else(|| json_string_field(resource, "assetObjectId")) .or_else(|| json_string_field(asset, "assetObjectId")); + let canvas_project_id = json_string_field(resource, "projectId") + .or_else(|| json_string_field(generated, "projectId")) + .or_else(|| Some(canvas_context.project_id.clone())); let generated_prompt = json_string_field(generated, "actualPrompt") .or_else(|| json_string_field(generated, "prompt")) .or_else(|| json_string_field(resource, "actualPrompt")) @@ -422,13 +452,60 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_options_at( .or_else(|| json_string_field(resource, "provider")); let source_hint = json_string_field(generated, "objectKey") .or_else(|| json_string_field(generated, "imageSrc")); - let extension = infer_file_extension(source_hint.as_deref(), &download.media_type); + let extension = infer_file_extension(source_hint.as_deref(), &download.media_type).to_string(); + Ok(PreparedPlatformArtAssetGeneration { + requested_output_path, + download, + canvas_context, + resource_id, + task_id, + asset_object_id, + canvas_project_id, + generated_prompt, + model, + provider, + extension, + }) +} + +#[cfg(test)] +pub(crate) async fn request_platform_art_asset_with_options_for_test( + root: &Path, + prompt: &str, + options: &PlatformArtAssetGenerationOptions, +) -> Result<(), String> { + request_platform_art_asset_with_options_at(root, prompt, &[], options) + .await + .map(|_| ()) +} + +pub(in crate::agent) fn commit_prepared_platform_art_asset_at( + root: &Path, + prepared: PreparedPlatformArtAssetGeneration, + options: &PlatformArtAssetGenerationOptions, +) -> Result { + let PreparedPlatformArtAssetGeneration { + requested_output_path, + download, + canvas_context, + resource_id, + task_id, + asset_object_id, + canvas_project_id, + generated_prompt, + model, + provider, + extension, + } = prepared; let file_stem = resource_id .as_deref() .or(task_id.as_deref()) .unwrap_or("platform-art"); - let (local_path, mut absolute_path) = match requested_output { - Some((local_path, absolute_path)) => { + let (local_path, mut absolute_path) = match requested_output_path { + Some(requested_output_path) => { + let (local_path, absolute_path) = + prepare_platform_art_asset_output_path(root, Some(&requested_output_path))? + .ok_or_else(|| "图片生成 outputPath 不能为空".to_string())?; if !platform_art_asset_output_extension_matches(&local_path, &extension) { return Err(format!( "图片生成结果格式为 {extension},与 outputPath 扩展名不一致" @@ -468,9 +545,6 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_options_at( format!("写入平台生成素材失败:{}: {error}", absolute_path.display()) })?; drop(output); - let canvas_project_id = json_string_field(resource, "projectId") - .or_else(|| json_string_field(generated, "projectId")) - .or_else(|| Some(canvas_context.project_id.clone())); let registered = match register_local_asset_entry( root, &local_path, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs new file mode 100644 index 000000000..c47055f39 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs @@ -0,0 +1,391 @@ +use super::*; + +const AGENT_INTERACTION_EXECUTE_TOOL: &str = "runtime_execute"; +const AGENT_INTERACTION_RESUME_TOOL: &str = "runtime_resume"; +const AGENT_INTERACTION_PROJECT_LOCATION_TOOL: &str = "project_location"; +const AGENT_INTERACTION_MAX_OUTPUT_TOKENS: u32 = 1_200; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AgentInteractionToolKind { + Execute, + Resume, + ProjectLocation, +} + +#[derive(Clone, Copy)] +struct AgentInteractionToolDefinition { + kind: AgentInteractionToolKind, + name: &'static str, + description: &'static str, +} + +const AGENT_INTERACTION_TOOL_DEFINITIONS: &[AgentInteractionToolDefinition] = &[ + AgentInteractionToolDefinition { + kind: AgentInteractionToolKind::Execute, + name: AGENT_INTERACTION_EXECUTE_TOOL, + description: "仅当用户明确要求执行需要读取、修改、生成、测试或调用项目工具的工作时,提交用户原始消息给持久 Runtime。否定、假设、解释、咨询或需求仍不明确时不要调用。", + }, + AgentInteractionToolDefinition { + kind: AgentInteractionToolKind::Resume, + name: AGENT_INTERACTION_RESUME_TOOL, + description: "仅当用户明确要求继续或恢复当前 Session 中未完成的持久 Runtime 时调用。", + }, + AgentInteractionToolDefinition { + kind: AgentInteractionToolKind::ProjectLocation, + name: AGENT_INTERACTION_PROJECT_LOCATION_TOOL, + description: "当用户询问当前项目目录或项目在哪里时调用;宿主会直接返回真实本地目录,不要猜测路径。", + }, +]; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum AgentInteractionAction { + Reply(String), + Execute, + Resume, + ProjectLocation, +} + +impl AgentInteractionAction { + pub(crate) fn label(&self) -> &'static str { + match self { + Self::Reply(_) => "reply", + Self::Execute => "execute", + Self::Resume => "resume", + Self::ProjectLocation => "project_location", + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, tag = "action", rename_all = "snake_case")] +enum AgentInteractionTextEnvelope { + Reply { reply: String }, + Execute, + Resume, + ProjectLocation, +} + +pub(crate) fn game_creator_agent_uses_interaction_kernel(agent_id: &str) -> bool { + if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + return true; + } + game_creator_agent_role_definition(agent_id).is_some_and(|(_group, role)| role.id == "director") +} + +fn agent_interaction_function_tools() -> Vec { + AGENT_INTERACTION_TOOL_DEFINITIONS + .iter() + .map(|definition| { + let parameters = match definition.kind { + AgentInteractionToolKind::Execute + | AgentInteractionToolKind::Resume + | AgentInteractionToolKind::ProjectLocation => { + serde_json::json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }) + } + }; + platform_llm::LlmFunctionTool::new(definition.name, definition.description, parameters) + .with_strict(true) + }) + .collect() +} + +fn agent_interaction_system_prompt(agent_id: &str, native_tools: bool) -> String { + let role_prompt = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + game_creator_project_supervisor_chat_system_prompt() + } else { + game_creator_role_agent_chat_system_prompt() + }; + let protocol = if native_tools { + "普通问答、身份说明、架构解释、方案讨论和必要澄清直接用自然语言回复。只有确实需要宿主持久能力时才调用一个 function tool,调用工具时不要同时输出回复文本。不要根据单个关键词决定是否执行,要理解整句的否定、假设、范围和上下文。" + } else { + "你必须只输出一个 JSON 对象,不要代码块或额外文字。允许的结构为:{\"action\":\"reply\",\"reply\":\"自然语言回复\"}、{\"action\":\"execute\"}、{\"action\":\"resume\"}、{\"action\":\"project_location\"}。不要根据单个关键词决定 action,要理解整句的否定、假设、范围和上下文。" + }; + format!( + "{role_prompt}\n\n你现在位于统一的 Agent interaction loop。{protocol} 高影响请求仍不明确时直接追问,不要擅自启动 Runtime。" + ) +} + +fn build_agent_interaction_request_for_session( + root: &Path, + agent_id: &str, + session_id: &str, + prompt: &str, +) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest, bool), String> { + let prompt = prompt.trim(); + if prompt.is_empty() { + return Err("交互内容不能为空".to_string()); + } + if !game_creator_agent_uses_interaction_kernel(agent_id) { + return Err(format!("Agent 不启用交互决策层:{agent_id}")); + } + let (llm, config_path, context) = + build_game_creator_role_agent_context_for_session(root, agent_id, Some(session_id))?; + let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?; + let native_tools = api_kind != LlmApiKind::Anthropic; + let user_prompt = if context.trim().is_empty() { + format!("用户这轮输入:\n{prompt}") + } else { + format!( + "项目上下文如下。只把它当作背景,不要逐字复述。\n\n{context}\n\n用户这轮输入:\n{prompt}" + ) + }; + let mut request = LlmRunRequest::new(vec![ + LlmMessage::system(agent_interaction_system_prompt(agent_id, native_tools)), + LlmMessage::user(user_prompt), + ]) + .with_api_kind(api_kind) + .with_max_output_tokens(AGENT_INTERACTION_MAX_OUTPUT_TOKENS); + if native_tools { + request = request + .with_function_tools(agent_interaction_function_tools()) + .with_tool_choice(platform_llm::LlmToolChoice::Auto); + } + Ok((llm, config_path, request, native_tools)) +} + +pub(crate) async fn decide_game_creator_agent_interaction_turn_for_session_at( + root: &Path, + agent_id: &str, + session_id: &str, + prompt: &str, + mut on_delta: F, +) -> Result +where + F: FnMut(&platform_llm::LlmStreamDelta), +{ + let (llm, config_path, request, native_tools) = + build_agent_interaction_request_for_session(root, agent_id, session_id, prompt)?; + let client = build_game_creator_agent_runtime_llm_client(&llm, &config_path)?; + let response = if native_tools && llm.stream { + let fallback_request = request.clone(); + match client.stream_run(request, |delta| on_delta(delta)).await { + Ok(response) => response, + Err(error) + if matches!( + error.kind(), + platform_llm::LlmErrorKind::StreamUnavailable + | platform_llm::LlmErrorKind::EmptyResponse + | platform_llm::LlmErrorKind::Deserialize + ) => + { + client.run(fallback_request).await.map_err(|fallback_error| { + format!( + "{config_path} Agent interaction 流式协议不可用且普通请求回退失败:流式错误:{error};普通请求错误:{fallback_error}" + ) + })? + } + Err(error) => { + return Err(format!( + "{config_path} Agent interaction 调用 LLM 失败:{error}" + )); + } + } + } else { + client + .run(request) + .await + .map_err(|error| format!("{config_path} Agent interaction 调用 LLM 失败:{error}"))? + }; + parse_agent_interaction_response(&response) +} + +fn parse_agent_interaction_response( + response: &platform_llm::LlmRunResponse, +) -> Result { + if response.tool_calls.len() > 1 { + return Err("Agent interaction 一轮最多只能选择一个宿主工具".to_string()); + } + if let Some(call) = response.tool_calls.first() { + let definition = AGENT_INTERACTION_TOOL_DEFINITIONS + .iter() + .find(|definition| definition.name == call.name) + .ok_or_else(|| format!("Agent interaction 返回未知工具:{}", call.name))?; + return match definition.kind { + AgentInteractionToolKind::Execute => { + let arguments = serde_json::from_str::>( + call.arguments.as_str(), + ) + .map_err(|error| format!("runtime_execute 参数无效:{error}"))?; + if !arguments.is_empty() { + return Err("runtime_execute 不接受参数".to_string()); + } + Ok(AgentInteractionAction::Execute) + } + AgentInteractionToolKind::Resume => { + validate_empty_interaction_tool_arguments(call)?; + Ok(AgentInteractionAction::Resume) + } + AgentInteractionToolKind::ProjectLocation => { + validate_empty_interaction_tool_arguments(call)?; + Ok(AgentInteractionAction::ProjectLocation) + } + }; + } + + let text = strip_llm_thinking_blocks(response.text.as_str()); + if let Some(payload) = extract_json_payload(text.as_str()) { + if let Ok(envelope) = serde_json::from_str::(payload) { + return match envelope { + AgentInteractionTextEnvelope::Reply { reply } => { + let reply = reply.trim(); + if reply.is_empty() { + Err("Agent interaction.reply 不能为空".to_string()) + } else { + Ok(AgentInteractionAction::Reply(reply.to_string())) + } + } + AgentInteractionTextEnvelope::Execute => Ok(AgentInteractionAction::Execute), + AgentInteractionTextEnvelope::Resume => Ok(AgentInteractionAction::Resume), + AgentInteractionTextEnvelope::ProjectLocation => { + Ok(AgentInteractionAction::ProjectLocation) + } + }; + } + } + if text.is_empty() { + return Err("Agent interaction 未返回回复或工具调用".to_string()); + } + Ok(AgentInteractionAction::Reply(text)) +} + +fn validate_empty_interaction_tool_arguments( + call: &platform_llm::LlmToolCall, +) -> Result<(), String> { + let arguments = + serde_json::from_str::>(&call.arguments) + .map_err(|error| format!("{} 参数无效:{error}", call.name))?; + if !arguments.is_empty() { + return Err(format!("{} 不接受参数", call.name)); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn response( + text: &str, + tool_calls: Vec, + ) -> platform_llm::LlmRunResponse { + platform_llm::LlmRunResponse { + provider: LlmProvider::OpenAiCompatible, + model: "interaction-test".to_string(), + text: text.to_string(), + finish_reason: Some("stop".to_string()), + response_id: Some("interaction-response".to_string()), + usage: None, + tool_calls, + } + } + + fn tool_call(name: &str, arguments: &str) -> platform_llm::LlmToolCall { + platform_llm::LlmToolCall { + id: "call-interaction".to_string(), + name: name.to_string(), + arguments: arguments.to_string(), + } + } + + #[test] + fn interaction_kernel_is_limited_to_supervisor_and_department_directors() { + for agent_id in [ + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "design-director", + "balance-director", + "art-director", + "audio-director", + "code-director", + "publish-strategy", + ] { + assert!( + game_creator_agent_uses_interaction_kernel(agent_id), + "{agent_id}" + ); + } + for agent_id in ["design-foundation", "art-asset-plan", "code-prototype"] { + assert!( + !game_creator_agent_uses_interaction_kernel(agent_id), + "{agent_id}" + ); + } + } + + #[test] + fn interaction_response_uses_natural_text_as_direct_reply() { + assert_eq!( + parse_agent_interaction_response(&response("我是项目总控。", Vec::new())).unwrap(), + AgentInteractionAction::Reply("我是项目总控。".to_string()) + ); + } + + #[test] + fn interaction_response_dispatches_registered_execute_tool() { + assert_eq!( + parse_agent_interaction_response(&response( + "", + vec![tool_call(AGENT_INTERACTION_EXECUTE_TOOL, "{}",)], + )) + .unwrap(), + AgentInteractionAction::Execute + ); + } + + #[test] + fn interaction_response_supports_text_protocol_for_non_native_provider() { + assert_eq!( + parse_agent_interaction_response(&response( + r#"{"action":"project_location"}"#, + Vec::new(), + )) + .unwrap(), + AgentInteractionAction::ProjectLocation + ); + } + + #[test] + fn interaction_response_rejects_multiple_host_actions() { + let error = parse_agent_interaction_response(&response( + "", + vec![ + tool_call(AGENT_INTERACTION_RESUME_TOOL, "{}"), + tool_call(AGENT_INTERACTION_PROJECT_LOCATION_TOOL, "{}"), + ], + )) + .expect_err("multiple actions must fail closed"); + assert!(error.contains("最多只能选择一个")); + } + + #[test] + fn interaction_registry_derives_unique_strict_function_tools() { + let tools = agent_interaction_function_tools(); + assert_eq!(tools.len(), AGENT_INTERACTION_TOOL_DEFINITIONS.len()); + let names = tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(); + assert_eq!(names.len(), tools.len()); + assert!(tools.iter().all(|tool| tool.strict)); + assert!(tools.iter().all(|tool| { + tool.parameters["additionalProperties"] == serde_json::json!(false) + && tool.parameters["properties"] == serde_json::json!({}) + })); + } + + #[test] + fn interaction_response_rejects_arguments_for_host_selected_action() { + let error = parse_agent_interaction_response(&response( + "", + vec![tool_call( + AGENT_INTERACTION_PROJECT_LOCATION_TOOL, + r#"{"path":"/tmp"}"#, + )], + )) + .expect_err("host facts must not accept model-selected arguments"); + assert!(error.contains("不接受参数")); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs index 2b3ce7a0e..769dd3fdc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs @@ -489,7 +489,7 @@ pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt_for_agent( } if agent_id == "art-asset-plan" { return format!( - "{prompt}\n\n你负责首版美术素材实际生成。资产清单和美术计划只是中间结果;最终必须调用 canvas.asset_generate 生成核心素材图并登记到 assets/art-spritesheet.png,assetKind=art-spritesheet、assetLabel=游戏首版核心美术素材。随后用 asset.list 核对 manifest 已登记该 image/* 资产。图片生成未配置、待确认或失败时不得提交最终回复,也不得把计划写完当成 completed。" + "{prompt}\n\n你负责首版美术素材实际生成。资产清单和美术计划只是中间结果;最终必须调用 canvas.asset_generate 生成核心素材图并登记到 assets/art-spritesheet.png,assetKind=art-spritesheet、assetLabel=游戏首版核心美术素材。成功执行 canvas.asset_generate 后,该动作本身就是当前 revision 的验证;随后只需调用 asset.list 回读并核对 manifest 已登记该 image/* 资产,然后回复。不得运行 game.static_smoke 或 preview.validate,也不得编辑 game/index.html。图片生成未配置、待确认或失败时不得提交最终回复,也不得把计划写完当成 completed。" ); } if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index 508827928..94be0929e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs @@ -257,6 +257,7 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_ ) -> Result<(), String> { let mut code_prototype = None; let mut quality_review = None; + let mut art_asset_plan = None; for action in &plan.actions { let Some(input) = autonomous_initial_delegate_input(action)? else { continue; @@ -277,6 +278,7 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_ let slot = match target_agent_id { "code-prototype" => &mut code_prototype, AGENT_RUNTIME_QUALITY_REVIEW_AGENT_ID => &mut quality_review, + "art-asset-plan" => &mut art_asset_plan, _ => continue, }; if slot.replace(input).is_some() { @@ -339,6 +341,31 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_ "首批 quality-review 的 expectedArtifacts 必须为 []", )); } + if let Some(art_asset_plan) = art_asset_plan { + let art_task = autonomous_initial_delegate_task(art_asset_plan, "art-asset-plan")?; + let art_criteria = agent_runtime_tool_input_string_list( + &serde_json::Value::Object(art_asset_plan.clone()), + &["acceptanceCriteria", "acceptance_criteria", "criteria"], + ); + if std::iter::once(art_task.as_str()) + .chain(art_criteria.iter().map(String::as_str)) + .any(agent_runtime_task_explicitly_requires_read_only_delivery) + { + return Err(autonomous_initial_collaboration_contract_error( + "首批 art-asset-plan 必须是非只读美术生成任务", + )); + } + let art_artifacts = + autonomous_initial_delegate_expected_artifacts(art_asset_plan, "art-asset-plan")?; + if !art_artifacts + .iter() + .any(|path| path == "assets/art-spritesheet.png") + { + return Err(autonomous_initial_collaboration_contract_error( + "首批 art-asset-plan 的 expectedArtifacts 必须包含 assets/art-spritesheet.png", + )); + } + } Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index 915a95d14..c53f07884 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -1243,18 +1243,35 @@ pub(in crate::agent) fn evaluate_project_verification_completion_at_locked( if !gate.requires_verification { return Ok(None); } + let required_verified_revision = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + revision.revision + } else { + gate.mutation_revision.unwrap_or(revision.revision) + }; + let verification_covers_required_revision = + if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + gate.verified_revision == Some(required_verified_revision) + } else { + gate.verified_revision + .is_some_and(|verified_revision| verified_revision >= required_verified_revision) + }; if gate.last_verification_status.as_deref() != Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED) - || gate.verified_revision != Some(revision.revision) + || !verification_covers_required_revision { let verified_revision = gate .verified_revision .map(|value| value.to_string()) .unwrap_or_else(|| "none".to_string()); return Ok(Some(agent_runtime_verification_blocker( - "项目在当前 revision 上尚未通过验证,不能把任务标记为完成", + if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + "项目在当前 revision 上尚未通过验证,不能把任务标记为完成" + } else { + "当前专业 run 的最后一次修改尚未通过验证,不能把任务标记为完成" + }, format!( - "currentRevision={}, mutationRevision={}, verifiedRevision={};请重新执行并通过 project.verify、可验证 command.exec 或 game.static_smoke。", + "currentRevision={}, requiredVerifiedRevision={}, mutationRevision={}, verifiedRevision={};请重新执行并通过 project.verify、可验证 command.exec 或 game.static_smoke。", revision.revision, + required_verified_revision, gate.mutation_revision .map(|value| value.to_string()) .unwrap_or_else(|| "none".to_string()), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs index aae363218..15ceeefa4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs @@ -714,7 +714,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { format!( - "上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留首批协作工具。必须在同一响应一次性建立完整首批合同:code-prototype 必须是非只读实现任务且 expectedArtifacts 包含 game/index.html;quality-review 的 task 必须显式声明只读、不得修改项目,且 expectedArtifacts 必须为 []。两者都使用 agent.delegate,repairOfDelegationId=null、runId=null;如当前 policy 还要求 isolated,再在同批补齐 agent.spawn_isolated。不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。" + "上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留首批协作工具。必须在同一响应一次性建立完整首批合同:code-prototype 必须是非只读实现任务且 expectedArtifacts 包含 game/index.html;quality-review 的 task 必须显式声明只读、不得修改项目,且 expectedArtifacts 必须为 [];如当前 policy 的 requiredStaticAgentIds 包含 art-asset-plan,还必须加入非只读美术生成委派且 expectedArtifacts 包含 assets/art-spritesheet.png。所有静态委派都使用 agent.delegate,repairOfDelegationId=null、runId=null;如当前 policy 还要求 isolated,再在同批补齐 agent.spawn_isolated。不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。" ) } else { format!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs index 9f88a88db..d2ccf1c56 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs @@ -46,6 +46,10 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> { ] } +fn autonomous_game_build_agent_can_generate_canvas_asset(agent_id: &str) -> bool { + matches!(agent_id.trim(), "design-foundation" | "art-asset-plan") +} + pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at( root: &Path, agent_id: &str, @@ -132,6 +136,23 @@ pub(crate) fn agent_runtime_tool_policy_snapshot_for_run_at( .denied_tools .push(GAME_CREATOR_USER_INPUT_REQUEST_TOOL.to_string()); } + if !autonomous_game_build_agent_can_generate_canvas_asset(agent_id) { + snapshot + .auto_tools + .retain(|tool| tool != "canvas.asset_generate"); + snapshot + .confirm_tools + .retain(|tool| tool != "canvas.asset_generate"); + if !snapshot + .denied_tools + .iter() + .any(|tool| tool == "canvas.asset_generate") + { + snapshot + .denied_tools + .push("canvas.asset_generate".to_string()); + } + } for tool in agent_runtime_executable_tools() { let Some(command_id) = game_creator_agent_runtime_tool_command_id(tool) else { continue; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index a51f12598..0b0952113 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -113,6 +113,7 @@ pub(super) const AGENT_RUNTIME_AUTONOMOUS_GAME_BUILD_AUTO_COMMAND_IDS: &[&str] = "project.verify", "command.run_limited", "preview.validate", + "canvas.asset_generate", "agent.delegate", "agent.spawn_isolated", "agent.run_status", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/verification.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/verification.rs index 02e2444c7..2b021800d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/verification.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/verification.rs @@ -140,6 +140,7 @@ pub(in crate::agent) fn validate_agent_runtime_verification_gate( | "project.restore" | "command.exec" | "command.start" + | "canvas.asset_generate" ) }) { return Err("Agent Runtime verification gate 的修改工具无效".to_string()); @@ -147,7 +148,11 @@ pub(in crate::agent) fn validate_agent_runtime_verification_gate( if gate.last_verification_tool.as_deref().is_some_and(|tool| { !matches!( tool, - "project.verify" | "game.static_smoke" | "command.exec" | "preview.validate" + "project.verify" + | "game.static_smoke" + | "command.exec" + | "preview.validate" + | "canvas.asset_generate" ) }) { return Err("Agent Runtime verification gate 的验证工具无效".to_string()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs index 3457a1433..9488e2a8e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs @@ -3280,26 +3280,65 @@ pub(crate) fn read_all_game_creator_agent_runtime_tasks( let mut records = Vec::new(); match File::open(path) { Ok(file) => { - for line in BufReader::new(file).lines() { - let line = line.map_err(|error| { + let mut reader = BufReader::new(file); + let mut line_number = 0usize; + loop { + let mut line = Vec::new(); + let bytes_read = reader.read_until(b'\n', &mut line).map_err(|error| { format!("读取 Agent Runtime 任务失败:{}: {error}", path.display()) })?; - let line = line.trim(); + if bytes_read == 0 { + break; + } + line_number += 1; + let terminated = line.last() == Some(&b'\n'); + if terminated { + line.pop(); + if line.last() == Some(&b'\r') { + line.pop(); + } + } + let line = match std::str::from_utf8(&line) { + Ok(line) => line.trim(), + Err(error) if !terminated && error.error_len().is_none() => break, + Err(error) => { + return Err(format!( + "读取 Agent Runtime 任务失败:{}: 第 {line_number} 行不是有效 UTF-8:{error}", + path.display() + )); + } + }; if line.is_empty() { continue; } - match serde_json::from_str::(line) { - Ok(mut record) => { - normalize_game_creator_agent_runtime_task(&mut record); - validate_game_creator_agent_runtime_task_goal_binding(&record).map_err( - |error| { - format!("读取 Agent Runtime 任务失败:{}: {error}", path.display()) - }, - )?; - records.push(record); + let mut record = match serde_json::from_str::(line) { + Ok(record) => record, + Err(error) if !terminated && error.is_eof() => break, + Err(error) => { + return Err(format!( + "读取 Agent Runtime 任务失败:{}: 第 {line_number} 行 JSON 无效:{error}", + path.display() + )); } - Err(_) => continue, - } + }; + normalize_game_creator_agent_runtime_task(&mut record); + validate_game_creator_agent_runtime_task_status_phase(&record).map_err( + |error| { + format!( + "读取 Agent Runtime 任务失败:{}: 第 {line_number} 行:{error}", + path.display() + ) + }, + )?; + validate_game_creator_agent_runtime_task_goal_binding(&record).map_err( + |error| { + format!( + "读取 Agent Runtime 任务失败:{}: 第 {line_number} 行:{error}", + path.display() + ) + }, + )?; + records.push(record); } } Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} @@ -3313,6 +3352,69 @@ pub(crate) fn read_all_game_creator_agent_runtime_tasks( Ok(records) } +fn validate_game_creator_agent_runtime_task_status_phase( + record: &AgentRuntimeTaskRecord, +) -> Result<(), String> { + if !matches!( + record.status.as_str(), + "idle" + | "pending" + | "running" + | "waiting-for-confirmation" + | "waiting-for-user-input" + | "paused" + | "pausing" + | "cancelling" + | "cancelled" + | "completed" + | "failed" + | "needs-reconciliation" + ) { + return Err(format!( + "Agent Runtime task status 无效:runId={} status={}", + record.run_id, record.status + )); + } + if !matches!( + record.phase.as_str(), + "idle" + | "queued" + | "planning" + | "llm" + | "action" + | "provider-action-batch" + | "observation" + | "response" + | "finalizing" + | "waiting-for-confirmation" + | "waiting-for-user-input" + | "waiting-for-provider-retry" + | "waiting-for-process-session" + | "waiting-for-isolated-join" + | "waiting-for-delegate-receipts" + | "waiting-for-visual-asset" + | "brief" + | "paused" + | "pausing" + | "cancelling" + | "cancelled" + | "completed" + | "failed" + | "budget-exhausted" + | "needs-reconciliation" + | "completion-contract-failed" + | "conversation-write-failed" + | "parent-terminal" + | "parent-link-missing" + ) { + return Err(format!( + "Agent Runtime task phase 无效:runId={} phase={}", + record.run_id, record.phase + )); + } + Ok(()) +} + pub(super) fn latest_game_creator_agent_runtime_tasks( records: Vec, ) -> Vec { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs index 0f45e247c..239e44b6c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs @@ -531,7 +531,33 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio detail: None, }; } - let _lock = match acquire_project_write_lock(root, "canvas.asset_generate") { + if let Some(blocker) = + supervisor_orchestrator_mutation_block_at(root, agent_id, run_id, "canvas.asset_generate") + { + return blocker; + } + let prepared = match request_platform_art_asset_with_options_at( + root, + prompt.trim(), + &[], + &options, + ) + .await + { + Ok(prepared) => prepared, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + }; + let _lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "canvas.asset_generate", + ) { Ok(lock) => lock, Err(error) => { return AgentRuntimeToolObservation { @@ -547,20 +573,47 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio { return blocker; } - if let Err(error) = advance_agent_runtime_project_revision_locked(root) { - return agent_runtime_revision_advance_failure_observation( - root, - "canvas.asset_generate", - &error, - ); - } - match generate_platform_art_asset_with_options_at(root, prompt.trim(), &[], &options).await { + let mutation_revision = match prepare_agent_runtime_project_mutation_locked( + root, + agent_id, + run_id, + "canvas.asset_generate", + ) { + Ok(revision) => revision, + Err(error) => { + return agent_runtime_revision_advance_failure_observation( + root, + "canvas.asset_generate", + &error, + ); + } + }; + match commit_prepared_platform_art_asset_at(root, prepared, &options) { Ok(generated) => { + let verification = begin_agent_runtime_project_verification_locked( + root, + agent_id, + run_id, + "canvas.asset_generate", + ) + .and_then(|(revision, gate)| { + finish_agent_runtime_project_verification_locked(root, &revision, gate, true) + }); + if let Err(error) = verification { + return AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: "failed".to_string(), + summary: "美术素材已生成,但无法提交当前 revision 的验证凭证".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } let _ = append_agent_db_record( root, serde_json::json!({ "recordType": "agent.runtime.canvas.asset_generate", "agentId": agent_id, + "runId": run_id, + "verifiedRevision": mutation_revision, "assetId": generated.asset.id.clone(), "localPath": generated.asset.local_path.clone(), "resourceId": generated.resource_id.clone(), @@ -574,7 +627,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio status: "ok".to_string(), summary: format!("已生成美术素材:{}", generated.asset.local_path), detail: Some(format!( - "assetId={}, localPath={}, resourceId={}, assetObjectId={}, taskId={}, model={}", + "assetId={}, localPath={}, resourceId={}, assetObjectId={}, taskId={}, model={}, verifiedRevision={mutation_revision}", generated.asset.id, generated.asset.local_path, generated.resource_id.as_deref().unwrap_or(""), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs index 953157d30..6710db107 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs @@ -1,5 +1,9 @@ use super::*; +fn autonomous_game_build_agent_can_execute_canvas_asset_generate(agent_id: &str) -> bool { + matches!(agent_id.trim(), "design-foundation" | "art-asset-plan") +} + pub(in crate::agent) fn refresh_game_creator_agent_runtime_tool_policy( root: &Path, state: &mut AgentRuntimeState, @@ -26,6 +30,9 @@ pub(crate) fn game_creator_agent_runtime_tool_policy_rule_for_run( command_id: &str, ) -> Option { let blocked = game_creator_agent_runtime_tool_policy_rule(root, agent_id, command_id); + if matches!(blocked, Some(AgentRuntimeToolPolicyBlock::Denied(_))) { + return blocked; + } let (run_profile, _) = match agent_runtime_run_profile_identity_at( root, agent_id, @@ -36,6 +43,14 @@ pub(crate) fn game_creator_agent_runtime_tool_policy_rule_for_run( Ok(identity) => identity, Err(error) => return Some(AgentRuntimeToolPolicyBlock::Denied(error)), }; + if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && command_id == "canvas.asset_generate" + && !autonomous_game_build_agent_can_execute_canvas_asset_generate(agent_id) + { + return Some(AgentRuntimeToolPolicyBlock::Denied(format!( + "自主构建模式只允许 design-foundation 或 art-asset-plan 执行:{command_id}" + ))); + } match blocked { Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_)) if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD => @@ -292,3 +307,156 @@ pub(in crate::agent) fn validate_agent_runtime_pending_action_after_lock( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + fn bind_delegated_autonomous_run( + root: &Path, + parent_run_id: &str, + agent_id: &str, + ) -> AgentRuntimeRunProfileBinding { + bind_game_creator_agent_runtime_run_profile_at( + root, + agent_id, + &format!("policy-{agent_id}-run"), + "agent-delegate", + None, + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some(parent_run_id.to_string()), + delegation_id: Some(format!("policy-{agent_id}-delegation")), + }), + ) + .expect("bind delegated autonomous run") + } + + #[test] + fn autonomous_canvas_execution_gate_allows_only_visual_agents_and_preserves_explicit_denies() { + let temporary = tempfile::tempdir().expect("create canvas host gate root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "project-canvas-host-gate", "画布宿主门禁项目") + .expect("project init"); + let supervisor_run_id = "policy-supervisor-run"; + let supervisor_binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + supervisor_run_id, + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind supervisor autonomous run"); + + let supervisor_block = game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + supervisor_run_id, + Some(&supervisor_binding.profile), + Some(&supervisor_binding.binding_fingerprint), + "canvas.asset_generate", + ); + assert!(matches!( + supervisor_block, + Some(AgentRuntimeToolPolicyBlock::Denied(reason)) + if reason.contains("只允许 design-foundation 或 art-asset-plan") + )); + + let mut bindings = BTreeMap::new(); + for agent_id in [ + "code-prototype", + "quality-review", + "design-foundation", + "art-asset-plan", + ] { + bindings.insert( + agent_id, + bind_delegated_autonomous_run(&root, supervisor_run_id, agent_id), + ); + } + for agent_id in ["code-prototype", "quality-review"] { + let binding = bindings.get(agent_id).expect("non-visual binding"); + let blocked = game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + agent_id, + &format!("policy-{agent_id}-run"), + Some(&binding.profile), + Some(&binding.binding_fingerprint), + "canvas.asset_generate", + ); + assert!(matches!( + blocked, + Some(AgentRuntimeToolPolicyBlock::Denied(reason)) + if reason.contains("只允许 design-foundation 或 art-asset-plan") + )); + } + for agent_id in ["design-foundation", "art-asset-plan"] { + let binding = bindings.get(agent_id).expect("visual binding"); + assert!(game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + agent_id, + &format!("policy-{agent_id}-run"), + Some(&binding.profile), + Some(&binding.binding_fingerprint), + "canvas.asset_generate", + ) + .is_none()); + } + + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["canvas.asset_generate".to_string()], + confirm_commands: ProjectPermissionPolicy::default().confirm_commands, + agent_policies: BTreeMap::new(), + }, + ) + .expect("write project deny"); + let art_binding = bindings.get("art-asset-plan").expect("art binding"); + assert!(matches!( + game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + "art-asset-plan", + "policy-art-asset-plan-run", + Some(&art_binding.profile), + Some(&art_binding.binding_fingerprint), + "canvas.asset_generate", + ), + Some(AgentRuntimeToolPolicyBlock::Denied(reason)) + if reason.contains("项目权限策略拒绝执行") + )); + + let mut agent_policies = BTreeMap::new(); + agent_policies.insert( + "design-foundation".to_string(), + ProjectAgentPermissionPolicy { + denied_commands: vec!["canvas.asset_generate".to_string()], + confirm_commands: Vec::new(), + }, + ); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: ProjectPermissionPolicy::default().confirm_commands, + agent_policies, + }, + ) + .expect("write agent deny"); + let design_binding = bindings.get("design-foundation").expect("design binding"); + assert!(matches!( + game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + "design-foundation", + "policy-design-foundation-run", + Some(&design_binding.profile), + Some(&design_binding.binding_fingerprint), + "canvas.asset_generate", + ), + Some(AgentRuntimeToolPolicyBlock::Denied(reason)) + if reason.contains("Agent 权限策略拒绝执行") + )); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index 431e275a4..31866dc59 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -346,29 +346,120 @@ pub(crate) struct CanvasResourceDownload { pub(crate) media_type: String, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum CanvasImageFormat { + Png, + Jpeg, + Webp, + Gif, +} + +impl CanvasImageFormat { + fn from_media_type(media_type: &str) -> Option { + match media_type { + "image/png" => Some(Self::Png), + "image/jpeg" | "image/jpg" => Some(Self::Jpeg), + "image/webp" => Some(Self::Webp), + "image/gif" => Some(Self::Gif), + _ => None, + } + } + + fn from_source_hint(source_hint: Option<&str>) -> Option { + let source = source_hint? + .split(['?', '#']) + .next() + .unwrap_or_default() + .to_ascii_lowercase(); + match Path::new(&source) + .extension() + .and_then(|extension| extension.to_str()) + { + Some("png") => Some(Self::Png), + Some("jpg" | "jpeg") => Some(Self::Jpeg), + Some("webp") => Some(Self::Webp), + Some("gif") => Some(Self::Gif), + _ => None, + } + } + + fn media_type(self) -> &'static str { + match self { + Self::Png => "image/png", + Self::Jpeg => "image/jpeg", + Self::Webp => "image/webp", + Self::Gif => "image/gif", + } + } + + fn matches_magic(self, bytes: &[u8]) -> bool { + match self { + Self::Png => bytes.starts_with(b"\x89PNG\r\n\x1a\n"), + Self::Jpeg => bytes.starts_with(&[0xff, 0xd8, 0xff]), + Self::Webp => bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP", + Self::Gif => bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a"), + } + } +} + +fn validate_canvas_downloaded_asset_content( + source_hint: Option<&str>, + image_source: bool, + media_type: &str, + bytes: &[u8], +) -> Result<(), String> { + let normalized_media_type = media_type + .split(';') + .next() + .unwrap_or(media_type) + .trim() + .to_ascii_lowercase(); + let declared_format = CanvasImageFormat::from_media_type(&normalized_media_type); + let hinted_format = CanvasImageFormat::from_source_hint(source_hint); + if declared_format + .zip(hinted_format) + .is_some_and(|(declared, hinted)| declared != hinted) + { + return Err("画板图片响应格式与资源路径不一致".to_string()); + } + + if let Some(format) = declared_format.or(hinted_format) { + if !format.matches_magic(bytes) { + return Err(format!("画板图片内容与 {} 格式不匹配", format.media_type())); + } + } else if normalized_media_type.starts_with("image/") || image_source { + return Err("画板图片响应缺少可校验的受支持格式".to_string()); + } + + Ok(()) +} + pub(crate) async fn resolve_canvas_resource_download( client: &reqwest::Client, api_base_url: &str, api_key: &str, resource: &serde_json::Value, ) -> Result, String> { - let signed_url = if let Some(object_key) = json_string_field(resource, "objectKey") { + let object_key = json_string_field(resource, "objectKey"); + let image_src = json_string_field(resource, "imageSrc"); + let source_hint = object_key.as_deref().or(image_src.as_deref()); + let signed_url = if let Some(object_key) = object_key.as_deref() { let read_url = format!( "{}/api/external/v1/assets/read-url?objectKey={}", api_base_url, - percent_encode_query_component(&object_key) + percent_encode_query_component(object_key) ); Some(resolve_external_asset_signed_url(client, api_key, read_url).await?) - } else if let Some(image_src) = json_string_field(resource, "imageSrc") { + } else if let Some(image_src) = image_src.as_deref() { if image_src.starts_with('/') { let read_url = format!( "{}/api/external/v1/assets/read-url?legacyPublicPath={}", api_base_url, - percent_encode_query_component(&image_src) + percent_encode_query_component(image_src) ); Some(resolve_external_asset_signed_url(client, api_key, read_url).await?) } else if image_src.starts_with("http://") || image_src.starts_with("https://") { - Some(image_src) + Some(image_src.to_string()) } else { None } @@ -408,6 +499,12 @@ pub(crate) async fn resolve_canvas_resource_download( if bytes.len() > 20 * 1024 * 1024 { return Err("画板资产超过 20 MiB,已拒绝同步".to_string()); } + validate_canvas_downloaded_asset_content( + source_hint, + image_src.is_some(), + &media_type, + &bytes, + )?; if bytes.is_empty() { return Ok(None); } @@ -776,3 +873,89 @@ pub(crate) fn register_local_asset_entry( manifest_path: manifest_path.to_string_lossy().into_owned(), }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canvas_download_accepts_supported_image_magic() { + let cases: [(&str, &str, &[u8]); 4] = [ + ( + "image/png; charset=binary", + "assets/hero.png", + b"\x89PNG\r\n\x1a\nbody", + ), + ("IMAGE/JPEG", "assets/hero.jpeg", &[0xff, 0xd8, 0xff, 0xe0]), + ( + "image/webp", + "assets/hero.webp?version=1", + b"RIFF\x04\x00\x00\x00WEBP", + ), + ("image/gif", "assets/hero.gif#frame", b"GIF89abody"), + ]; + + for (media_type, source_hint, bytes) in cases { + validate_canvas_downloaded_asset_content(Some(source_hint), false, media_type, bytes) + .unwrap_or_else(|error| panic!("{media_type} should pass: {error}")); + } + } + + #[test] + fn canvas_download_rejects_error_bodies_and_wrong_image_magic() { + for (media_type, source_hint) in [ + ("image/png", "assets/hero.png"), + ("image/jpeg", "assets/hero.jpg"), + ("image/webp", "assets/hero.webp"), + ("image/gif", "assets/hero.gif"), + ] { + let error = validate_canvas_downloaded_asset_content( + Some(source_hint), + false, + media_type, + br#"{"error":"generation failed"}"#, + ) + .expect_err("200 error body must fail image validation"); + assert!(error.contains("格式不匹配")); + } + } + + #[test] + fn canvas_download_uses_image_path_when_response_mime_is_not_image() { + let error = validate_canvas_downloaded_asset_content( + Some("assets/hero.png"), + false, + "application/json", + br#"{"error":"expired signed url"}"#, + ) + .expect_err("image path must still require valid image bytes"); + assert!(error.contains("image/png")); + } + + #[test] + fn canvas_download_rejects_unverifiable_image_source_but_leaves_media_unchanged() { + let error = validate_canvas_downloaded_asset_content( + Some("https://example.test/generated"), + true, + "text/html", + b"upstream error", + ) + .expect_err("imageSrc response without a supported format must fail closed"); + assert!(error.contains("缺少可校验")); + + validate_canvas_downloaded_asset_content( + Some("assets/theme.mp3"), + false, + "audio/mpeg", + b"audio-validation-remains-unchanged", + ) + .expect("audio downloads are outside image magic validation"); + validate_canvas_downloaded_asset_content( + Some("assets/intro.mp4"), + false, + "video/mp4", + b"video-validation-remains-unchanged", + ) + .expect("video downloads are outside image magic validation"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs b/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs index b8c68d893..e7d1c2781 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs @@ -22,7 +22,7 @@ const SUPERVISOR_COLLABORATION_POLICY_BINDING_LEGACY_CURRENT: &str = const SUPERVISOR_COLLABORATION_MAX_STATIC_DELEGATES: usize = 3; const SUPERVISOR_COLLABORATION_MAX_ISOLATED_CHILDREN: usize = 3; const SUPERVISOR_COLLABORATION_MAX_ISOLATED_GROUPS_BEFORE_CLAIM: usize = 16; -const AUTONOMOUS_GAME_BUILD_REQUIRED_STATIC_AGENT_IDS: [&str; 2] = +const AUTONOMOUS_GAME_BUILD_BASE_REQUIRED_STATIC_AGENT_IDS: [&str; 2] = ["code-prototype", "quality-review"]; #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] @@ -67,18 +67,65 @@ impl Default for SupervisorCollaborationPolicy { } } -fn autonomous_game_build_supervisor_collaboration_policy() -> SupervisorCollaborationPolicy { +fn autonomous_game_build_has_canonical_art_asset(root: &Path) -> bool { + const ART_PATH: &str = "assets/art-spritesheet.png"; + read_existing_manifest_for_project(root) + .ok() + .is_some_and(|manifest| { + manifest.assets.iter().any(|asset| { + asset.local_path == ART_PATH + && asset.kind == "art-spritesheet" + && asset.media_type.starts_with("image/") + && asset.source.kind == GameCreationAppAssetSourceKind::Canvas + && resolve_local_project_path(root, ART_PATH) + .ok() + .and_then(|path| fs::read(path).ok()) + .is_some_and(|bytes| { + bytes.len() > 8 && bytes.starts_with(b"\x89PNG\r\n\x1a\n") + }) + }) + }) +} + +fn autonomous_game_build_supervisor_collaboration_policy( + root: &Path, +) -> SupervisorCollaborationPolicy { + let mut required_static_agent_ids = AUTONOMOUS_GAME_BUILD_BASE_REQUIRED_STATIC_AGENT_IDS + .into_iter() + .map(str::to_string) + .collect::>(); + if editor_api_key_is_configured() && !autonomous_game_build_has_canonical_art_asset(root) { + required_static_agent_ids.push("art-asset-plan".to_string()); + } SupervisorCollaborationPolicy { required_initial_wave: SupervisorInitialCollaborationWave::Static, - min_static_delegates: AUTONOMOUS_GAME_BUILD_REQUIRED_STATIC_AGENT_IDS.len(), - required_static_agent_ids: AUTONOMOUS_GAME_BUILD_REQUIRED_STATIC_AGENT_IDS - .into_iter() - .map(str::to_string) - .collect(), + min_static_delegates: required_static_agent_ids.len(), + required_static_agent_ids, ..SupervisorCollaborationPolicy::default() } } +fn apply_autonomous_game_build_required_static_agents( + root: &Path, + mut policy: SupervisorCollaborationPolicy, +) -> Result { + if editor_api_key_is_configured() + && !autonomous_game_build_has_canonical_art_asset(root) + && !policy + .required_static_agent_ids + .iter() + .any(|existing| existing == "art-asset-plan") + { + policy + .required_static_agent_ids + .push("art-asset-plan".to_string()); + } + policy.min_static_delegates = policy + .min_static_delegates + .max(policy.required_static_agent_ids.len()); + normalize_supervisor_collaboration_policy(policy) +} + #[derive(Clone, Debug)] struct SupervisorCollaborationUnboundPolicy { policy: SupervisorCollaborationPolicy, @@ -92,11 +139,19 @@ fn read_supervisor_collaboration_unbound_policy_for_run_at( parent_agent_id: &str, parent_run_id: &str, ) -> Result { + let (run_profile, _) = + agent_runtime_run_profile_identity_at(root, parent_agent_id, parent_run_id, None, None)?; + let autonomous_supervisor = parent_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD; let policy_path = root.join(SUPERVISOR_COLLABORATION_POLICY_RELATIVE_PATH); match fs::symlink_metadata(&policy_path) { Ok(_) => { + let mut policy = read_supervisor_collaboration_policy_at(root)?; + if autonomous_supervisor { + policy = apply_autonomous_game_build_required_static_agents(root, policy)?; + } return Ok(SupervisorCollaborationUnboundPolicy { - policy: read_supervisor_collaboration_policy_at(root)?, + policy, source: "project-policy-unbound", project_policy_status: "current", project_policy_present: true, @@ -109,14 +164,10 @@ fn read_supervisor_collaboration_unbound_policy_for_run_at( )); } } - let (run_profile, _) = - agent_runtime_run_profile_identity_at(root, parent_agent_id, parent_run_id, None, None)?; - if parent_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD - { + if autonomous_supervisor { return Ok(SupervisorCollaborationUnboundPolicy { policy: normalize_supervisor_collaboration_policy( - autonomous_game_build_supervisor_collaboration_policy(), + autonomous_game_build_supervisor_collaboration_policy(root), )?, source: "autonomous-run-default", project_policy_status: "absent", 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 e97fd3918..85497b903 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -995,8 +995,6 @@ pub(crate) async fn generate_platform_art_asset( ) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "canvas.asset_generate")?; - let _lock = acquire_project_write_lock(root, "canvas.asset_generate")?; - advance_agent_runtime_project_revision_locked(root)?; let generated = generate_platform_art_asset_at(root, prompt.trim(), &[]).await?; Ok(generated.asset) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 0274c8ee5..40255bda5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -122,6 +122,16 @@ pub(crate) fn read_manifest_for_project(root: &Path) -> Result Result { + let (manifest_path, manifest) = read_or_create_manifest(root)?; + if !manifest_storage_exists(&manifest_path)? { + return Err("本地项目尚未初始化".to_string()); + } + Ok(manifest) +} + pub(crate) fn ensure_manifest_has_seed_tasks( root: &Path, goal: Option<&str>, diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs index b39f0484e..6e80fdbba 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs @@ -11,6 +11,7 @@ mod input; mod observer; mod report; mod terminal_classification; +mod turn_dispatch; mod turn_wait; use commands::*; @@ -20,6 +21,7 @@ use input::*; use observer::*; use report::*; use terminal_classification::*; +use turn_dispatch::*; use turn_wait::*; #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/input.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/input.rs index 6a645a453..cc4bb3c7a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/input.rs @@ -10,6 +10,7 @@ pub(super) enum SwarmChatInput { Mcp, Goal(SwarmGoalCommand), InvalidGoal(String), + Resume, Quit, Message(String), } @@ -77,14 +78,12 @@ pub(super) fn run_game_creator_swarm_chat_with_input( return Err("parentAgentId 不能为空".to_string()); } let new_run_launch = resolve_swarm_new_run_launch(parent_agent_id, run_profile)?; - let project_path = root.display().to_string(); enforce_project_permission_policy(root, "conversation.read")?; enforce_project_permission_policy(root, "conversation.write")?; enforce_project_permission_policy(root, "agent.run_status")?; enforce_project_permission_policy(root, "agent.compact")?; enforce_project_permission_policy(root, "agent.resume")?; let _ = read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?; - let resumed = resume_game_creator_agent_background_tasks_at(root)?; let existing_runtimes = read_game_creator_agent_runtimes_at(root)?; writeln!(output, "Agent Swarm Chat") @@ -93,10 +92,6 @@ pub(super) fn run_game_creator_swarm_chat_with_input( .and_then(|_| writeln!(output, "输入 /help 查看命令。")) .map_err(|error| format!("写入终端失败:{error}"))?; print_conversation_history(root, parent_agent_id, output)?; - if !resumed.is_empty() { - writeln!(output, "[恢复扫描] 已检查 {} 个 Runtime。", resumed.len()) - .map_err(|error| format!("写入终端失败:{error}"))?; - } let active_conversation = read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?; @@ -112,42 +107,11 @@ pub(super) fn run_game_creator_swarm_chat_with_input( ) .is_some_and(runtime_is_busy); if matching_parent_is_busy { - writeln!(output, "[恢复] 检测到未收束 Runtime,继续观察现有任务。") - .map_err(|error| format!("写入终端失败:{error}"))?; - let before = active_conversation; - let session_id = before - .session_id - .as_deref() - .ok_or_else(|| "父 Agent 当前 Session 缺失".to_string())?; - let parent_run_id = - swarm_parent_runtime(parent_agent_id, session_id, run_profile, &existing_runtimes) - .map(|runtime| runtime.state.run_id.as_str()) - .unwrap_or_default(); - let mut conversation_baseline = - new_swarm_turn_conversation_baseline(before.messages.len(), parent_run_id); - capture_recovered_swarm_assistant_at( - root, - parent_agent_id, - session_id, - &mut conversation_baseline, - )?; - let mut observer = SwarmRuntimeObserver::default(); - let outcome = wait_for_swarm_turn( - root, - parent_agent_id, - session_id, - run_profile, - conversation_baseline, - input, + writeln!( output, - &mut observer, - SWARM_CHAT_POLL_INTERVAL, - SWARM_CHAT_SETTLE_WINDOW, - )?; - if outcome == SwarmTurnOutcome::Quit { - return print_swarm_chat_exit(output); - } - print_turn_outcome(outcome, output)?; + "[恢复扫描] 检测到未收束 Runtime;输入 /resume 继续观察,新消息会进入该 run 的 steer 队列。" + ) + .map_err(|error| format!("写入终端失败:{error}"))?; } loop { @@ -201,6 +165,26 @@ pub(super) fn run_game_creator_swarm_chat_with_input( print_turn_outcome(outcome, output)?; } SwarmChatInput::InvalidGoal(error) => print_swarm_goal_error(output, &error)?, + SwarmChatInput::Resume => { + let before = + read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?; + let session_id = before + .session_id + .as_deref() + .ok_or_else(|| "父 Agent 当前 Session 缺失".to_string())?; + if handle_swarm_resume_turn( + root, + parent_agent_id, + session_id, + run_profile, + before.messages.len(), + input, + output, + )? == SwarmChatFlow::Exit + { + return Ok(()); + } + } SwarmChatInput::Quit => { writeln!( output, @@ -214,129 +198,21 @@ pub(super) fn run_game_creator_swarm_chat_with_input( read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?; let session_id = before .session_id - .clone() + .as_deref() .ok_or_else(|| "父 Agent 当前 Session 缺失".to_string())?; - let mut observer = SwarmRuntimeObserver::seed(root)?; - if let Some(goal) = - read_game_creator_agent_goal_at(root, parent_agent_id, &session_id)? - { - match goal.status.as_str() { - AGENT_GOAL_STATUS_ACTIVE => { - let steer_id = format!("swarm-goal-steer-{}", unix_millis()); - let result = steer_game_creator_agent_runtime_task( - project_path.clone(), - parent_agent_id.to_string(), - session_id.clone(), - goal.run_id.clone(), - steer_id.clone(), - message, - Some(run_profile.to_string()), - )?; - writeln!( - output, - "[Goal 已追加] run={} steer={} providerInterrupted={}", - goal.run_id, steer_id, result.provider_interrupted - ) - .map_err(|error| format!("写入终端失败:{error}"))?; - let conversation_baseline = new_swarm_turn_conversation_baseline( - before.messages.len(), - &goal.run_id, - ); - let outcome = wait_for_swarm_turn( - root, - parent_agent_id, - &session_id, - run_profile, - conversation_baseline, - input, - output, - &mut observer, - SWARM_CHAT_POLL_INTERVAL, - SWARM_CHAT_SETTLE_WINDOW, - )?; - if outcome == SwarmTurnOutcome::Quit { - return print_swarm_chat_exit(output); - } - print_turn_outcome(outcome, output)?; - continue; - } - AGENT_GOAL_STATUS_PAUSE_REQUESTED | AGENT_GOAL_STATUS_PAUSED => { - print_swarm_goal_error( - output, - "当前 Goal 已暂停;请先输入 /goal resume。", - )?; - continue; - } - AGENT_GOAL_STATUS_CLEARING => { - print_swarm_goal_error(output, "当前 Goal 正在清理,暂不接受新消息。")?; - continue; - } - AGENT_GOAL_STATUS_NEEDS_RECONCILIATION => { - print_swarm_goal_error( - output, - "当前 Goal 需要人工 reconciliation,暂不接受新消息。", - )?; - continue; - } - AGENT_GOAL_STATUS_COMPLETED | AGENT_GOAL_STATUS_CLEARED => {} - status => { - print_swarm_goal_error( - output, - &format!("当前 Goal 状态未知,已阻止发送:{status}"), - )?; - continue; - } - } - } - let requested_run_id = format!("swarm-{parent_agent_id}-{}", unix_millis()); - let started = match new_run_launch { - SwarmNewRunLaunch::ProjectSupervisor { - source, - run_profile, - } => start_game_creator_supervisor_background_task_for_session_at( - root, - Some(&session_id), - &message, - &requested_run_id, - source, - run_profile, - )?, - SwarmNewRunLaunch::ExplicitParentDebug => { - start_game_creator_agent_runtime_task( - project_path.clone(), - parent_agent_id.to_string(), - Some(session_id.clone()), - message, - requested_run_id.clone(), - )? - } - }; - writeln!( - output, - "[已投递] agent={} session={} run={}", - parent_agent_id, started.state.session_id, requested_run_id - ) - .map_err(|error| format!("写入终端失败:{error}"))?; - let conversation_baseline = new_swarm_turn_conversation_baseline( - before.messages.len(), - &started.state.run_id, - ); - let outcome = wait_for_swarm_turn( + if handle_swarm_user_turn( root, parent_agent_id, - &session_id, + session_id, run_profile, - conversation_baseline, + new_run_launch, + &message, input, output, - &mut observer, - SWARM_CHAT_POLL_INTERVAL, - SWARM_CHAT_SETTLE_WINDOW, - )?; - if outcome == SwarmTurnOutcome::Quit { - return print_swarm_chat_exit(output); + )? == SwarmChatFlow::Exit + { + return Ok(()); } - print_turn_outcome(outcome, output)?; } } } @@ -433,6 +309,7 @@ pub(super) fn parse_swarm_chat_input(input: &str) -> Option { "/history" => SwarmChatInput::History, "/compact" => SwarmChatInput::Compact, "/mcp" => SwarmChatInput::Mcp, + "/resume" => SwarmChatInput::Resume, "/quit" | "/exit" => SwarmChatInput::Quit, value => SwarmChatInput::Message(value.to_string()), }) @@ -481,6 +358,7 @@ pub(super) fn print_swarm_chat_help(output: &mut W) -> Result<(), Stri .and_then(|_| writeln!(output, "/history 查看父 Agent 当前 Session 历史")) .and_then(|_| writeln!(output, "/compact 压缩父 Agent 当前空闲 Session 历史")) .and_then(|_| writeln!(output, "/mcp 查看 Runner MCP server 与工具目录")) + .and_then(|_| writeln!(output, "/resume 继续观察当前 Session 的未收束 Runtime")) .and_then(|_| writeln!(output, "/goal <目标> 启动当前 Session 的持久 Goal")) .and_then(|_| writeln!(output, "/goal 查看当前 Goal")) .and_then(|_| writeln!(output, "/goal status 查看当前 Goal")) diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs index e0988d9c2..834f98ff0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs @@ -213,6 +213,32 @@ fn cross_profile_steer_is_rejected_before_persistent_side_effects() { fs::remove_dir_all(root).ok(); } +#[test] +fn natural_language_is_not_preclassified_by_cli_keywords() { + for message in [ + "你是谁", + "项目在哪里", + "实现登录页并运行测试", + "不要运行任何命令,只解释架构", + "你能做什么,并顺便修复这个问题", + "继续刚才的任务", + ] { + assert_eq!( + parse_swarm_chat_input(message), + Some(SwarmChatInput::Message(message.to_string())), + "{message} must enter the unified Agent interaction loop" + ); + } +} + +#[test] +fn resume_is_an_explicit_control_command() { + assert_eq!( + parse_swarm_chat_input("/resume"), + Some(SwarmChatInput::Resume) + ); +} + #[test] fn parent_runtime_matching_is_scoped_to_requested_profile() { let mut standard = runtime("running", "planning", 0); diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs new file mode 100644 index 000000000..590d66d95 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs @@ -0,0 +1,421 @@ +use super::*; + +pub(super) fn handle_swarm_user_turn( + root: &Path, + parent_agent_id: &str, + session_id: &str, + run_profile: &str, + new_run_launch: SwarmNewRunLaunch<'_>, + message: &str, + input: &Receiver, + output: &mut W, +) -> Result { + let before = + read_local_conversation_for_session_at(root, Some(parent_agent_id), Some(session_id))?; + if let Some(goal) = read_game_creator_agent_goal_at(root, parent_agent_id, session_id)? { + match goal.status.as_str() { + AGENT_GOAL_STATUS_ACTIVE => { + return steer_and_wait_for_swarm_turn( + root, + parent_agent_id, + session_id, + run_profile, + &goal.run_id, + message, + before.messages.len(), + input, + output, + "Goal 已追加", + ); + } + AGENT_GOAL_STATUS_PAUSE_REQUESTED | AGENT_GOAL_STATUS_PAUSED => { + print_swarm_goal_error(output, "当前 Goal 已暂停;请先输入 /goal resume。")?; + return Ok(SwarmChatFlow::Continue); + } + AGENT_GOAL_STATUS_CLEARING => { + print_swarm_goal_error(output, "当前 Goal 正在清理,暂不接受新消息。")?; + return Ok(SwarmChatFlow::Continue); + } + AGENT_GOAL_STATUS_NEEDS_RECONCILIATION => { + print_swarm_goal_error( + output, + "当前 Goal 需要人工 reconciliation,暂不接受新消息。", + )?; + return Ok(SwarmChatFlow::Continue); + } + AGENT_GOAL_STATUS_COMPLETED | AGENT_GOAL_STATUS_CLEARED => {} + status => { + print_swarm_goal_error( + output, + &format!("当前 Goal 状态未知,已阻止发送:{status}"), + )?; + return Ok(SwarmChatFlow::Continue); + } + } + } + + let runtimes = read_game_creator_agent_runtimes_at(root)?; + if let Some(runtime) = swarm_parent_runtime(parent_agent_id, session_id, run_profile, &runtimes) + .filter(|runtime| runtime_is_busy(runtime)) + { + return steer_and_wait_for_swarm_turn( + root, + parent_agent_id, + session_id, + run_profile, + &runtime.state.run_id, + message, + before.messages.len(), + input, + output, + "运行中输入已排队", + ); + } + + let action = if game_creator_agent_uses_interaction_kernel(parent_agent_id) { + let Some(runtime_lock) = + try_acquire_game_creator_agent_runtime_task_lock(root, parent_agent_id)? + else { + let current_runtimes = read_game_creator_agent_runtimes_at(root)?; + if let Some(runtime) = + swarm_parent_runtime(parent_agent_id, session_id, run_profile, ¤t_runtimes) + .filter(|runtime| runtime_is_busy(runtime)) + { + return steer_and_wait_for_swarm_turn( + root, + parent_agent_id, + session_id, + run_profile, + &runtime.state.run_id, + message, + before.messages.len(), + input, + output, + "运行中输入已排队", + ); + } + return Err(format!( + "Agent 交互锁已被占用但没有可识别的活动 Runtime:{parent_agent_id}" + )); + }; + let action_result = + decide_interaction_action(root, parent_agent_id, session_id, message, output); + let persistence_result = match &action_result { + Ok(AgentInteractionAction::Reply(reply)) => { + persist_swarm_reply(root, parent_agent_id, session_id, message, reply) + } + Ok(AgentInteractionAction::ProjectLocation) => { + let reply = format!("当前 CLI 会话绑定的项目目录是:{}", root.display()); + writeln!(output, "陶泥儿> {reply}") + .map_err(|error| format!("写入终端失败:{error}")) + .and_then(|_| { + persist_swarm_reply(root, parent_agent_id, session_id, message, &reply) + }) + } + _ => Ok(()), + }; + spawn_next_game_creator_agent_background_task_drain_with_lock( + root, + parent_agent_id, + runtime_lock, + ); + persistence_result?; + action_result? + } else { + AgentInteractionAction::Execute + }; + writeln!(output, "[意图] {}", action.label()) + .map_err(|error| format!("写入终端失败:{error}"))?; + match action { + AgentInteractionAction::Reply(reply) => { + debug_assert!(!reply.trim().is_empty()); + Ok(SwarmChatFlow::Continue) + } + AgentInteractionAction::ProjectLocation => Ok(SwarmChatFlow::Continue), + AgentInteractionAction::Execute => start_and_wait_for_swarm_turn( + root, + parent_agent_id, + session_id, + run_profile, + new_run_launch, + message, + before.messages.len(), + input, + output, + ), + AgentInteractionAction::Resume => handle_swarm_resume_turn( + root, + parent_agent_id, + session_id, + run_profile, + before.messages.len(), + input, + output, + ), + } +} + +fn decide_interaction_action( + root: &Path, + parent_agent_id: &str, + session_id: &str, + message: &str, + output: &mut W, +) -> Result { + writeln!(output, "[决策] Agent 正在判断直接回复或调用持久能力。") + .map_err(|error| format!("写入终端失败:{error}"))?; + let mut printed_chars = 0usize; + let mut reply_started = false; + let mut protocol_buffered = false; + let action = + tauri::async_runtime::block_on(decide_game_creator_agent_interaction_turn_for_session_at( + root, + parent_agent_id, + session_id, + message, + |delta| { + if protocol_buffered { + return; + } + let trimmed = delta.accumulated_text.trim_start(); + if !reply_started && (trimmed.starts_with('{') || trimmed.starts_with("```")) { + protocol_buffered = true; + return; + } + if delta.accumulated_text.len() <= printed_chars { + return; + } + if !reply_started { + let _ = write!(output, "陶泥儿> "); + reply_started = true; + } + let chunk = &delta.accumulated_text[printed_chars..]; + let _ = write!(output, "{chunk}"); + let _ = output.flush(); + printed_chars = delta.accumulated_text.len(); + }, + ))?; + if let AgentInteractionAction::Reply(reply) = &action { + if reply.len() > printed_chars { + if !reply_started { + write!(output, "陶泥儿> ").map_err(|error| format!("写入终端失败:{error}"))?; + } + write!(output, "{}", &reply[printed_chars..]) + .map_err(|error| format!("写入终端失败:{error}"))?; + } + writeln!(output).map_err(|error| format!("写入终端失败:{error}"))?; + } else if reply_started { + writeln!(output).map_err(|error| format!("写入终端失败:{error}"))?; + } + Ok(action) +} + +fn persist_swarm_reply( + root: &Path, + parent_agent_id: &str, + session_id: &str, + message: &str, + reply: &str, +) -> Result<(), String> { + with_agent_conversation_session_lane_at( + root, + parent_agent_id, + "Swarm interaction 回复落盘", + || { + append_local_conversation_message_for_session_at( + root, + Some(parent_agent_id), + Some(session_id), + LocalConversationMessage { + role: "user".to_string(), + content: message.to_string(), + agent_id: Some(parent_agent_id.to_string()), + }, + )?; + append_local_conversation_message_for_session_at( + root, + Some(parent_agent_id), + Some(session_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: reply.to_string(), + agent_id: Some(parent_agent_id.to_string()), + }, + )?; + Ok(()) + }, + ) +} + +fn steer_and_wait_for_swarm_turn( + root: &Path, + parent_agent_id: &str, + session_id: &str, + run_profile: &str, + run_id: &str, + message: &str, + previous_message_count: usize, + input: &Receiver, + output: &mut W, + label: &str, +) -> Result { + require_external_agent_runner_for_cli_runtime_write(root)?; + let steer_id = format!("swarm-steer-{}", unix_millis()); + let result = steer_game_creator_agent_runtime_task( + root.display().to_string(), + parent_agent_id.to_string(), + session_id.to_string(), + run_id.to_string(), + steer_id.clone(), + message.to_string(), + Some(run_profile.to_string()), + )?; + writeln!( + output, + "[{label}] run={} steer={} providerInterrupted={}", + run_id, steer_id, result.provider_interrupted + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + let conversation_baseline = + new_swarm_turn_conversation_baseline(previous_message_count, run_id); + wait_and_print_swarm_turn( + root, + parent_agent_id, + session_id, + run_profile, + conversation_baseline, + input, + output, + ) +} + +fn start_and_wait_for_swarm_turn( + root: &Path, + parent_agent_id: &str, + session_id: &str, + run_profile: &str, + new_run_launch: SwarmNewRunLaunch<'_>, + task: &str, + previous_message_count: usize, + input: &Receiver, + output: &mut W, +) -> Result { + require_external_agent_runner_for_cli_runtime_write(root)?; + let requested_run_id = format!("swarm-{parent_agent_id}-{}", unix_millis()); + let started = match new_run_launch { + SwarmNewRunLaunch::ProjectSupervisor { + source, + run_profile, + } => start_game_creator_supervisor_background_task_for_session_at( + root, + Some(session_id), + task, + &requested_run_id, + source, + run_profile, + )?, + SwarmNewRunLaunch::ExplicitParentDebug => start_game_creator_agent_runtime_task( + root.display().to_string(), + parent_agent_id.to_string(), + Some(session_id.to_string()), + task.to_string(), + requested_run_id.clone(), + )?, + }; + writeln!( + output, + "[已投递] agent={} session={} run={}", + parent_agent_id, started.state.session_id, requested_run_id + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + let conversation_baseline = + new_swarm_turn_conversation_baseline(previous_message_count, &started.state.run_id); + wait_and_print_swarm_turn( + root, + parent_agent_id, + session_id, + run_profile, + conversation_baseline, + input, + output, + ) +} + +pub(super) fn handle_swarm_resume_turn( + root: &Path, + parent_agent_id: &str, + session_id: &str, + run_profile: &str, + previous_message_count: usize, + input: &Receiver, + output: &mut W, +) -> Result { + require_external_agent_runner_for_cli_runtime_write(root)?; + let resumed = resume_game_creator_agent_background_tasks_at(root)?; + if !resumed.is_empty() { + writeln!(output, "[恢复扫描] 已检查 {} 个 Runtime。", resumed.len()) + .map_err(|error| format!("写入终端失败:{error}"))?; + } + let current_runtimes = read_game_creator_agent_runtimes_at(root)?; + let Some(parent) = + swarm_parent_runtime(parent_agent_id, session_id, run_profile, ¤t_runtimes) + .filter(|runtime| runtime_is_busy(runtime)) + else { + writeln!(output, "[恢复] 当前 Session 没有可恢复的运行任务。") + .map_err(|error| format!("写入终端失败:{error}"))?; + return Ok(SwarmChatFlow::Continue); + }; + writeln!( + output, + "[恢复] 继续观察 run={} status={} phase={}", + parent.state.run_id, parent.state.status, parent.state.phase + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + let mut conversation_baseline = + new_swarm_turn_conversation_baseline(previous_message_count, &parent.state.run_id); + capture_recovered_swarm_assistant_at( + root, + parent_agent_id, + session_id, + &mut conversation_baseline, + )?; + wait_and_print_swarm_turn( + root, + parent_agent_id, + session_id, + run_profile, + conversation_baseline, + input, + output, + ) +} + +fn wait_and_print_swarm_turn( + root: &Path, + parent_agent_id: &str, + session_id: &str, + run_profile: &str, + conversation_baseline: SwarmTurnConversationBaseline, + input: &Receiver, + output: &mut W, +) -> Result { + let mut observer = SwarmRuntimeObserver::seed(root)?; + let outcome = wait_for_swarm_turn( + root, + parent_agent_id, + session_id, + run_profile, + conversation_baseline, + input, + output, + &mut observer, + SWARM_CHAT_POLL_INTERVAL, + SWARM_CHAT_SETTLE_WINDOW, + )?; + if outcome == SwarmTurnOutcome::Quit { + print_swarm_chat_exit(output)?; + return Ok(SwarmChatFlow::Exit); + } + print_turn_outcome(outcome, output)?; + Ok(SwarmChatFlow::Continue) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs index 7da85b9d2..ebd997c09 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs @@ -279,6 +279,10 @@ pub(super) fn wait_for_swarm_turn( recovery_scan_required = true; } SwarmChatInput::InvalidGoal(error) => print_swarm_goal_error(output, &error)?, + SwarmChatInput::Resume => { + writeln!(output, "[恢复] 当前已经在观察这个 Runtime。") + .map_err(|error| format!("写入终端失败:{error}"))?; + } SwarmChatInput::Message(message) => { if let Some(parent) = runtimes.iter().find(|runtime| { runtime.state.agent_id == parent_agent_id diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs index b9214b44b..eb88f284c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs @@ -67,7 +67,11 @@ fn visual_specialist_prompts_require_real_registered_image_deliveries() { "canvas.asset_generate", "assets/art-spritesheet.png", "assetKind=art-spritesheet", + "该动作本身就是当前 revision 的验证", + "随后只需调用 asset.list", "asset.list", + "不得运行 game.static_smoke 或 preview.validate", + "不得编辑 game/index.html", "不得把计划写完当成 completed", ] { assert!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs index 9ae1e5fac..968ae7694 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs @@ -645,6 +645,102 @@ fn valid_autonomous_initial_responsibility_actions_for_test() -> Vec AgentRuntimeToolAction { + AgentRuntimeToolAction { + tool: "agent.delegate".to_string(), + reason: Some("委派美术 Agent 生成首版精灵图".to_string()), + input: serde_json::json!({ + "agentId": "art-asset-plan", + "task": "生成首版游戏美术资源并写入项目资产目录。", + "acceptanceCriteria": [ + "使用画布生成接口产出可直接用于游戏的精灵图", + "生成结果必须登记为项目本地资产" + ], + "expectedArtifacts": expected_artifacts, + "repairOfDelegationId": null, + "runId": null + }), + } +} + +#[tokio::test] +async fn supervisor_autonomous_initial_art_responsibility_requires_canonical_spritesheet_artifact() +{ + let root = unique_project_path(); + let config_dir = unique_project_path(); + fs::create_dir_all(&config_dir).expect("create isolated runtime config dir"); + fs::write( + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::json!({ + "editorApi": { + "baseUrl": "https://editor.example.test", + "apiKey": "editor-runtime-key" + } + }) + .to_string(), + ) + .expect("write runtime config with editor API key"); + let _config_guard = use_test_runtime_config_dir(config_dir.clone()); + init_local_game_project_at( + &root, + "project-supervisor-autonomous-art-contract", + "自主构建首批美术交付合同测试", + ) + .expect("project init"); + let run_id = "supervisor-autonomous-art-contract-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous Supervisor profile"); + let runtime = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "建立程序、质量与美术首批职责。", + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + "准备首批美术交付合同", + Vec::new(), + ) + .expect("start autonomous Supervisor runtime"); + let mut actions = valid_autonomous_initial_responsibility_actions_for_test(); + actions.push(autonomous_art_responsibility_action_for_test(&[ + "assets/art-preview.png", + ])); + let plan = supervisor_collaboration_plan_for_test(actions); + let revision = read_game_creator_agent_runtime_project_revision(&root) + .expect("read art contract project revision"); + + let preparation = prepare_game_creator_agent_runtime_provider_action_batch( + &root, + &runtime, + &runtime.current_task, + &plan, + &[], + &revision, + &"e".repeat(64), + ) + .await + .expect("preflight art responsibility without canonical spritesheet"); + let AgentRuntimeProviderActionBatchPreparation::Blocked(observation) = preparation else { + panic!("art responsibility without canonical spritesheet must be blocked"); + }; + assert_eq!(observation.tool, "runtime.collaboration_policy"); + assert_eq!(observation.status, "blocked"); + let detail = observation.detail.expect("art responsibility block detail"); + assert!(detail.contains("art-asset-plan")); + assert!(detail.contains("assets/art-spritesheet.png")); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); +} + fn native_supervisor_responsibility_plan_response_for_test( call_prefix: &str, actions: &[AgentRuntimeToolAction], @@ -705,7 +801,7 @@ fn captured_supervisor_function_names_for_test(request: &str) -> BTreeSet>(), + BTreeSet::from(["code-prototype", "quality-review", "art-asset-plan"]) + ); + assert!(!root + .join(SUPERVISOR_COLLABORATION_POLICY_RELATIVE_PATH) + .exists()); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); +} + +#[test] +fn supervisor_autonomous_game_build_augments_existing_project_policy_with_required_art_delegate() { + let root = unique_project_path(); + let config_dir = unique_project_path(); + fs::create_dir_all(&config_dir).expect("create isolated runtime config dir"); + fs::write( + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::json!({ + "editorApi": { + "baseUrl": "https://editor.example.test", + "apiKey": "editor-runtime-key" + } + }) + .to_string(), + ) + .expect("write runtime config with editor API key"); + let _config_guard = use_test_runtime_config_dir(config_dir.clone()); + init_local_game_project_at( + &root, + "project-supervisor-autonomous-existing-policy-art", + "自主构建已有策略美术协作测试", + ) + .expect("project init"); + write_supervisor_collaboration_policy_at(&root, SupervisorCollaborationPolicy::default()) + .expect("write default project collaboration policy"); + let run_id = "supervisor-autonomous-existing-policy-art-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous supervisor profile"); + + let resolution = resolve_supervisor_collaboration_policy_for_run_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("resolve augmented project collaboration policy"); + assert_eq!(resolution.source, "project-policy-unbound"); + assert_eq!(resolution.project_policy_status, "current"); + assert_eq!(resolution.policy.min_static_delegates, 1); + assert_eq!( + resolution + .policy + .required_static_agent_ids + .iter() + .map(String::as_str) + .collect::>(), + BTreeSet::from(["art-asset-plan"]) + ); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); +} + +#[test] +fn supervisor_autonomous_game_build_with_editor_api_key_and_existing_art_asset_skips_art_delegate() +{ + let root = unique_project_path(); + let config_dir = unique_project_path(); + fs::create_dir_all(&config_dir).expect("create isolated runtime config dir"); + fs::write( + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::json!({ + "editorApi": { + "baseUrl": "https://editor.example.test", + "apiKey": "editor-runtime-key" + } + }) + .to_string(), + ) + .expect("write runtime config with editor API key"); + let _config_guard = use_test_runtime_config_dir(config_dir.clone()); + init_local_game_project_at( + &root, + "project-supervisor-autonomous-existing-art-collaboration", + "自主构建已有美术资源协作策略测试", + ) + .expect("project init"); + register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); + let run_id = "supervisor-autonomous-existing-art-collaboration-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous supervisor profile"); + + let resolution = resolve_supervisor_collaboration_policy_for_run_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("resolve autonomous collaboration policy with existing art asset"); + assert_eq!(resolution.source, "autonomous-run-default"); + assert_eq!(resolution.project_policy_status, "absent"); + assert_eq!( + resolution.policy.required_initial_wave, + SupervisorInitialCollaborationWave::Static + ); + assert_eq!(resolution.policy.min_static_delegates, 2); + assert_eq!( + resolution.policy.required_static_agent_ids, + vec!["code-prototype".to_string(), "quality-review".to_string()] + ); + assert!(!root + .join(SUPERVISOR_COLLABORATION_POLICY_RELATIVE_PATH) + .exists()); + + fs::write(root.join("assets/art-spritesheet.png"), b"not-a-png") + .expect("corrupt canonical art fixture"); + let corrupt_run_id = "supervisor-autonomous-corrupt-art-collaboration-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + corrupt_run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous supervisor profile for corrupt art"); + let corrupt_resolution = resolve_supervisor_collaboration_policy_for_run_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + corrupt_run_id, + ) + .expect("resolve autonomous collaboration policy with corrupt art asset"); + assert_eq!(corrupt_resolution.policy.min_static_delegates, 3); + assert!(corrupt_resolution + .policy + .required_static_agent_ids + .iter() + .any(|agent_id| agent_id == "art-asset-plan")); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs index db60bb232..f0fd36f0c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs @@ -156,17 +156,13 @@ fn agent_runtime_context_bundle_preserves_project_verification_gate_evidence() { &loaded.observations, ); assert!(project_verification_completion_blocker(&retained).is_none()); - let blocker = project_verification_completion_blocker_at( + assert!(project_verification_completion_blocker_at( &root, "design-director", "design-verification-context-run", &retained, ) - .expect("live project revision must override old bundle evidence"); - assert!(blocker - .detail - .as_deref() - .is_some_and(|detail| detail.contains("currentRevision=2"))); + .is_none()); fs::remove_dir_all(root).ok(); } @@ -2019,7 +2015,7 @@ fn project_verification_gate_scopes_credential_to_exact_agent_run_and_revision() .expect("read agent B gate"); assert_eq!(agent_a_gate.verified_revision, Some(1)); assert_eq!(agent_b_gate.verified_revision, Some(2)); - assert!(project_verification_completion_blocker_at(&root, "agent-a", "run-a", &[]).is_some()); + assert!(project_verification_completion_blocker_at(&root, "agent-a", "run-a", &[]).is_none()); assert!(project_verification_completion_blocker_at(&root, "agent-b", "run-b", &[]).is_none()); fs::remove_dir_all(root).ok(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 96c850e44..449645312 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -2332,6 +2332,18 @@ fn spawn_barrier_mock_llm_server( fn spawn_mock_external_canvas_api_server_with_capture( expected_requests: usize, request_sender: Option>, +) -> String { + spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( + expected_requests, + request_sender, + None, + ) +} + +fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( + expected_requests: usize, + request_sender: Option>, + generation_response_gate: Option>, ) -> String { let listener = bind_test_tcp_listener("mock canvas api bind"); let base_url = format!( @@ -2433,6 +2445,7 @@ fn spawn_mock_external_canvas_api_server_with_capture( }) .to_string(); std::thread::spawn(move || { + let mut generation_response_gate = generation_response_gate; for _ in 0..expected_requests { let (mut stream, _) = listener.accept().expect("mock canvas api accept"); let mut request_buffer = [0_u8; 8192]; @@ -2456,6 +2469,10 @@ fn spawn_mock_external_canvas_api_server_with_capture( ("application/json", project_body.as_bytes().to_vec()) } else if request.starts_with("POST /api/external/v1/editor/images/generations ") { assert!(normalized_request.contains("authorization: bearer ")); + if let Some(gate) = generation_response_gate.take() { + gate.recv_timeout(Duration::from_secs(5)) + .expect("release mock canvas generation response"); + } ("application/json", generation_body.as_bytes().to_vec()) } else if request.starts_with( "GET /api/external/v1/assets/read-url?objectKey=generated%2Fcanvas%2Fhero.png ", @@ -2463,7 +2480,7 @@ fn spawn_mock_external_canvas_api_server_with_capture( assert!(normalized_request.contains("authorization: bearer ")); ("application/json", read_body.as_bytes().to_vec()) } else if request.starts_with("GET /signed/hero.png ") { - ("image/png", b"fake-png".to_vec()) + ("image/png", valid_test_png_bytes()) } else { ("text/plain", b"not found".to_vec()) }; @@ -2495,6 +2512,17 @@ fn spawn_mock_external_canvas_generation_api_server( spawn_mock_external_canvas_api_server_with_capture(5, request_sender) } +fn spawn_mock_external_canvas_generation_api_server_with_gate( + request_sender: mpsc::Sender, + generation_response_gate: mpsc::Receiver<()>, +) -> String { + spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( + 5, + Some(request_sender), + Some(generation_response_gate), + ) +} + fn spawn_mock_external_canvas_generation_failure_server() -> String { let listener = bind_test_tcp_listener("mock canvas api bind"); let base_url = format!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index ee2111673..77a2d3711 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -640,6 +640,26 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { .observations .iter() .any(|item| item.contains("canvas.asset_generate:ok · 已生成美术素材:"))); + let revision = read_game_creator_agent_runtime_project_revision(&root) + .expect("read generated art revision") + .revision; + let gate = read_game_creator_agent_runtime_verification_gate( + &root, + "art-asset-plan", + "art-generate-run", + ) + .expect("read generated art verification gate"); + assert_eq!(gate.mutation_revision, Some(revision)); + assert_eq!(gate.verified_revision, Some(revision)); + assert_eq!( + gate.last_mutation_tool.as_deref(), + Some("canvas.asset_generate") + ); + assert_eq!( + gate.last_verification_tool.as_deref(), + Some("canvas.asset_generate") + ); + assert_eq!(gate.last_verification_status.as_deref(), Some("passed")); let manifest: Value = serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) .expect("manifest json"); @@ -650,7 +670,7 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { assert_eq!(asset["localPath"], "assets/art-spritesheet.png"); assert_eq!( fs::read(root.join(asset["localPath"].as_str().unwrap())).unwrap(), - b"fake-png" + valid_test_png_bytes() ); let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); assert!(agent_db.contains("\"recordType\":\"canvas.asset_generate\"")); @@ -685,6 +705,104 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { fs::remove_dir_all(config_dir).ok(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_manifest() { + let root = unique_project_path(); + let config_dir = unique_project_path(); + let (request_sender, request_receiver) = mpsc::channel(); + let (generation_release_sender, generation_release_receiver) = mpsc::channel(); + let canvas_base_url = spawn_mock_external_canvas_generation_api_server_with_gate( + request_sender, + generation_release_receiver, + ); + init_local_game_project_at(&root, "project-canvas-request-lock", "月光厨房") + .expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow asset generation"); + fs::create_dir_all(&config_dir).expect("runtime config dir"); + fs::write( + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::json!({ + "editorApi": { + "baseUrl": canvas_base_url, + "apiKey": "editor-runtime-key" + } + }) + .to_string(), + ) + .expect("write runtime config"); + let request_root = root.clone(); + let request_config_dir = config_dir.clone(); + let request = std::thread::spawn(move || { + let _config_guard = use_test_runtime_config_dir(request_config_dir); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build canvas request runtime") + .block_on(request_platform_art_asset_with_options_for_test( + &request_root, + "生成首版花园素材", + &PlatformArtAssetGenerationOptions { + output_path: Some("assets/art-spritesheet.png".to_string()), + asset_kind: "art-spritesheet".to_string(), + asset_label: "游戏首版核心美术素材".to_string(), + ..PlatformArtAssetGenerationOptions::default() + }, + )) + }); + + let mut saw_generation_request = false; + for _ in 0..3 { + let captured = request_receiver + .recv_timeout(Duration::from_secs(15)) + .expect("canvas request before generation response"); + if captured.starts_with("POST /api/external/v1/editor/images/generations ") { + saw_generation_request = true; + break; + } + } + assert!(saw_generation_request); + let project_lock = acquire_project_write_lock(&root, "test.canvas.external-request") + .expect("external canvas request must not hold project lock"); + create_manifest_task_at( + &root, + "concurrent-manifest-task", + "网络请求期间写入的并行任务", + GameCreationAppAgentGroup::Code, + "Code", + GameCreationAppTaskStatus::Pending, + Vec::new(), + Vec::new(), + Vec::new(), + ) + .expect("write manifest while external request waits"); + drop(project_lock); + generation_release_sender + .send(()) + .expect("release mock generation response"); + request + .join() + .expect("join external request") + .expect("prepare external canvas asset"); + + let manifest = read_existing_manifest_for_project(&root).expect("read manifest after request"); + assert!(manifest + .tasks + .iter() + .any(|task| task.id == "concurrent-manifest-task")); + assert!(!root.join("assets/art-spritesheet.png").exists()); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); +} + #[tokio::test] async fn generate_local_game_draft_fails_after_max_passes_without_final_artifacts() { let root = unique_project_path(); @@ -1356,7 +1474,7 @@ async fn sync_canvas_project_assets_downloads_external_resources() { assert!(result.import_root.starts_with("assets/canvas-sync/")); assert_eq!( fs::read(&result.assets[0].absolute_path).unwrap(), - b"fake-png" + valid_test_png_bytes() ); let manifest: Value = @@ -1388,6 +1506,8 @@ async fn generate_platform_art_asset_downloads_and_registers_external_image() { let root = unique_project_path(); let config_dir = unique_project_path(); let base_url = spawn_mock_external_canvas_generation_api_server(None); + init_local_game_project_at(&root, "project-platform-art", "未命名游戏原型") + .expect("project init"); fs::create_dir_all(&config_dir).expect("runtime config dir"); fs::write( config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), @@ -1429,7 +1549,10 @@ async fn generate_platform_art_asset_downloads_and_registers_external_image() { .asset .local_path .starts_with("assets/canvas-generated/")); - assert_eq!(fs::read(&result.asset.absolute_path).unwrap(), b"fake-png"); + assert_eq!( + fs::read(&result.asset.absolute_path).unwrap(), + valid_test_png_bytes() + ); assert_eq!(result.resource_id.as_deref(), Some("resource-1")); assert_eq!(result.asset_object_id.as_deref(), Some("asset-object-1")); assert_eq!(result.task_id.as_deref(), Some("task-1")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs index aaa22475b..c35c4fabc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs @@ -69,6 +69,14 @@ fn autonomous_game_build_profile_auto_grants_only_scoped_build_actions() { .denied_tools .iter() .any(|tool| tool == GAME_CREATOR_USER_INPUT_REQUEST_TOOL)); + assert!(policy + .denied_tools + .iter() + .any(|tool| tool == "canvas.asset_generate")); + assert!(!policy + .auto_tools + .iter() + .any(|tool| tool == "canvas.asset_generate")); for tool in [ "project.git_commit", "command.exec", @@ -126,10 +134,45 @@ fn autonomous_game_build_profile_auto_grants_only_scoped_build_actions() { && reason.contains("不能等待人工确认") )); + let art_run_id = "autonomous-profile-art-run"; + let art_link = AgentRuntimeTaskLink { + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some(run_id.to_string()), + delegation_id: Some("autonomous-profile-art-delegation".to_string()), + }; + let art_binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + "art-asset-plan", + art_run_id, + "agent-delegate", + None, + Some(&art_link), + ) + .expect("bind autonomous art profile"); + let art_policy = agent_runtime_tool_policy_snapshot_for_run_at( + &root, + "art-asset-plan", + art_run_id, + Some(&art_binding.profile), + Some(&art_binding.binding_fingerprint), + ) + .expect("autonomous art policy snapshot"); + assert!(art_policy + .auto_tools + .iter() + .any(|tool| tool == "canvas.asset_generate")); + assert!(!art_policy + .denied_tools + .iter() + .any(|tool| tool == "canvas.asset_generate")); + write_project_permission_policy_at( &root, ProjectPermissionPolicy { - denied_commands: vec!["file.write".to_string()], + denied_commands: vec![ + "file.write".to_string(), + "canvas.asset_generate".to_string(), + ], confirm_commands: ProjectPermissionPolicy::default().confirm_commands, agent_policies: BTreeMap::new(), }, @@ -163,6 +206,14 @@ fn autonomous_game_build_profile_auto_grants_only_scoped_build_actions() { .auto_tools .iter() .any(|candidate| candidate == "file.write")); + assert!(policy + .denied_tools + .iter() + .any(|candidate| candidate == "canvas.asset_generate")); + assert!(!policy + .auto_tools + .iter() + .any(|candidate| candidate == "canvas.asset_generate")); fs::remove_dir_all(root).ok(); } @@ -2791,6 +2842,16 @@ async fn autonomous_game_build_verified_revision_forces_response_only_delivery() "game.static_smoke", true, ); + advance_project_revision_for_test( + &root, + "art-asset-plan", + "autonomous-verified-delivery-concurrent-art", + "canvas.asset_generate", + ); + assert!( + project_verification_completion_blocker_at(&root, "code-prototype", child_run_id, &[],) + .is_none() + ); let observations = vec![ verification_gate_observation("file.patch", "ok", "已更新 game/index.html"), verification_gate_observation( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs index bb365e551..f3f490fd0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs @@ -1,5 +1,132 @@ use super::*; +fn runtime_task_json_line(run_id: &str, status: &str, phase: &str) -> String { + serde_json::json!({ + "schemaVersion": AGENT_RUNTIME_SCHEMA_VERSION, + "agentId": "design-director", + "taskId": "design-director", + "sessionId": "agent-session-design-director", + "runId": run_id, + "source": "test", + "task": "验证 Runtime task JSONL 读取", + "status": status, + "phase": phase, + "currentAction": "等待测试", + "updatedAt": 1, + }) + .to_string() +} + +#[test] +fn runtime_task_reader_ignores_only_unterminated_truncated_tail() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("create runtime task fixture directory"); + let path = root.join("tasks.jsonl"); + let complete = runtime_task_json_line("complete-run", "pending", "queued"); + fs::write( + &path, + format!("{complete}\n{{\"schemaVersion\":\"{AGENT_RUNTIME_SCHEMA_VERSION}\""), + ) + .expect("write truncated runtime task tail"); + + let records = read_all_game_creator_agent_runtime_tasks(&path) + .expect("unterminated truncated tail should be ignored"); + + assert_eq!(records.len(), 1); + assert_eq!(records[0].run_id, "complete-run"); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn runtime_task_reader_rejects_malformed_middle_line() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("create runtime task fixture directory"); + let path = root.join("tasks.jsonl"); + let first = runtime_task_json_line("first-run", "pending", "queued"); + let last = runtime_task_json_line("last-run", "completed", "completed"); + fs::write(&path, format!("{first}\n{{\"runId\":\n{last}")) + .expect("write malformed middle runtime task line"); + + let error = read_all_game_creator_agent_runtime_tasks(&path) + .expect_err("malformed middle line must fail closed"); + + assert!(error.contains("第 2 行 JSON 无效"), "{error}"); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn runtime_task_reader_rejects_newline_terminated_malformed_tail() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("create runtime task fixture directory"); + let path = root.join("tasks.jsonl"); + let complete = runtime_task_json_line("complete-run", "pending", "queued"); + fs::write(&path, format!("{complete}\n{{\"runId\":\n")) + .expect("write terminated malformed runtime task tail"); + + let error = read_all_game_creator_agent_runtime_tasks(&path) + .expect_err("newline-terminated malformed tail must fail closed"); + + assert!(error.contains("第 2 行 JSON 无效"), "{error}"); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn runtime_task_reader_rejects_unterminated_non_truncated_syntax_error() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("create runtime task fixture directory"); + let path = root.join("tasks.jsonl"); + let complete = runtime_task_json_line("complete-run", "pending", "queued"); + fs::write(&path, format!("{complete}\nnot-json")) + .expect("write unterminated invalid runtime task tail"); + + let error = read_all_game_creator_agent_runtime_tasks(&path) + .expect_err("unterminated syntax error must not be treated as a truncated record"); + + assert!(error.contains("第 2 行 JSON 无效"), "{error}"); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn runtime_task_reader_rejects_unknown_status_and_phase() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("create runtime task fixture directory"); + let path = root.join("tasks.jsonl"); + for (status, phase, expected) in [ + ("unknown-status", "queued", "status 无效"), + ("pending", "unknown-phase", "phase 无效"), + ] { + fs::write( + &path, + runtime_task_json_line("invalid-schema-run", status, phase), + ) + .expect("write invalid runtime task schema"); + let error = read_all_game_creator_agent_runtime_tasks(&path) + .expect_err("unknown status or phase must fail closed"); + assert!(error.contains(expected), "{error}"); + } + fs::remove_dir_all(root).ok(); +} + +#[test] +fn runtime_task_reader_accepts_rare_persisted_phases() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("create runtime task fixture directory"); + let path = root.join("tasks.jsonl"); + let brief = runtime_task_json_line("brief-run", "running", "brief"); + let parent_link_missing = + runtime_task_json_line("parent-link-missing-run", "failed", "parent-link-missing"); + fs::write(&path, format!("{brief}\n{parent_link_missing}\n")) + .expect("write rare persisted task phases"); + + let records = read_all_game_creator_agent_runtime_tasks(&path) + .expect("known persisted task phases must remain readable"); + + assert_eq!(records.len(), 2); + assert_eq!(records[0].phase, "brief"); + assert_eq!(records[1].phase, "parent-link-missing"); + fs::remove_dir_all(root).ok(); +} + #[test] fn parallel_jsonl_appends_keep_records_line_delimited() { let root = unique_project_path(); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c52fbca90..40e3869be 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5282,3 +5282,22 @@ - 决策:`autonomous-game-build` initial wave 中,程序任务必须非只读且 `expectedArtifacts` 包含 `game/index.html`;质量任务必须显式只读、不得修改项目且 `expectedArtifacts=[]`。Provider 计划解析、batch prepare 与 durable batch 恢复均重验同一合同;Provider action batch 升级为 v3,仅 v3 应用新职责,升级前 v2 collaboration batch 与 v1 contractless batch 继续按原 fingerprint 和合同恢复。只读 specialist 的计划只允许纯读取与状态观察,任何文件、revision、命令、任务、记忆、黑板、资产或委派副作用都在执行前拒绝,格式修复只保留 `respond_to_user`。非只读 specialist 只有本人 run 已产生 mutation 且对应 revision 验证通过后才能回复;ready 未认领或 claim 未 observed 时只允许先执行 `agent.run_status`。 - 语义边界:只读识别接受明确只读审查/验收和不得修改指令,不再把任意“只读”子串视为只读合同。`非只读 / 不要只读 / not read-only` 必须保持可修改;否则模型照抄修复提示中的“非只读实现任务”会永久触发同一格式修复错误。 - 验收:阻塞终审修复前 Rust 串行全量为 `1163 passed / 5 ignored / 0 failed`;补入只读写入与 v3/v2/v1 恢复回归后共运行 1169 项并以退出码 `0` 完成,其中 5 项真实浏览器环境用例 ignored。Provider `148/148`、collaboration `142/142`、swarm CLI `37/37`、autonomous `24/24` 和 App Surface `294/294` 通过。当前树确定性 Runner + Chrome 为 `17/17` lifecycle、revision `0 -> 2`、试玩 `37/37`。最新独立真实外部轮次单次输入后立即 EOF,一个原始专业任务失败后由唯一 repair 恢复,父 Supervisor completed,revision `0 -> 6`、`game/index.html=8080 bytes`、真实 Chrome `37/37`、唯一 Supervisor assistant;`88` 个 lifecycle 全部 terminal,`75 completed / 13 failed` 和 `12` 条 durable retry audit 保留真实失败证据并自行恢复,终局人工输入、open lifecycle、sidecar、reconciliation、重复与泄漏均为 `0`,Runner、项目和隔离 AppData 自动清理。 + +## 2026-07-24 Supervisor 与部门 Director 使用统一 Interaction Loop + +- 背景:`--swarm-chat` 曾在模型调用前用字符串包含判断选择 Chat / Execute / Resume,否定句、复合请求和未列入词表的工作请求都会误路由;busy Runtime 期间的裸聊天还可能绕过 Agent lane 并与原 run 交错写同一 Session。 +- 决策:删除自然语言关键词分类和硬编码自然语言直答。Project Supervisor 与角色目录中 `role.id=director` 的六个部门负责人使用统一 interaction loop;自然语言回复与 `project_location / runtime_execute / runtime_resume` 都来自同一次 Provider turn 的直接文本或原生 function tool。叶子专业 Agent 保持合同执行者,不接入该外层决策能力。 +- Canonical 输入:`runtime_execute` 不允许模型提交 task 参数,真正入队始终使用用户原始消息,避免模型改写时丢失否定、范围和验收条件。非原生 tool Provider 使用同构严格 JSON envelope 适配;模型只能提出 intention,不能选择 runId、越过权限或直接执行项目副作用。 +- 并发与 Runner:`SwarmChat` 恢复为 External Runner 写入口,启动前必须显式使用项目外 AppData。已有 active Goal 或 busy Runtime 时,新输入只进入同 run durable steer;空闲 direct reply 的 user / assistant 在 Agent Session lane 内成对落盘。`/resume` 是显式控制命令,不能再由“继续”等字符串特判。 +- 扩展边界:首版 interaction capability 由一个定义同时派生工具名、描述、schema 和 dispatch kind,作为后续统一 Tool Registry 的窄入口。现有 Runtime Store、Tool Host、Goal、delegation、sandbox、revision、verification、finalization 和 exactly-once 保持自研且不迁入 Prompt 或 Skill;本轮不引入 Pi Node sidecar,也不宣称已完成全量工具 registry、PromptSection 或 Cargo crate 拆分。 +- Provider 兼容:真实 OpenAI-compatible smoke 发现部分网关会在纯文本回复中返回 `tool_calls: null`;`platform-llm` 将该字段按缺省空列表解析,并保留真实工具调用数组语义。 +- 验证:interaction parser `7/7`、swarm CLI `39/39`、Runner/config 门禁回归和 `platform-llm` null-tool-calls 回归通过。隔离 AppData 的真实 Provider 连续验证了身份直接回复、否定执行的架构解释、模型选择 `project_location` 和模型选择 `runtime_execute`;执行轮产生 `[已投递]` 后以 `turn.report outcome=settled`、busy/pending/reconciliation 均为 `0` 收束。 + +## 2026-07-24 自主构建按画布配置强制生成首版美术素材 + +- 背景:`agc:test:chat` 已把正式 AppData 中的 `editorApi.baseUrl / editorApi.apiKey` 私有复制到隔离配置,但 `autonomous-game-build` 缺省首批只固定 `code-prototype / quality-review`,因此真实运行可以只交付自包含 `game/index.html`,完全不调用已配置的 External Editor API。 +- 决策:当有效运行时配置中的 `editorApi.apiKey` 非空且项目还缺少规范首版美术素材时,缺省 Supervisor 协作策略把 `art-asset-plan` 加入首批必需静态 Agent,与程序和只读质量审查同批委派;已有自定义项目协作策略只补入这一美术职责,不额外注入缺省的程序和质量职责。美术委派必须是非只读生成任务,`expectedArtifacts` 必须包含 `assets/art-spritesheet.png`;继续复用既有专业完成门禁,只有真实图片存在、manifest 登记为 `canvas / image/* / art-spritesheet` 后才能完成。已有同路径、同 kind、`canvas` 来源且本地文件存在的有效素材时,后续修复轮不重复生成或扣费。 +- 自主权限:`canvas.asset_generate` 只在 `autonomous-game-build` profile 的 `design-foundation / art-asset-plan` 视觉职责中加入固定 auto-safe 白名单,使单次无人值守测试可以使用用户已经配置的画布 API Key;Supervisor、程序和其他非视觉 Agent 一律拒绝该工具,避免多个并行 Agent 对同一规范路径重复生成和扣费。普通 Runtime 仍沿用项目确认策略,显式 `deniedCommands` 在自主 profile 中也继续优先拒绝。Key 未配置时缺省首批仍保持程序与质量审查,不伪造画布生成能力或素材产物。 +- 并发边界:External Editor 项目、素材库、生图和下载请求都在项目锁外执行,请求阶段只能读取现有 manifest 快照,不能补 seed task 或重写 manifest;下载完成后先按声明的图片类型校验 PNG/JPEG/WebP/GIF 魔数,再取得项目写锁,复核协作边界与输出路径并提交文件、manifest、revision 和验证凭证。专业 Agent 的完成门禁要求同一 run 的 `verifiedRevision >= mutationRevision`,不能被并行 Agent 的后续全局 revision 误判为过期,也允许更晚 revision 的复验覆盖本人修改;当前全局 revision 的集成验证仍由 Project Supervisor 精确负责。 +- 复用边界:已有规范素材只有同时满足固定路径、`art-spritesheet / image/* / canvas` 登记、本地普通文件存在且 PNG 签名有效时才跳过生成;空文件、伪 PNG 或损坏占位必须重新进入美术委派。 +- 恢复边界:首批 policy snapshot、durable provider batch 与恢复校验继续绑定同一 required Agent 集合;格式修复必须根据当前 policy 补齐可选的 `art-asset-plan` 固定产物合同,不能只修复程序与质量委派后绕过美术交付。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 11ae44d5f..e9a8abcfb 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -88,6 +88,8 @@ V1.47 在只读工具边界和 batch v3/v2/v1 恢复终审修复后的最新独 2026-07-23 起,开发验收提供两个根级短入口。`npm run agc:test` 直接委托现有确定性可玩塔防 E2E,不复制 Runtime harness;`npm run agc:test:chat` 自动发现 Tauri identifier `world.genarrative.ai-game-creator` 对应 AppData,把 `game-creator.config.json` 和存在时的 `game-creator.config.local.json` 私有复制到单次 sentinel 隔离 AppData,绝不复制正式 Runner endpoint、lock、备份或其它文件,再创建带私有 sentinel 的一次性项目。LLM 状态检查和 `--swarm-chat --init --autonomous-game-build` 都只使用隔离 AppData,因此当前 debug 二进制指纹变化不会探测、退役或阻塞正在工作的正式客户端 Runner。用户只输入需求并发送 EOF;正常收束且存在 `game/index.html` 后,通过仅开发 CLI `--preview-serve` 复用正式 localhost preview server,自动打开固定形态的 loopback 试玩地址。预览按 `Ctrl+C` 结束后,脚本通过内部 `--runner-shutdown-if-idle` 只关闭已空闲的隔离 Runner,确认 endpoint 消失后再验证 sentinel 并清理隔离 AppData 和项目;隔离 Runner 仍有任务、退出失败、Swarm 未收束或预览启动失败时保留对应现场,不能强杀或误删。`--keep-project` 可主动保留项目但不额外保留已空闲的隔离配置;显式 `--project-dir` 永不删除,非空未初始化目录拒绝,`--config-dir` 只表示绝对配置来源目录,`--project-dir` 也只接受绝对路径。该人工入口用于快速体验,不能替代真实外部 Provider E2E 的完整生命周期、隐私和残留门禁。 +2026-07-24 起,`--swarm-chat` 的普通自然语言不再由 CLI 关键词表预分类为 Chat / Execute / Resume。Project Supervisor 与六个部门 Director 进入同一个轻量 interaction loop:模型可以直接回复,或从统一 capability registry 中选择 `project_location / runtime_execute / runtime_resume`;`runtime_execute` 不接收模型改写后的任务正文,宿主始终把原始用户消息提交给持久 Runtime。显式 `/status /goal /resume` 继续属于确定性控制面。匹配 Session 已有 busy Runtime 或 active Goal 时,新消息不启动旁路 Provider,也不直接改写同一 canonical Session,而是进入原 run 的 durable steer;所有 Runtime mutation 继续要求项目外 `--config-dir`、External Runner、Agent task lock、Session lane、权限、确认、revision、verification 和 finalization 门禁。该 interaction registry 是 Pi 风格小内核与可组合工具边界的第一步,不代表现有 Runtime 工具已经完成单一 registry 迁移,也不允许把安全不变量下放到 Prompt、Skill 或模型判断。 + 2026-07-15 起,Runtime V1.1 文档的“V1.17 单 Agent 持久计划”作为后台工具规划进度的新事实源。`submit_agent_tool_plan` 新增 nullable `planUpdate={explanation,steps[{step,status}]}`;步骤只接受 `pending / in_progress / completed`,最多 8 步且至多一个 `in_progress`。结构化计划一旦建立,legacy `plan` 只作旧协议 fallback;终态步骤必须保留,`planRevision` 只在真实变化时单调递增,工具 action 下标不得自动完成结构化步骤,存在未完成步骤时不得写最终回复或 completed。 V1.17 计划快照随 `game-creator-runtime-context-bundle.v3` 持久化,v2 在通过原身份、revision 和 verification gate 校验后从当前 Runtime state 补齐计划字段继续恢复;计划元数据本身不推进项目 revision、不改变 verification gate,也不触发项目权限确认。开发 UI 和 CLI 有界展示 revision、说明与完整 8 步;正式用户的 Supervisor 只展示完成数、当前步骤、等待对象、下一步和协作数量的紧凑摘要。恢复、same-run steer 和真实 Provider 的完整验收矩阵以 Runtime V1.17 章节为准;2026-07-16 已在当前 v5 context 上完成正式 `openai_chat / gpt-5.5` 的同 run steer + Runner 强杀恢复专项,门禁状态为 PASS。 @@ -650,6 +652,8 @@ game-project/ - 2026-07-16 起,同一 Runtime 文档的“V1.26 Provider 原生工具目录”作为 OpenAI-compatible planning 协议事实源。Chat / Responses 不再只广告 `submit_agent_tool_plan` 包装函数,而是直接提供 `update_agent_plan`、`respond_to_user`、全部内置 Runtime action 和动态 MCP function;每个函数使用独立 schema,Runtime 继续负责身份、权限、确认、沙箱、revision、验证、恢复与副作用防重放。Anthropic 与历史 fixture 保留 text JSON / wrapper 解析兼容,但新请求和 repair 不能静默降级。plan-only 是合法持久 checkpoint,未完成计划仍阻止最终化。 - 2026-07-16 V1.26 已完成真实验收:正式 `openai_chat / gpt-5.5` 的 `project-skill` suite 中 9/9 个成功工具计划与 6/6 个格式修复全部使用 `native_runtime_tools`,wrapper/text fallback 均为 0。Agent 自主读取匹配 Skill、只改唯一目标文件并完成 Agent/宿主双重验证;15 个 tool-plan 和 1 个 final-reply lifecycle 唯一闭合,最终 assistant/completed 各 1,重复、Skill 正文、API Key、诱饵、项目/配置路径和报告泄漏均为 0,隔离 Runner/AppData/项目完整清理。 - 2026-07-16 V1.26 后重新加强并复验 `goal-runtime`:Goal suite 现在把成功计划、repair、call metadata、wrapper/text fallback 和协议审计零 payload 纳入硬门禁。正式 `openai_chat / gpt-5.5` 最终复跑的成功计划 21/21、repair 17/17 全为 `native_runtime_tools`;Goal edit、旧动作失效、真实失败修复、pause、Runner 强杀、显式同 run resume、verification、finalization 和唯一回复全部 PASS,重复、重放、正文、密钥、诱饵与路径泄漏均为 0。Goal 阶段等待同时增加 terminal fail-fast,Provider transport failure 不再占满 30 分钟验收超时。 +- 2026-07-24 补充:`agc:test:chat / autonomous-game-build` 不再只因 `game/index.html` 可试玩就跳过已配置的画布能力。有效运行时配置存在 `editorApi.apiKey` 且项目尚无规范 `canvas / image/* / art-spritesheet` 本地素材时,缺省 Supervisor 首批协作固定加入 `art-asset-plan`,并要求真实交付 `assets/art-spritesheet.png`;该 Agent 继续通过 `canvas.asset_generate` 把结果同时写入 External Editor 画布、素材库、本地 assets 与 manifest。已有有效首版素材的后续修复轮不重复生成或扣费。该工具仅在自主构建 profile 的 `design-foundation / art-asset-plan` 视觉职责中作为固定 auto-safe 动作,Supervisor、程序和其他非视觉 Agent 一律拒绝,避免同一路径并发生成和重复扣费;普通模式和显式 deny 不变,Key 缺失时不伪造图片产物。 +- 同一轮程序与美术并行时,External Editor 网络请求和图片下载不能持有 `.agent/project.lock`,请求阶段也不能借 `read_manifest_for_project` 补 seed task 或重写 manifest;只允许读取已初始化 manifest 的只读快照,下载完成后先校验声明图片类型的文件魔数,再取得项目锁提交固定路径、资产登记、revision 和验证凭证。视觉专业 Agent 按同一 run 的 `verifiedRevision >= mutationRevision` 完成交付,不受其它专业 Agent 后续推进全局 revision 的影响,也允许在更晚 revision 复验;Supervisor 的静态自检和浏览器试玩仍必须精确覆盖最新全局 revision。已有自定义项目协作策略只补缺失的 `art-asset-plan`,不额外注入缺省程序/质量职责;规范素材复用额外要求本地文件具备有效 PNG 签名,损坏占位不能阻止重新生成。 - 2026-07-16 起,V1.27 只允许同一 Agent 把连续 2-3 个自动批准的严格只读 action 组成持久并行批次。首版白名单为 `memory.read / conversation.read / asset.list / project.search / project.diff / git.inspect / file.list / file.read / task.list`;写入、确认、命令/进程、验证、生成、MCP、Git commit、委派和回执认领仍按 Provider 顺序串行。批次用项目一致性锁形成 steer/Goal/取消的线性化边界,物理读取由独立工作线程重叠执行,observation/task/event/receipt/context 仍按 Provider action index 稳定投影;`executing` Runner 恢复只重放严格只读观察,`observed` 只补投影。 - V1.27 的 8 项确定性回归已证明:2 个读取真实非空重叠;第二个物理读取先结束时仍按 Provider 顺序投影;3 个动作 `executing` 恢复保持原 actionId;`observed` sidecar、部分公共投影、部分 context 和删除 sidecar 前故障都从持久前缀继续且零重复;确认策略阻止自动批次;取消、Goal/repository drift 阻止旧读取重放;steer 先获得锁时旧批次零执行,批次先获得锁时 steer 等到全部读取 observed 后才接受。正式 `openai_chat / gpt-5.5` 的隔离 `parallel-read` suite 同样 PASS:真实模型同轮提交 2 个独立 `project.search`,物理重叠 `10,969,247ns`,4/4 个成功工具计划与 2/2 个 repair 全为 `native_runtime_tools`,6 个 tool-plan 与 1 个 final-reply lifecycle 唯一闭合,最终 assistant/completed 各 1;重复、正文/API Key/诱饵/项目与配置路径泄漏均为 0,Runner/AppData/项目完整清理。V1.27 当前真实行为门禁为 PASS。 - 2026-07-16 起,Runtime V1.1 文档的“V1.28 Project Supervisor 合同委派与单回复收束”作为正式用户多 Agent 协作的当前编码事实源。`project-supervisor` 是正式用户唯一默认对话和最终回复 Agent;专业 Agent 与 child 只生成内部回执、摘要和证据,开发直调入口不改变该边界。 diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index 1b1460ed0..62ea4aa8d 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -451,7 +451,7 @@ struct ChatCompletionsMessage { #[serde(default)] content: Option, #[serde(default)] - tool_calls: Vec, + tool_calls: Option>, } #[derive(Deserialize)] @@ -2231,13 +2231,13 @@ fn extract_chat_tool_calls(choice: &ChatCompletionsChoice) -> Vec { choice .message .as_ref() - .map(|message| message.tool_calls.as_slice()) + .and_then(|message| message.tool_calls.as_deref()) .filter(|tool_calls| !tool_calls.is_empty()) .or_else(|| { choice .delta .as_ref() - .map(|message| message.tool_calls.as_slice()) + .and_then(|message| message.tool_calls.as_deref()) }) .unwrap_or_default() .iter() @@ -3362,7 +3362,7 @@ mod tests { MockResponse { status_line: "200 OK", content_type: "application/json; charset=utf-8", - body: r#"{"id":"chat_text","model":"gpt-5","choices":[{"message":{"content":"Chat 纯文本"},"finish_reason":"stop"}]}"#.to_string(), + body: r#"{"id":"chat_text","model":"gpt-5","choices":[{"message":{"content":"Chat 纯文本","reasoning_content":null,"tool_calls":null},"finish_reason":"stop","native_finish_reason":"stop"}]}"#.to_string(), extra_headers: Vec::new(), }, MockResponse {