diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index c53e4119a..88404d556 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -10,6 +10,7 @@ "build": "npm --prefix ../.. exec tauri -- build", "llm-status": "node scripts/run-cli-with-config.mjs --llm-status", "agent-task": "node scripts/run-cli-with-config.mjs --agent-task", + "swarm": "node scripts/run-cli-with-config.mjs --swarm-chat", "agent-run": "node scripts/run-cli-with-config.mjs --agent-run", "agent-run:smoke": "node scripts/smoke-agent-run-local-provider.mjs", "agent-runtime:real-e2e": "node scripts/agent-runtime-real-e2e.mjs", diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 06aefc2a9..6a90dafe8 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -383,6 +383,15 @@ if ( ); } +if ( + packageConfig.scripts?.swarm !== + 'node scripts/run-cli-with-config.mjs --swarm-chat' +) { + throw new Error( + 'AI game creator shell swarm must use client config before starting the interactive Agent runtime', + ); +} + if (tauriConfig.productName !== 'Genarrative AI Game Creator') { throw new Error('AI game creator shell productName drifted'); } @@ -713,6 +722,7 @@ for (const script of [ 'ai-game-creator-shell:dev-server', 'ai-game-creator-shell:build', 'ai-game-creator-shell:agent-task', + 'agc:swarm', 'ai-game-creator-shell:typecheck', 'ai-game-creator-shell:agent-run:smoke', 'ai-game-creator-shell:check', @@ -722,6 +732,13 @@ for (const script of [ } } +if ( + rootPackageConfig.scripts?.['agc:swarm'] !== + 'npm --prefix apps/ai-game-creator-shell run swarm --' +) { + throw new Error('root agc:swarm script must forward CLI args'); +} + if ( rootPackageConfig.scripts?.['ai-game-creator-shell:build'] !== 'npm --prefix apps/ai-game-creator-shell run build --' diff --git a/apps/ai-game-creator-shell/src-tauri/src/cli.rs b/apps/ai-game-creator-shell/src-tauri/src/cli.rs index 75027b10f..8d9a1f0a2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -14,6 +14,11 @@ pub(crate) enum CliCommand { task: String, initialize: bool, }, + SwarmChat { + project_path: PathBuf, + parent_agent_id: String, + initialize: bool, + }, AgentEnqueue { project_path: PathBuf, agent_id: String, @@ -54,6 +59,7 @@ impl CliCommand { matches!( self, Self::AgentTask { .. } + | Self::SwarmChat { .. } | Self::AgentEnqueue { .. } | Self::AgentConfirm { .. } | Self::AgentSteer { .. } @@ -72,6 +78,11 @@ impl CliCommand { initialize, .. } + | Self::SwarmChat { + project_path, + initialize, + .. + } | Self::AgentEnqueue { project_path, initialize, @@ -356,6 +367,25 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S prompt: prompt.to_string(), })); } + if args.first().map(String::as_str) == Some("--swarm-chat") { + let mut rest = args[1..].to_vec(); + let initialize = if let Some(index) = rest.iter().position(|arg| arg == "--init") { + rest.remove(index); + true + } else { + false + }; + if rest.len() != 2 || rest.iter().any(|value| value.trim().is_empty()) { + return Err( + "用法:--swarm-chat [--init] <本地项目绝对路径> ".to_string(), + ); + } + return Ok(Some(CliCommand::SwarmChat { + project_path: PathBuf::from(&rest[0]), + parent_agent_id: rest[1].trim().to_string(), + initialize, + })); + } if args.first().map(String::as_str) == Some("--agent-task") { let mut rest = args[1..].to_vec(); let initialize = if let Some(index) = rest.iter().position(|arg| arg == "--init") { @@ -534,6 +564,16 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { )) } } + CliCommand::SwarmChat { + project_path, + parent_agent_id, + initialize, + } => { + let project_path = canonicalize_cli_path(&project_path, "本地项目路径", initialize)?; + require_external_agent_runner_for_cli_runtime_write(&project_path)?; + initialize_cli_agent_project(&project_path, initialize)?; + run_game_creator_swarm_chat_at(&project_path, &parent_agent_id) + } CliCommand::AgentEnqueue { project_path, agent_id, @@ -807,4 +847,42 @@ mod tests { .expect_err("agent steer must require config dir"); assert!(error.contains("--config-dir")); } + + #[test] + fn parses_swarm_chat_and_requires_external_config_dir() { + let project_path = std::env::current_dir().expect("current directory"); + let mut command = parse_cli_command(&[ + "--swarm-chat".to_string(), + "--init".to_string(), + project_path.display().to_string(), + "code-prototype".to_string(), + ]) + .expect("parse swarm chat") + .expect("swarm chat command"); + + assert_eq!( + command, + CliCommand::SwarmChat { + project_path, + parent_agent_id: "code-prototype".to_string(), + initialize: true, + } + ); + assert!(command.requires_external_agent_runner()); + let error = prepare_cli_command_paths(&mut command, None) + .expect_err("swarm chat must require config dir"); + assert!(error.contains("--config-dir")); + } + + #[test] + fn swarm_chat_rejects_missing_or_extra_arguments() { + assert!(parse_cli_command(&["--swarm-chat".to_string()]).is_err()); + assert!(parse_cli_command(&[ + "--swarm-chat".to_string(), + "/tmp/game-project".to_string(), + "code-prototype".to_string(), + "extra".to_string(), + ]) + .is_err()); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 99bfa4cda..4966fbade 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -62,6 +62,7 @@ mod process_session_bridge; mod project; mod repository_context; mod runner; +mod swarm_cli; mod windows; use agent::*; @@ -82,6 +83,7 @@ use process_session::*; use project::*; use repository_context::*; use runner::*; +use swarm_cli::*; use windows::*; #[derive(Debug, Eq, PartialEq, Serialize)] 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 new file mode 100644 index 000000000..c95f96260 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs @@ -0,0 +1,851 @@ +use super::*; +use std::collections::{BTreeMap, BTreeSet}; +use std::io::{BufRead, Write}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; +use std::time::{Duration, Instant}; + +const SWARM_CHAT_POLL_INTERVAL: Duration = Duration::from_millis(250); +const SWARM_CHAT_SETTLE_WINDOW: Duration = Duration::from_millis(1_500); +const SWARM_CHAT_HISTORY_LIMIT: usize = 50; + +#[derive(Debug, Eq, PartialEq)] +enum SwarmChatInput { + Help, + Agents, + Status, + History, + Quit, + Message(String), +} + +#[derive(Default)] +struct SwarmRuntimeObserver { + state_signatures: BTreeMap, + seen_events: BTreeSet, + handled_confirmations: BTreeSet, +} + +#[derive(Debug, Eq, PartialEq)] +enum SwarmTurnOutcome { + Settled, + NeedsReconciliation(Vec), + Quit, +} + +enum SwarmInputEvent { + Line(String), + Eof, + Error(String), +} + +enum SwarmConfirmationResolution { + None, + Handled, + Quit, +} + +pub(crate) fn run_game_creator_swarm_chat_at( + root: &Path, + parent_agent_id: &str, +) -> Result<(), String> { + let (input_tx, input_rx) = mpsc::channel(); + std::thread::spawn(move || { + let stdin = std::io::stdin(); + let mut input = stdin.lock(); + loop { + let mut line = String::new(); + match input.read_line(&mut line) { + Ok(0) => { + let _ = input_tx.send(SwarmInputEvent::Eof); + break; + } + Ok(_) => { + if input_tx + .send(SwarmInputEvent::Line(line.trim().to_string())) + .is_err() + { + break; + } + } + Err(error) => { + let _ = input_tx.send(SwarmInputEvent::Error(error.to_string())); + break; + } + } + } + }); + let stdout = std::io::stdout(); + let mut output = stdout.lock(); + run_game_creator_swarm_chat_with_input(root, parent_agent_id, &input_rx, &mut output) +} + +fn run_game_creator_swarm_chat_with_input( + root: &Path, + parent_agent_id: &str, + input: &Receiver, + output: &mut W, +) -> Result<(), String> { + let parent_agent_id = parent_agent_id.trim(); + if parent_agent_id.is_empty() { + return Err("parentAgentId 不能为空".to_string()); + } + 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.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") + .and_then(|_| writeln!(output, "项目:{}", root.display())) + .and_then(|_| writeln!(output, "父 Agent:{parent_agent_id}")) + .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}"))?; + } + + if runtimes_are_busy(&existing_runtimes) { + writeln!(output, "[恢复] 检测到未收束 Runtime,继续观察现有任务。") + .map_err(|error| format!("写入终端失败:{error}"))?; + 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())?; + let mut observer = SwarmRuntimeObserver::default(); + let outcome = wait_for_swarm_turn( + root, + parent_agent_id, + session_id, + before.messages.len(), + 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)?; + } + + loop { + write!(output, "\n你> ").map_err(|error| format!("写入终端失败:{error}"))?; + output + .flush() + .map_err(|error| format!("刷新终端失败:{error}"))?; + let Some(line) = receive_swarm_chat_line(input)? else { + writeln!(output, "\n已退出 Agent Swarm Chat。") + .map_err(|error| format!("写入终端失败:{error}"))?; + return Ok(()); + }; + let Some(command) = parse_swarm_chat_input(&line) else { + continue; + }; + match command { + SwarmChatInput::Help => print_swarm_chat_help(output)?, + SwarmChatInput::Agents => print_swarm_agents(root, output)?, + SwarmChatInput::Status => print_swarm_status(root, output)?, + SwarmChatInput::History => print_conversation_history(root, parent_agent_id, output)?, + SwarmChatInput::Quit => { + writeln!( + output, + "已退出 Agent Swarm Chat;后台 Runner 和已投递任务保持运行。" + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + return Ok(()); + } + SwarmChatInput::Message(message) => { + let before = + read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?; + let session_id = before + .session_id + .clone() + .ok_or_else(|| "父 Agent 当前 Session 缺失".to_string())?; + let mut observer = SwarmRuntimeObserver::seed(root)?; + let requested_run_id = format!("swarm-{parent_agent_id}-{}", unix_millis()); + let started = 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 outcome = wait_for_swarm_turn( + root, + parent_agent_id, + &session_id, + before.messages.len(), + 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)?; + } + } + } +} + +fn receive_swarm_chat_line(input: &Receiver) -> Result, String> { + match input.recv() { + Ok(SwarmInputEvent::Line(line)) => Ok(Some(line)), + Ok(SwarmInputEvent::Eof) | Err(_) => Ok(None), + Ok(SwarmInputEvent::Error(error)) => Err(format!("读取终端输入失败:{error}")), + } +} + +fn prompt_swarm_decision( + input: &Receiver, + output: &mut W, + prompt: &str, +) -> Result, String> { + write!(output, "{prompt}").map_err(|error| format!("写入终端失败:{error}"))?; + output + .flush() + .map_err(|error| format!("刷新终端失败:{error}"))?; + loop { + let Some(line) = receive_swarm_chat_line(input)? else { + return Err("确认输入已结束;待确认动作保持未处理".to_string()); + }; + match line.to_ascii_lowercase().as_str() { + "approve" | "yes" | "y" | "批准" => return Ok(Some(true)), + "reject" | "no" | "n" | "拒绝" => return Ok(Some(false)), + "/quit" | "/exit" => return Ok(None), + _ => { + write!(output, "请输入 approve 或 reject:") + .map_err(|error| format!("写入终端失败:{error}"))?; + output + .flush() + .map_err(|error| format!("刷新终端失败:{error}"))?; + } + } + } +} + +fn print_swarm_chat_exit(output: &mut W) -> Result<(), String> { + writeln!( + output, + "已退出 Agent Swarm Chat;后台 Runner 和已投递任务保持运行。" + ) + .map_err(|error| format!("写入终端失败:{error}")) +} + +fn parse_swarm_chat_input(input: &str) -> Option { + let input = input.trim(); + if input.is_empty() { + return None; + } + Some(match input { + "/help" => SwarmChatInput::Help, + "/agents" => SwarmChatInput::Agents, + "/status" => SwarmChatInput::Status, + "/history" => SwarmChatInput::History, + "/quit" | "/exit" => SwarmChatInput::Quit, + value => SwarmChatInput::Message(value.to_string()), + }) +} + +fn print_swarm_chat_help(output: &mut W) -> Result<(), String> { + writeln!(output, "/agents 查看静态 Agent 与动态 child") + .and_then(|_| writeln!(output, "/status 查看全部 Runtime 状态")) + .and_then(|_| writeln!(output, "/history 查看父 Agent 当前 Session 历史")) + .and_then(|_| writeln!(output, "/help 查看命令")) + .and_then(|_| writeln!(output, "/quit 退出终端观察客户端")) + .map_err(|error| format!("写入终端失败:{error}")) +} + +fn print_swarm_agents(root: &Path, output: &mut W) -> Result<(), String> { + writeln!(output, "静态 Agent:").map_err(|error| format!("写入终端失败:{error}"))?; + for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { + for role in group.roles { + writeln!( + output, + "- {} / {}: {} ({})", + group.label, role.role, role.task_id, role.id + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + } + } + let dynamic = read_game_creator_agent_runtimes_at(root)? + .into_iter() + .filter(|runtime| runtime.state.agent_id.starts_with("child-")) + .collect::>(); + if !dynamic.is_empty() { + writeln!(output, "动态隔离 Agent:").map_err(|error| format!("写入终端失败:{error}"))?; + for runtime in dynamic { + writeln!( + output, + "- {} <- {} run={} delegation={} status={}/{}", + runtime.state.agent_id, + runtime + .state + .parent_agent_id + .as_deref() + .unwrap_or("unknown"), + runtime.state.run_id, + runtime.state.delegation_id.as_deref().unwrap_or("unknown"), + runtime.state.status, + runtime.state.phase + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + } + } + Ok(()) +} + +fn print_swarm_status(root: &Path, output: &mut W) -> Result<(), String> { + let runtimes = read_game_creator_agent_runtimes_at(root)?; + for runtime in runtimes.iter().filter(|runtime| { + !runtime.state.run_id.is_empty() + || runtime.task_queue.pending > 0 + || runtime.task_queue.running > 0 + || runtime.task_queue.waiting_for_confirmation > 0 + }) { + print_runtime_state(&runtime.state, &runtime.task_queue, output)?; + } + if runtimes + .iter() + .all(|runtime| runtime.state.run_id.is_empty()) + { + writeln!(output, "当前没有 Agent Runtime 记录。") + .map_err(|error| format!("写入终端失败:{error}"))?; + } + Ok(()) +} + +fn print_conversation_history( + root: &Path, + parent_agent_id: &str, + output: &mut W, +) -> Result<(), String> { + let conversation = read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?; + writeln!( + output, + "[历史] session={} messages={}", + conversation.session_id.as_deref().unwrap_or("unknown"), + conversation.messages.len() + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + let start = conversation + .messages + .len() + .saturating_sub(SWARM_CHAT_HISTORY_LIMIT); + for message in &conversation.messages[start..] { + let label = if message.role == "assistant" { + "Agent" + } else if message.role == "user" { + "你" + } else { + message.role.as_str() + }; + writeln!(output, "{label}> {}", message.content) + .map_err(|error| format!("写入终端失败:{error}"))?; + } + Ok(()) +} + +fn wait_for_swarm_turn( + root: &Path, + parent_agent_id: &str, + session_id: &str, + previous_message_count: usize, + input: &Receiver, + output: &mut W, + observer: &mut SwarmRuntimeObserver, + poll_interval: Duration, + settle_window: Duration, +) -> Result { + let mut stable_since: Option = None; + let mut recovery_scan_required = true; + let mut last_runner_check = Instant::now(); + loop { + let runtimes = read_game_creator_agent_runtimes_at(root)?; + let changed = observer.print_changes(&runtimes, output)?; + if changed { + stable_since = None; + } + let mut reconciliation = swarm_reconciliation_agents(&runtimes); + if !reconciliation.is_empty() { + return Ok(SwarmTurnOutcome::NeedsReconciliation(reconciliation)); + } + match observer.resolve_confirmations(root, &runtimes, input, output)? { + SwarmConfirmationResolution::Handled => { + stable_since = None; + recovery_scan_required = true; + continue; + } + SwarmConfirmationResolution::Quit => return Ok(SwarmTurnOutcome::Quit), + SwarmConfirmationResolution::None => {} + } + if last_runner_check.elapsed() >= Duration::from_secs(2) { + let runner = read_external_agent_runner_status(); + last_runner_check = Instant::now(); + if runtimes_are_busy(&runtimes) && (!runner.enabled || !runner.running) { + reconciliation.push("external-runner".to_string()); + return Ok(SwarmTurnOutcome::NeedsReconciliation(reconciliation)); + } + } + if runtimes_are_busy(&runtimes) { + stable_since = None; + recovery_scan_required = true; + } else { + let since = stable_since.get_or_insert_with(Instant::now); + if since.elapsed() >= settle_window { + if recovery_scan_required { + writeln!( + output, + "[收束] Runtime 已空闲,检查待发布的 receipt / join。" + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + output + .flush() + .map_err(|error| format!("刷新终端失败:{error}"))?; + resume_game_creator_agent_background_tasks_at(root)?; + recovery_scan_required = false; + stable_since = Some(Instant::now()); + continue; + } + print_new_parent_reply( + root, + parent_agent_id, + session_id, + previous_message_count, + output, + )?; + return Ok(SwarmTurnOutcome::Settled); + } + } + match input.recv_timeout(poll_interval) { + Ok(SwarmInputEvent::Line(line)) => { + let Some(command) = parse_swarm_chat_input(&line) else { + continue; + }; + match command { + SwarmChatInput::Quit => return Ok(SwarmTurnOutcome::Quit), + SwarmChatInput::Help => print_swarm_chat_help(output)?, + SwarmChatInput::Agents => print_swarm_agents(root, output)?, + SwarmChatInput::Status => print_swarm_status(root, output)?, + SwarmChatInput::History => { + print_conversation_history(root, parent_agent_id, output)? + } + SwarmChatInput::Message(message) => { + if let Some(parent) = runtimes.iter().find(|runtime| { + runtime.state.agent_id == parent_agent_id + && matches!(runtime.state.status.as_str(), "pending" | "running") + }) { + 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(), + parent.state.session_id.clone(), + parent.state.run_id.clone(), + steer_id.clone(), + message, + )?; + writeln!( + output, + "[已追加] run={} steer={} providerInterrupted={}", + parent.state.run_id, steer_id, result.provider_interrupted + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + } else { + writeln!(output, "[暂未发送] 子 Agent 尚未收束,请稍后重发。") + .map_err(|error| format!("写入终端失败:{error}"))?; + } + stable_since = None; + recovery_scan_required = true; + } + } + } + Ok(SwarmInputEvent::Eof) | Err(RecvTimeoutError::Disconnected) => { + return Ok(SwarmTurnOutcome::Quit) + } + Ok(SwarmInputEvent::Error(error)) => return Err(format!("读取终端输入失败:{error}")), + Err(RecvTimeoutError::Timeout) => {} + } + } +} + +fn runtimes_are_busy(runtimes: &[AgentRuntimeResult]) -> bool { + runtimes.iter().any(|runtime| { + matches!( + runtime.state.status.as_str(), + "pending" | "running" | "waiting-for-confirmation" | "cancelling" + ) || runtime.state.phase == "needs-reconciliation" + || runtime.task_queue.pending > 0 + || runtime.task_queue.running > 0 + || runtime.task_queue.waiting_for_confirmation > 0 + }) +} + +fn swarm_reconciliation_agents(runtimes: &[AgentRuntimeResult]) -> Vec { + runtimes + .iter() + .filter(|runtime| { + runtime.state.phase == "needs-reconciliation" + || (runtime.state.status == "waiting-for-confirmation" + && runtime.state.pending_tool_action.is_none()) + }) + .map(|runtime| runtime.state.agent_id.clone()) + .collect() +} + +fn print_new_parent_reply( + root: &Path, + parent_agent_id: &str, + session_id: &str, + previous_message_count: usize, + output: &mut W, +) -> Result<(), String> { + let conversation = + read_local_conversation_for_session_at(root, Some(parent_agent_id), Some(session_id))?; + let reply = conversation + .messages + .iter() + .skip(previous_message_count) + .filter(|message| message.role == "assistant") + .next_back(); + if let Some(reply) = reply { + writeln!(output, "\nAgent> {}", reply.content) + .map_err(|error| format!("写入终端失败:{error}"))?; + } else { + writeln!(output, "[本轮结束] 父 Agent 未产生新的最终回复。") + .map_err(|error| format!("写入终端失败:{error}"))?; + } + Ok(()) +} + +fn print_turn_outcome(outcome: SwarmTurnOutcome, output: &mut W) -> Result<(), String> { + if let SwarmTurnOutcome::NeedsReconciliation(agent_ids) = outcome { + writeln!( + output, + "[已阻断] 以下 Agent 需要人工 reconciliation:{}", + agent_ids.join(", ") + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + } + Ok(()) +} + +impl SwarmRuntimeObserver { + fn seed(root: &Path) -> Result { + let mut observer = Self::default(); + for runtime in read_game_creator_agent_runtimes_at(root)? { + observer.state_signatures.insert( + runtime.state.agent_id.clone(), + runtime_state_signature(&runtime.state, &runtime.task_queue), + ); + for event in runtime.recent_events { + observer.seen_events.insert(runtime_event_key(&event)); + } + } + Ok(observer) + } + + fn print_changes( + &mut self, + runtimes: &[AgentRuntimeResult], + output: &mut W, + ) -> Result { + let mut changed = false; + for runtime in runtimes { + let has_runtime_history = !runtime.state.run_id.is_empty() + || runtime.task_queue.total > 0 + || !runtime.recent_events.is_empty(); + if has_runtime_history { + let signature = runtime_state_signature(&runtime.state, &runtime.task_queue); + if self.state_signatures.get(&runtime.state.agent_id) != Some(&signature) { + changed = true; + self.state_signatures + .insert(runtime.state.agent_id.clone(), signature); + print_runtime_state(&runtime.state, &runtime.task_queue, output)?; + } + } + for event in &runtime.recent_events { + if self.seen_events.insert(runtime_event_key(event)) { + changed = true; + writeln!( + output, + "[事件] {} {} {}/{} {}{}", + event.agent_id, + event.event_type, + event.status, + event.phase, + event.summary, + event + .detail + .as_deref() + .map(|detail| format!(" | {detail}")) + .unwrap_or_default() + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + } + } + } + Ok(changed) + } + + fn resolve_confirmations( + &mut self, + root: &Path, + runtimes: &[AgentRuntimeResult], + input: &Receiver, + output: &mut W, + ) -> Result { + for runtime in runtimes { + let Some(pending) = runtime.state.pending_tool_action.as_ref() else { + continue; + }; + let key = format!( + "{}:{}:{}", + runtime.state.agent_id, runtime.state.run_id, pending.action_id + ); + if self.handled_confirmations.contains(&key) { + continue; + } + writeln!( + output, + "\n[待确认] agent={} run={} action={} tool={}", + runtime.state.agent_id, runtime.state.run_id, pending.action_id, pending.tool + ) + .and_then(|_| { + if let Some(summary) = pending.input_summary.as_deref() { + writeln!(output, "输入摘要:{summary}") + } else { + Ok(()) + } + }) + .and_then(|_| write!(output, "输入 approve 或 reject:")) + .map_err(|error| format!("写入终端失败:{error}"))?; + let Some(decision) = prompt_swarm_decision(input, output, "")? else { + return Ok(SwarmConfirmationResolution::Quit); + }; + let project_path = root.display().to_string(); + if decision { + confirm_game_creator_agent_runtime_task( + project_path, + runtime.state.agent_id.clone(), + runtime.state.run_id.clone(), + pending.action_id.clone(), + "Agent Swarm Chat 终端批准".to_string(), + )?; + writeln!(output, "[已批准] {}", pending.action_id) + .map_err(|error| format!("写入终端失败:{error}"))?; + } else { + reject_game_creator_agent_runtime_task( + project_path, + runtime.state.agent_id.clone(), + runtime.state.run_id.clone(), + pending.action_id.clone(), + "Agent Swarm Chat 终端拒绝".to_string(), + )?; + writeln!(output, "[已拒绝] {}", pending.action_id) + .map_err(|error| format!("写入终端失败:{error}"))?; + } + self.handled_confirmations.insert(key); + return Ok(SwarmConfirmationResolution::Handled); + } + Ok(SwarmConfirmationResolution::None) + } +} + +fn print_runtime_state( + state: &AgentRuntimeState, + queue: &AgentRuntimeTaskQueueSummary, + output: &mut W, +) -> Result<(), String> { + let relation = state + .parent_agent_id + .as_deref() + .map(|parent| { + format!( + " parent={parent} delegation={}", + state.delegation_id.as_deref().unwrap_or("unknown") + ) + }) + .unwrap_or_default(); + writeln!( + output, + "[状态] {} {}/{} run={} queue={}/{}/{}{} | {}", + state.agent_id, + state.status, + state.phase, + state.run_id, + queue.pending, + queue.running, + queue.waiting_for_confirmation, + relation, + state.current_action + ) + .map_err(|error| format!("写入终端失败:{error}")) +} + +fn runtime_state_signature( + state: &AgentRuntimeState, + queue: &AgentRuntimeTaskQueueSummary, +) -> String { + format!( + "{}:{}:{}:{}:{}:{}:{}:{}:{}", + state.run_id, + state.status, + state.phase, + state.current_action, + state.updated_at, + queue.pending, + queue.running, + queue.waiting_for_confirmation, + queue.updated_at + ) +} + +fn runtime_event_key(event: &AgentRuntimeEvent) -> String { + format!( + "{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}", + event.agent_id, + event.task_id, + event.session_id, + event.run_id, + event.event_type, + event.action_id.as_deref().unwrap_or_default(), + event.status, + event.phase, + event.updated_at, + event.summary, + event.detail.as_deref().unwrap_or_default() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn runtime(status: &str, phase: &str, pending: u32) -> AgentRuntimeResult { + let state = serde_json::from_value::(serde_json::json!({ + "agentId": "code-prototype", + "runId": "run-test", + "status": status, + "phase": phase, + })) + .expect("deserialize runtime fixture"); + let mut task_queue = AgentRuntimeTaskQueueSummary::default(); + task_queue.pending = pending; + AgentRuntimeResult { + state, + session_path: String::new(), + event_path: String::new(), + task_path: String::new(), + task_queue, + recent_events: Vec::new(), + recent_tasks: Vec::new(), + } + } + + #[test] + fn parses_chat_commands_without_stealing_normal_messages() { + assert_eq!(parse_swarm_chat_input(" "), None); + assert_eq!( + parse_swarm_chat_input("/agents"), + Some(SwarmChatInput::Agents) + ); + assert_eq!(parse_swarm_chat_input("/exit"), Some(SwarmChatInput::Quit)); + assert_eq!( + parse_swarm_chat_input("让策划和程序并行检查玩法"), + Some(SwarmChatInput::Message( + "让策划和程序并行检查玩法".to_string() + )) + ); + } + + #[test] + fn swarm_stays_busy_for_active_queue_and_reconciliation() { + assert!(runtimes_are_busy(&[runtime("running", "planning", 0)])); + assert!(runtimes_are_busy(&[runtime("idle", "completed", 1)])); + assert!(runtimes_are_busy(&[runtime( + "failed", + "needs-reconciliation", + 0 + )])); + assert!(!runtimes_are_busy(&[runtime("idle", "completed", 0)])); + assert!(!runtimes_are_busy(&[runtime("failed", "failed", 0)])); + } + + #[test] + fn missing_confirmation_sidecar_is_reported_as_reconciliation() { + let broken = runtime("waiting-for-confirmation", "waiting-for-confirmation", 0); + assert_eq!( + swarm_reconciliation_agents(&[broken]), + vec!["code-prototype".to_string()] + ); + } + + #[test] + fn blank_agent_snapshots_do_not_reset_the_settle_window() { + let mut blank = runtime("idle", "idle", 0); + blank.state.run_id.clear(); + blank.state.updated_at = 100; + let mut observer = SwarmRuntimeObserver::default(); + let mut output = Vec::new(); + assert!(!observer + .print_changes(&[blank.clone()], &mut output) + .expect("observe first blank snapshot")); + blank.state.updated_at = 101; + assert!(!observer + .print_changes(&[blank], &mut output) + .expect("observe refreshed blank snapshot")); + assert!(output.is_empty()); + } + + #[test] + fn input_channel_preserves_lines_and_eof() { + let (tx, rx) = mpsc::channel(); + tx.send(SwarmInputEvent::Line("hello swarm".to_string())) + .expect("send line"); + tx.send(SwarmInputEvent::Eof).expect("send eof"); + assert_eq!( + receive_swarm_chat_line(&rx).expect("read line").as_deref(), + Some("hello swarm") + ); + assert_eq!(receive_swarm_chat_line(&rx).expect("read eof"), None); + } + + #[test] + fn event_deduplication_keeps_phase_and_detail_changes() { + let base = serde_json::json!({ + "agentId": "code-prototype", + "taskId": "task-1", + "sessionId": "session-1", + "runId": "run-1", + "eventType": "observation", + "status": "running", + "phase": "action", + "summary": "工具观察", + "detail": "第一条", + "updatedAt": 100, + }); + let first = serde_json::from_value::(base.clone()) + .expect("deserialize first event"); + let mut changed = base; + changed["phase"] = serde_json::json!("observation"); + changed["detail"] = serde_json::json!("第二条"); + let second = + serde_json::from_value::(changed).expect("deserialize second event"); + assert_ne!(runtime_event_key(&first), runtime_event_key(&second)); + } +} diff --git a/docs/project-memory/plans/【计划】AgentSwarm纯聊天验证入口-2026-07-14.md b/docs/project-memory/plans/【计划】AgentSwarm纯聊天验证入口-2026-07-14.md new file mode 100644 index 000000000..8d17ee21e --- /dev/null +++ b/docs/project-memory/plans/【计划】AgentSwarm纯聊天验证入口-2026-07-14.md @@ -0,0 +1,48 @@ +# Agent Swarm 纯聊天验证入口计划 + +更新时间:`2026-07-14` + +## 目标 + +提供一版不依赖正常客户端 GUI 的终端聊天入口,让开发者选择一个父 Agent,通过真实 Agent Runtime 验证静态委派、动态隔离子 Agent、并行执行、确认动作、回执汇总和多轮持久化。 + +## 范围 + +- 新增 `--swarm-chat [--init] <本地项目绝对路径> `。 +- 新增短命令 `npm run agc:swarm -- --config-dir <项目外 AppData 绝对路径> [--init] `。 +- 普通输入进入父 Agent background Runtime;不使用一次性 `--agent-chat`。 +- 复用 External Runner、active Session、现有 conversation、Agent 私有记忆、项目黑板、`agent.delegate`、`agent.spawn_isolated`、terminal receipt 和 all-join。 +- 展示全部 Agent 的状态、事件、父子身份和委派关系,并支持在终端批准或拒绝待确认动作。 +- 提供 `/help`、`/agents`、`/status`、`/history`、`/quit`。 + +## 非目标 + +- 不新增 Tauri 窗口、浏览器页面或本地 HTTP / SSE bridge。 +- 不新增 Provider 调用路径、Agent 数据库或 conversation 格式。 +- 不伪造 token streaming;首版展示 Runtime 状态 / 事件流和最终回复。 +- 不在终端退出时取消 Runner、run 或 Runner-owned process session。 + +## 实现步骤 + +1. 扩展 CLI command、参数解析和项目外 `--config-dir` 门禁。 +2. 新增独立终端 REPL 模块,复用项目初始化、Runtime resume、任务投递和 conversation 读取。 +3. 轮询全 Agent Runtime,按身份去重输出状态和事件;待确认时走现有 confirm / reject。 +4. 用“全 Runtime 非活跃 + 全队列为空 + 稳定观察窗口”判断一轮收束,再读取父 Agent 当前 Session 的新增 assistant 消息。 +5. 增加 npm 短命令、确定性测试、真实启动 smoke 和文档同步。 + +## 验收清单 + +- CLI parse、绝对路径、项目初始化、`--config-dir`、空输入、EOF 和 slash command。 +- 同一父 Agent 连续两轮使用同一 Session,退出重进后历史可见。 +- 两个静态 Agent 并行委派,多个动态 child 并行并形成唯一 all-join。 +- approve / reject 均能在终端完成,拒绝后父 Agent 能继续修正计划。 +- 父 Agent 暂时 idle、child 仍运行或 receipt 尚未认领时不提前结束。 +- Runner 或终端重启后恢复同一 run / session,不重放副作用。 +- 真实 Provider transcript 与 task / event / Agent DB / receipt / conversation 一致,最终父回复唯一且无密钥泄漏。 + +## 当前进度 + +- 已完成终端入口、短命令、Runtime 状态 / 事件输出、approve / reject、active Session 历史、same-run steer、活跃 `/quit`、启动 / 收束恢复扫描和 Runner 失联阻断。 +- 已用真实 `gpt-5.5` 观察到两个静态 Agent 并行、两条 child result、receipt 排队与重启恢复;终端确认、拒绝和活跃退出均生效。同一父 Session 连续 4 轮固定回复及 8 条消息历史恢复通过,最终双安静窗口收束返回 `FOURTH_OK`。 +- 未通过父 Agent 最终汇总:全量 `agent.run_status` 输出截断导致第一轮预算耗尽;第二轮 receipt continuation 未恢复原始只读目标并请求文件写入,已拒绝。 +- 待办:修复定向状态查询或 receipt continuation 目标恢复,复验唯一最终父回复;再补动态 isolated child 并行、唯一 all-join 和 Runner 强杀恢复。 diff --git a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md index 02a228a3c..9613f0875 100644 --- a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md +++ b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md @@ -654,6 +654,20 @@ V1.14 对标 `codex fork`,允许开发者从任意已有静态 Agent 会话创 确定性验收必须覆盖 active / archived / legacy 源、空会话、消息与 messageId 精确复制、源与分叉后续隔离、provenance 持久化、运行中父任务和委派 child 阻断、非 active 源任务阻断、损坏 task journal 失败关闭、默认 Session Runtime 入队与分叉线性化、未提交分叉文件不可见、非法源 ID、catalog 写入失败清理以及重复点击创建不同 Session。前端测试必须证明按钮调用精确源 Session、成功后加载复制历史并切换 active、后续消息写入新 Session 且源会话不变、归档源可分叉、Runtime 忙时按钮禁用。 +## V1.15 Agent Swarm 纯聊天验证入口 + +V1.15 新增不依赖 Tauri WebView 或正常客户端 GUI 的终端聊天入口,用于开发阶段直接验证多 Agent 协作。入口固定为 `--swarm-chat [--init] <本地项目绝对路径> `,推荐通过 `npm run agc:swarm -- --config-dir <项目外 AppData 绝对路径> [--init] ` 启动。它不是新的 Agent 实现:每条普通输入都投递给现有父 Agent background Runtime,继续由同一发布二进制的 External Runner 执行;不得退化到一次性 `--agent-chat`,也不得新建本地 HTTP 服务、旁路 Provider 客户端或第二套持久化。 + +- 首版复用父 Agent 当前 active Session;Runtime 继续把 user / assistant 写入 `.agent/conversations/agents//sessions/.jsonl`,静态委派、动态 `child-*`、私有记忆、项目黑板、durable action、verification gate 和 all-join 均沿用现有身份与恢复语义。`--init` 只复用现有项目初始化函数;Runtime 写命令仍强制显式传项目外 `--config-dir`。入口启动和一轮准备收束前都执行现有 resume / reconciliation 扫描,不能只凭 idle 快照跳过尚未发布的 receipt 或 join 修复。 +- 终端只提供聊天所需的轻量控制命令:`/help`、`/agents`、`/status`、`/history`、`/quit`。空闲时普通文本创建父 Agent 新 run;父 Agent run 仍处于 pending / running 时普通文本追加为同一 run steer,只有 child 忙而父 Agent 已终态时拒绝吞掉输入并要求稍后重发。确认动作在终端显示 Agent、run、action、tool 和安全摘要,并接受 `approve / reject`,分别调用现有 confirm / reject Runtime 路径,不能要求回到开发窗口。stdin 由独立读取线程投递,因此活跃 run 中 `/quit`、EOF、状态命令和 steer 仍可响应;退出只结束观察客户端。 +- 每轮轮询 `read_game_creator_agent_runtimes_at`,按 Agent / run / event 去重输出状态、phase、委派来源、父 Agent、delegationId、动态 child 和 join / receipt 事件。Provider token delta 当前没有经过 Runner RPC 暴露,首版只承诺 Runtime 状态与事件的持续输出以及持久化后的最终父 Agent 回复,禁止用拆字或延时打印伪装 token streaming。 +- 一轮只有在所有已发现 Runtime 都不处于 `pending / running / waiting-for-confirmation / cancelling / needs-reconciliation`,全部任务队列为空,并持续经过稳定观察窗口后才能收束。父 run 暂时 idle 但 delegated child 尚未终态、receipt 尚未入队或 all-join 尚未认领时不得提前返回。失败、取消和 reconciliation 要明确显示并保留项目现场,不自动重试副作用。 +- 终端退出只结束观察客户端,不终止 External Runner、已投递 run 或 Runner-owned process session;下次启动先调用现有 resume,再从 conversation 与 Runtime journal 恢复。首版只允许一个前台输入流,不承诺多个终端并发编辑同一 active Session。 + +确定性验收必须覆盖 CLI parse、项目绝对路径与 `--config-dir` 门禁、`--init`、空输入和 EOF、命令分流、历史恢复、连续两轮写入同一 Session、状态与事件去重、两个静态 Agent 并行委派、多个隔离 child 并行与唯一 all-join、confirm / reject、父 Agent receipt 汇总、稳定窗口不早退、Runner / 终端重启恢复以及失败与 reconciliation 显示。真实 Provider 验收必须保存一份脱敏 transcript,并以 task / event / Agent DB / receipt / conversation 的结构化事实证明并行、最终父回复唯一、副作用无重放和密钥零泄漏;未实际运行时只能标记未验收,不能凭确定性测试宣称 swarm 可用。 + +2026-07-14 首轮真实 `gpt-5.5` 验证已证明终端入口能够启动真实 External Runner、持久化父 Session、实时展示状态 / event / parent / delegation、并行运行 `design-foundation` 与 `balance-seed`,并在终端完成两次 `agent.delegate` approve、一次重复委派 reject、重启恢复、receipt 续跑、`file.write` reject 和活跃 run 中 `/quit`。独立收束复验在同一 `code-prototype` Session 连续完成 4 轮固定回复,重启后 `/history` 读取 8 条 user / assistant 消息,最后一轮按 `idle -> 安静窗口 -> receipt/join 恢复扫描 -> 安静窗口 -> 最终回复` 返回 `FOURTH_OK`。但端到端 swarm 汇总未通过:第一轮父 Agent 反复调用全量 `agent.run_status`,因输出截断无法看到目标 Agent,18 轮后 `budget-exhausted` 并压掉两条排队 receipt;第二轮按 receipt 模式续跑时,父 Agent 没有恢复原始“只读汇总”目标,转而读取项目并请求写 `game/balance.json`,已由终端拒绝。当前结论只能是“V1.15 验证入口可用、现有静态委派的父回执汇总策略未验收”,不得标记完整 Agent Swarm 通过;后续需修复定向状态查询或 receipt continuation 原目标恢复后再跑唯一最终父回复验收。动态 isolated child / all-join 也仍待通过该入口真实复验。 + ## 验收命令 - `npm run ai-game-creator-shell:typecheck` diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 71a660ef2..228615599 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -46,6 +46,8 @@ V1.11 的受保护仓库控制目录同时包含 `.git / .agent / .agents / .cod 2026-07-14 起,同一文档的“V1.14 Agent 会话分叉”补齐 `codex fork` 风格的开发会话分支。开发 Agent 窗口可从 active、archived 或 legacy Session 复制截至当前的持久 conversation,创建带来源记录的新 active Session;源会话不变,后续消息与 run 按新 Session 隔离。分叉不复制 Runtime / pending action / process session,不推进项目 revision,并在当前 Agent 或委派 child 未终态时拒绝执行;Session 变更与 Runtime 入队共用 per-Agent lane gate,损坏任务日志失败关闭,catalog 提交前的分叉文件不会被列表暴露。 +2026-07-14 起,同一文档的“V1.15 Agent Swarm 纯聊天验证入口”补充无 GUI 开发验收面。`npm run agc:swarm -- --config-dir <项目外AppData> [--init] ` 直接把每轮输入投递给真实父 Agent background Runtime,复用 External Runner、active Session、conversation、静态委派、动态隔离 child、私有记忆、项目黑板、确认策略和 all-join,不调用一次性 `--agent-chat`,不新建本地 HTTP 服务或平行数据库。终端持续显示全 Agent 状态、事件和父子 / 委派关系,提供 `/agents`、`/status`、`/history`、`/help`、`/quit` 以及 approve / reject;父 run 活跃时普通输入走 same-run steer,stdin channel 保证运行中仍可退出。入口启动和收束前执行恢复扫描,只有全 Runtime 非活跃、队列为空、恢复扫描无新增工作并通过稳定观察窗口后才输出绑定父 Session 的最后回复。首版没有 Runner Provider token delta,只承诺状态 / 事件实时输出和最终回复。真实 Provider 已证明入口、两个静态 Agent 并行、确认 / 拒绝、重启恢复和活跃退出,但父 Agent 的全量状态轮询与 receipt 原目标恢复仍导致汇总失败;当前不得宣称完整 swarm 已通过,动态 isolated child / all-join 也待复验。 + 2026-07-12 真实验收:发布 AppData 中的真实 `gpt-5.5` 已通过最终安全收紧后的 `llm-runtime` 套件,覆盖 Runner 强杀恢复且 run/session 身份稳定、仓库上下文、checkpoint/精确修改、失败命令诊断与修复复验、6 套确认生命周期、项目验证、桌面与移动非空画布证据、3 个隔离实例并行和唯一 all-join;95 条 task、161 条 event、137 条 Agent DB、13 条合法工具协议、副作用判重、终态投影、assistant audit、消息、回执和密钥泄露均以结构化落盘事实验收。`full` 套件仍要求 External Editor API 配置,缺失时必须返回 `BLOCKED(editorApi)`,不得记为通过。 2026-07-13 V1.3 真实验收:同一真实 Provider 套件已改为先读取 SHA-256,再用唯一一次 `project.patchset` 同时更新和创建文件,并使用自动 checkpointId 读取 2 项内容 hunks;prepared / completed 审计各 1 条、patchset revision 增量为 1,Runner 强杀恢复、命令和项目验证、双视口浏览器验证、隔离 Agent join、重复副作用与密钥扫描继续全部通过。 diff --git a/package.json b/package.json index 5eedc21d7..5b833000c 100644 --- a/package.json +++ b/package.json @@ -139,6 +139,7 @@ "ai-game-creator-shell:build": "npm --prefix apps/ai-game-creator-shell run build --", "ai-game-creator-shell:llm-status": "npm --prefix apps/ai-game-creator-shell run llm-status --", "ai-game-creator-shell:agent-task": "npm --prefix apps/ai-game-creator-shell run agent-task --", + "agc:swarm": "npm --prefix apps/ai-game-creator-shell run swarm --", "ai-game-creator-shell:agent-run": "npm --prefix apps/ai-game-creator-shell run agent-run --", "ai-game-creator-shell:agent-run:smoke": "npm --prefix apps/ai-game-creator-shell run agent-run:smoke", "ai-game-creator-shell:agent-runtime:real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:real-e2e --",