From afd699a060096d451bb5355102ea22d5cfe5470a Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Fri, 26 Jun 2026 21:55:31 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E6=B8=B8=E6=88=8F=E5=88=9B?= =?UTF-8?q?=E4=BD=9C=E6=99=BA=E8=83=BD=E4=BD=93=E6=9C=AC=E5=9C=B0Run?= =?UTF-8?q?=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 agent run 状态、停止、重试和恢复命令 把 run 生命周期写入 trace、activity、output 和 context bundle 补齐聊天入口、测试覆盖和实施计划文档 --- .../src-tauri/src/main.rs | 293 ++++++++++++++++++ apps/ai-game-creator-shell/src/App.tsx | 160 +++++++++- .../tests/appSurface.test.ts | 112 +++++++ .../tests/rememberCommand.test.ts | 18 ++ .../shared-memory/decision-log.md | 1 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 5 + .../shared/src/contracts/gameCreationApp.ts | 6 + .../shared-contracts/src/game_creation_app.rs | 30 +- 8 files changed, 622 insertions(+), 3 deletions(-) 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 6f38796f0..3bcb8784d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -88,6 +88,19 @@ struct GameCreatorLlmConfigStatus { error: Option, } +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct AgentRunControlResult { + run_id: String, + status: String, + lifecycle_status: String, + next_step: String, + message: String, + activity_path: String, + output_path: String, + context_bundle_path: String, +} + #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct UploadLocalAssetResult { @@ -754,6 +767,19 @@ fn get_local_game_manifest(project_path: String) -> Result, +) -> Result { + update_agent_run_lifecycle( + Path::new(project_path.trim()), + action.trim(), + detail.as_deref().map(str::trim).filter(|value| !value.is_empty()), + ) +} + #[tauri::command] async fn generate_local_game_draft( app: tauri::AppHandle, @@ -3504,6 +3530,7 @@ fn write_agent_run_trace( run_id: run_id.to_string(), command_id: "game.generate_draft".to_string(), status: status.to_string(), + lifecycle_status: Some(agent_run_lifecycle_status(status).to_string()), passes, max_passes: GAME_CREATOR_AGENT_LOOP_MAX_PASSES, tool_call_count, @@ -3544,6 +3571,216 @@ fn agent_run_stop_reason(status: &str, error: Option<&str>) -> &'static str { } } +fn agent_run_lifecycle_status(status: &str) -> &'static str { + match status { + "running" | "needs-revision" | "artifacts-written" | "preview-running" => "running", + "waiting" | "preview-stopped" => "waiting", + "pending" => "pending", + "killed" => "killed", + "failed" => "failed", + "passed" => "done", + _ => "scheduled", + } +} + +fn append_agent_run_activity( + root: &Path, + run_id: &str, + event: &str, + message: &str, +) -> Result<(), String> { + append_agent_run_jsonl( + root, + ".agent/activity.jsonl", + &serde_json::json!({ + "timestamp": unix_timestamp(), + "runId": run_id, + "event": event, + "message": message, + }), + ) +} + +fn append_agent_run_output( + root: &Path, + run_id: &str, + event: &str, + message: &str, +) -> Result<(), String> { + append_agent_run_jsonl( + root, + ".agent/output.jsonl", + &serde_json::json!({ + "timestamp": unix_timestamp(), + "runId": run_id, + "event": event, + "content": message, + }), + ) +} + +fn append_agent_run_jsonl( + root: &Path, + relative_path: &str, + value: &serde_json::Value, +) -> Result<(), String> { + let path = root.join(relative_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建 Agent 事件目录失败:{}: {error}", parent.display()))?; + } + let mut line = serde_json::to_string(value) + .map_err(|error| format!("序列化 Agent 事件失败:{error}"))?; + line.push('\n'); + fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .and_then(|mut file| file.write_all(line.as_bytes())) + .map_err(|error| format!("写入 Agent 事件失败:{}: {error}", path.display())) +} + +fn write_agent_run_context_bundle(root: &Path, trace: &GameCreationAgentRunTrace) -> Result<(), String> { + let bundle_path = root.join(".agent/context.bundle.json"); + if let Some(parent) = bundle_path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "创建 Agent context bundle 目录失败:{}: {error}", + parent.display() + ) + })?; + } + let manifest = read_manifest_for_project(root).ok(); + let payload = serde_json::json!({ + "schemaVersion": "game-creator-context-bundle.v1", + "runId": trace.run_id, + "commandId": trace.command_id, + "goal": trace.goal, + "lifecycleStatus": trace.lifecycle_status.as_deref().unwrap_or_else(|| agent_run_lifecycle_status(&trace.status)), + "status": trace.status, + "nextStep": trace.next_step, + "memory": { + "short": "memory/session.md", + "long": "memory/project.md" + }, + "manifest": manifest, + "trace": ".agent/run.latest.json", + "activity": ".agent/activity.jsonl", + "output": ".agent/output.jsonl", + "updatedAt": unix_timestamp() + }); + let content = serde_json::to_string_pretty(&payload) + .map_err(|error| format!("生成 Agent context bundle 失败:{error}"))?; + fs::write(&bundle_path, content).map_err(|error| { + format!( + "写入 Agent context bundle 失败:{}: {error}", + bundle_path.display() + ) + }) +} + +fn update_agent_run_lifecycle( + root: &Path, + action: &str, + detail: Option<&str>, +) -> Result { + let trace_path = root.join(".agent/run.latest.json"); + let content = fs::read_to_string(&trace_path).map_err(|error| { + format!( + "读取 Agent run trace 失败:{}: {error}", + trace_path.display() + ) + })?; + let mut trace = + serde_json::from_str::(&content).map_err(|error| { + format!( + "解析 Agent run trace 失败:{}: {error}", + trace_path.display() + ) + })?; + let (status, lifecycle_status, next_step, event, message) = match action { + "status" => { + let lifecycle = trace + .lifecycle_status + .clone() + .unwrap_or_else(|| agent_run_lifecycle_status(&trace.status).to_string()); + ( + trace.status.clone(), + lifecycle.clone(), + trace.next_step.clone(), + "agent.run_status", + format!("run {} 当前状态:{} / {}", trace.run_id, trace.status, lifecycle), + ) + } + "kill" => ( + "killed".to_string(), + "killed".to_string(), + "resume-or-retry".to_string(), + "agent.kill", + format!("run {} 已标记为 killed", trace.run_id), + ), + "retry" => ( + "pending".to_string(), + "pending".to_string(), + "runner-claim".to_string(), + "agent.retry", + format!("run {} 已重试,等待下一次 claim", trace.run_id), + ), + "resume" => ( + "pending".to_string(), + "pending".to_string(), + "runner-claim".to_string(), + "agent.resume", + format!( + "run {} 已恢复:{}", + trace.run_id, + detail.unwrap_or("等待下一次 claim") + ), + ), + _ => return Err("未知 Agent run 控制动作".to_string()), + }; + + if action != "status" { + trace.status = status; + trace.lifecycle_status = Some(lifecycle_status); + trace.next_step = next_step; + trace.stop_reason = match action { + "kill" => "killed", + "retry" => "retry-requested", + "resume" => "human-resume", + _ => trace.stop_reason.as_str(), + } + .to_string(); + trace.error = if action == "kill" { + Some("用户请求停止当前 run".to_string()) + } else { + None + }; + trace.updated_at = unix_timestamp(); + write_agent_run_trace_payload(root, &trace)?; + } + + append_agent_run_activity(root, &trace.run_id, event, &message)?; + append_agent_run_output(root, &trace.run_id, event, &message)?; + write_agent_run_context_bundle(root, &trace)?; + + Ok(AgentRunControlResult { + run_id: trace.run_id, + status: trace.status, + lifecycle_status: trace + .lifecycle_status + .unwrap_or_else(|| agent_run_lifecycle_status("scheduled").to_string()), + next_step: trace.next_step, + message, + activity_path: root.join(".agent/activity.jsonl").to_string_lossy().to_string(), + output_path: root.join(".agent/output.jsonl").to_string_lossy().to_string(), + context_bundle_path: root + .join(".agent/context.bundle.json") + .to_string_lossy() + .to_string(), + }) +} + fn count_agent_tool_calls(steps: &[GameCreationAgentRunStep]) -> Result { let count = steps.iter().try_fold(0u16, |current, step| { let step_count = u16::try_from(step.tool_calls.len()) @@ -5960,6 +6197,7 @@ fn main() { }) .invoke_handler(tauri::generate_handler![ init_local_game_project, + control_agent_run, generate_local_game_draft, check_game_creator_llm_config, upload_local_asset, @@ -8041,6 +8279,61 @@ GENARRATIVE_GAME_CREATOR_LLM_STREAM=true fs::remove_dir_all(root).ok(); } + #[test] + fn agent_run_control_updates_lifecycle_and_jsonl() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + write_agent_run_trace( + &root, + "run-control-1", + "做一个可控 run", + "running", + 1, + &[agent_trace_step( + 1, + "Planner", + "completed", + &[".agent/manifest.json"], + &[".agent/spec.md"], + "已拆解目标", + "llm.chat.planner", + )], + None, + ) + .expect("run trace"); + + let killed = + update_agent_run_lifecycle(&root, "kill", None).expect("kill should update trace"); + assert_eq!(killed.status, "killed"); + assert_eq!(killed.lifecycle_status, "killed"); + assert_eq!(killed.next_step, "resume-or-retry"); + let trace: Value = + serde_json::from_str(&fs::read_to_string(root.join(".agent/run.latest.json")).unwrap()) + .expect("run trace json"); + assert_eq!(trace["status"], "killed"); + assert_eq!(trace["lifecycleStatus"], "killed"); + assert_eq!(trace["stopReason"], "killed"); + + let retried = + update_agent_run_lifecycle(&root, "retry", None).expect("retry should mark pending"); + assert_eq!(retried.status, "pending"); + assert_eq!(retried.lifecycle_status, "pending"); + assert_eq!(retried.next_step, "runner-claim"); + let status = + update_agent_run_lifecycle(&root, "status", None).expect("status should read trace"); + assert_eq!(status.status, "pending"); + assert_eq!(status.lifecycle_status, "pending"); + let activity = fs::read_to_string(root.join(".agent/activity.jsonl")).expect("activity"); + assert!(activity.contains("agent.kill")); + assert!(activity.contains("agent.retry")); + assert!(activity.contains("agent.run_status")); + let output = fs::read_to_string(root.join(".agent/output.jsonl")).expect("output"); + assert!(output.contains("agent.kill")); + assert!(root.join(".agent/context.bundle.json").exists()); + + fs::remove_dir_all(root).ok(); + } + #[test] fn limited_local_command_rejects_placeholder_game_smoke() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 80a41abda..2711e5e71 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -139,6 +139,17 @@ interface AgentProgressEvent { message: string; } +interface AgentRunControlResult { + runId: string; + status: string; + lifecycleStatus: string; + nextStep: string; + message: string; + activityPath: string; + outputPath: string; + contextBundlePath: string; +} + export type PendingCommand = | { id: 'game.generate_draft'; @@ -165,6 +176,10 @@ export type PendingCommand = | { id: 'preview.open'; } + | { + id: 'agent.kill' | 'agent.retry' | 'agent.resume'; + detail?: string; + } | { id: 'memory.write'; scope: MemoryScope; @@ -257,6 +272,10 @@ const chatCommandHelp = [ '/status:查看项目状态', '/tasks:查看任务拆分', '/trace 或 /loop:查看最近一次 Agent loop trace', + '/agent-status:查看最近 run 生命周期', + '/agent-kill:标记最近 run 为 killed', + '/agent-retry:把最近 run 标记为 pending 等待重试', + '/agent-resume [说明]:把最近 run 标记为 pending 等待继续', '/files:列出本地项目文件', '/assets:列出本地项目资产', '/read 路径:读取本地项目内文本文件', @@ -681,6 +700,9 @@ export function isAbsoluteProjectPath(value: string) { export function needsInitializedChatProject(commandId: PendingCommand['id']) { return [ 'asset.upload', + 'agent.kill', + 'agent.retry', + 'agent.resume', 'command.run_limited', 'game.generate_draft', 'game.run_local', @@ -909,6 +931,13 @@ function pendingCommandTitle(command: PendingCommand) { if (command.id === 'project.create') { return command.id; } + if ( + command.id === 'agent.kill' || + command.id === 'agent.retry' || + command.id === 'agent.resume' + ) { + return command.id; + } if (command.id === 'memory.write' || command.id === 'memory.delete') { return `${command.id} · ${memoryScopeLabel(command.scope)}`; } @@ -952,6 +981,15 @@ export function pendingCommandDetail( if (command.id === 'preview.open') { return '打开当前本地预览'; } + if (command.id === 'agent.kill') { + return `标记 ${projectPath}/.agent/run.latest.json 为 killed,并写入 activity/output`; + } + if (command.id === 'agent.retry') { + return `标记 ${projectPath}/.agent/run.latest.json 为 pending,等待 runner claim`; + } + if (command.id === 'agent.resume') { + return `附加用户说明并标记 ${projectPath}/.agent/run.latest.json 为 pending`; + } if (command.id === 'memory.write') { return `${ command.mode === 'replace' ? '覆盖保存到' : '追加到' @@ -1241,6 +1279,50 @@ export function App() { return; } + if (prompt === '/agent-status') { + void executeAgentRunControl('status', undefined, true); + return; + } + + if (prompt === '/agent-kill') { + if (!requireChatProjectForUserAction()) { + return; + } + queuePendingCommand({ id: 'agent.kill' }); + setMessages((current) => [ + ...current, + { role: 'assistant', text: '准备标记最近 run 为 killed。' }, + ]); + return; + } + + if (prompt === '/agent-retry') { + if (!requireChatProjectForUserAction()) { + return; + } + queuePendingCommand({ id: 'agent.retry' }); + setMessages((current) => [ + ...current, + { role: 'assistant', text: '准备把最近 run 标记为 pending。' }, + ]); + return; + } + + if (prompt === '/agent-resume' || prompt.startsWith('/agent-resume ')) { + if (!requireChatProjectForUserAction()) { + return; + } + queuePendingCommand({ + id: 'agent.resume', + detail: prompt.slice('/agent-resume'.length).trim(), + }); + setMessages((current) => [ + ...current, + { role: 'assistant', text: '准备恢复最近 run。' }, + ]); + return; + } + if (prompt === '/smoke') { if (!requireChatProjectForUserAction()) { return; @@ -1707,6 +1789,16 @@ export function App() { void executePreviewStart(true); } else if (command.id === 'preview.open') { void executePreviewOpen(true); + } else if ( + command.id === 'agent.kill' || + command.id === 'agent.retry' || + command.id === 'agent.resume' + ) { + void executeAgentRunControl( + command.id.slice('agent.'.length), + command.detail, + true, + ); } else if (command.id === 'memory.write') { void executeMemoryWrite( command.scope, @@ -1735,7 +1827,7 @@ export function App() { command.canvasProjectId, true, ); - } else { + } else if (command.id === 'game.generate_draft') { void executeGameDraft(command.prompt); } } @@ -2088,6 +2180,72 @@ export function App() { } } + async function executeAgentRunControl( + action: string, + detail: string | undefined, + announceToChat: boolean, + ) { + const invoke = resolveTauriInvoke(); + if (!invoke) { + setAgentRunStatus('需要在 Tauri App 内运行'); + if (announceToChat) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: '需要在 Tauri App 内运行。' }, + ]); + } + return; + } + const nextProjectPath = requireChatProjectForUserAction(); + if (!nextProjectPath) { + return; + } + + try { + const result = await invoke('control_agent_run', { + projectPath: nextProjectPath, + action, + detail, + }); + setAgentRunStatus( + `${result.status} · ${result.lifecycleStatus} · ${result.nextStep}`, + ); + setCommandLog((current) => [ + ...current, + `agent.${action}`, + 'file.write .agent/activity.jsonl', + 'file.write .agent/output.jsonl', + 'file.write .agent/context.bundle.json', + ]); + await refreshAgentRunTrace(nextProjectPath); + if (announceToChat) { + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: [ + result.message, + `状态:${result.status} / ${result.lifecycleStatus}`, + `下一步:${result.nextStep}`, + `事件:${result.activityPath}`, + `输出:${result.outputPath}`, + `上下文包:${result.contextBundlePath}`, + ].join('\n'), + }, + ]); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setAgentRunStatus(message); + if (announceToChat) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: message }, + ]); + } + } + } + async function executeAgentAuditChat() { const invoke = resolveTauriInvoke(); if (!invoke) { diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index 795fe419b..09e652c62 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -93,6 +93,8 @@ describe('AI 游戏创作 App 界面边界', () => { expect( screen.getByText(/\/audit:审计当前项目的 Agent 能力证据/), ).not.toBeNull(); + expect(screen.getByText(/\/agent-status:查看最近 run 生命周期/)).not.toBeNull(); + expect(screen.getByText(/\/agent-kill:标记最近 run 为 killed/)).not.toBeNull(); expect(screen.queryByLabelText('开发环境')).toBeNull(); }); @@ -2142,6 +2144,116 @@ describe('AI 游戏创作 App 界面边界', () => { }); }); + it('controls agent run lifecycle from chat through the authorized local project path', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const trace: GameCreationAgentRunTrace = { + schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, + runId: 'run-control-chat', + commandId: 'game.generate_draft', + status: 'pending', + lifecycleStatus: 'pending', + passes: 1, + maxPasses: 3, + toolCallCount: 1, + maxToolCalls: 128, + stopReason: 'retry-requested', + goal: '做一个反弹弹幕厨房游戏', + coordination: 'filesystem', + steps: [], + artifacts: [], + taskGraph: { + goal: '做一个反弹弹幕厨房游戏', + readyTaskIds: [], + activeTaskIds: [], + carriedTaskIds: [], + repairFocus: [], + repairRoutes: [], + tasks: createGameCreationAppSeedTasks(), + }, + passPlans: [], + nextStep: 'runner-claim', + error: null, + updatedAt: 1, + }; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'control_agent_run') { + return { + runId: 'run-control-chat', + status: args?.action === 'kill' ? 'killed' : 'pending', + lifecycleStatus: args?.action === 'kill' ? 'killed' : 'pending', + nextStep: + args?.action === 'kill' ? 'resume-or-retry' : 'runner-claim', + message: + args?.action === 'kill' + ? 'run run-control-chat 已标记为 killed' + : 'run run-control-chat 当前状态:pending / pending', + activityPath: '/tmp/authorized-game/.agent/activity.jsonl', + outputPath: '/tmp/authorized-game/.agent/output.jsonl', + contextBundlePath: '/tmp/authorized-game/.agent/context.bundle.json', + }; + } + if (command === 'read_local_project_file') { + return { + path: '.agent/run.latest.json', + absolutePath: `${String(args?.projectPath ?? '')}/.agent/run.latest.json`, + content: JSON.stringify(trace), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/agent-status'); + expect( + await screen.findByText(/run run-control-chat 当前状态:pending \/ pending/), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('control_agent_run', { + projectPath: '/tmp/authorized-game', + action: 'status', + detail: undefined, + }); + + submitChat('/agent-kill'); + expect(screen.getByText('agent.kill')).not.toBeNull(); + expect( + screen.getByText( + '标记 /tmp/authorized-game/.agent/run.latest.json 为 killed,并写入 activity/output', + ), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText(/run run-control-chat 已标记为 killed/), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('control_agent_run', { + projectPath: '/tmp/authorized-game', + action: 'kill', + detail: undefined, + }); + }); + it('manages long memory from chat through the authorized local project path', async () => { const manifest = createGameCreationAppManifest( 'local-project-draft', diff --git a/apps/ai-game-creator-shell/tests/rememberCommand.test.ts b/apps/ai-game-creator-shell/tests/rememberCommand.test.ts index 4217f7e3b..26e100928 100644 --- a/apps/ai-game-creator-shell/tests/rememberCommand.test.ts +++ b/apps/ai-game-creator-shell/tests/rememberCommand.test.ts @@ -102,6 +102,9 @@ describe('AI 游戏创作聊天记忆命令', () => { it('requires a project before chat commands write or run local artifacts', () => { expect(needsInitializedChatProject('game.generate_draft')).toBe(true); expect(needsInitializedChatProject('asset.upload')).toBe(true); + expect(needsInitializedChatProject('agent.kill')).toBe(true); + expect(needsInitializedChatProject('agent.retry')).toBe(true); + expect(needsInitializedChatProject('agent.resume')).toBe(true); expect(needsInitializedChatProject('command.run_limited')).toBe(true); expect(needsInitializedChatProject('preview.open')).toBe(true); expect(needsInitializedChatProject('preview.start')).toBe(true); @@ -113,6 +116,21 @@ describe('AI 游戏创作聊天记忆命令', () => { expect(needsInitializedChatProject('canvas.project_open')).toBe(false); }); + it('describes agent run lifecycle controls before confirmation', () => { + expect(pendingCommandDetail({ id: 'agent.kill' }, '/tmp/game')).toBe( + '标记 /tmp/game/.agent/run.latest.json 为 killed,并写入 activity/output', + ); + expect(pendingCommandDetail({ id: 'agent.retry' }, '/tmp/game')).toBe( + '标记 /tmp/game/.agent/run.latest.json 为 pending,等待 runner claim', + ); + expect( + pendingCommandDetail( + { id: 'agent.resume', detail: '继续修复输入监听' }, + '/tmp/game', + ), + ).toBe('附加用户说明并标记 /tmp/game/.agent/run.latest.json 为 pending'); + }); + it('shows the authorized project path in pending write command details', () => { expect( resolvePendingCommandProjectPath( diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index dfe407a5f..c5d492fb2 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3789,6 +3789,7 @@ - 2026-06-25 调整:正式用户 App 不承载游戏预览画面,release CSP 不允许 `frame-src http://127.0.0.1:*`;只有开发窗口 / dev CSP 可以嵌入本地预览 iframe。`/preview`、`/run` 和生成完成后的用户侧路径只启动 `127.0.0.1` HTTP preview 并通过 `open_local_game_preview` 交给系统外部浏览器。 - 2026-06-25 调整:`project.create` 成功后的 durable 权限证据必须在聊天 `/project` 和开发窗口初始化两条入口统一写入 `.agent/logs/command.log`,避免同一能力因为入口不同导致 `/audit` 或开发排障证据不一致。 - 2026-06-25 调整:`.agent/run.latest.json` 和 `.agent/runs/.json` 必须记录 loop 的 `maxPasses` 与 `stopReason`,开发窗口直接展示该状态,避免只从 summary 文案推断 loop 是否跑满、通过、返工、写入产物或进入预览。本地 HTTP 预览的 `/` 映射到 `game/index.html`,路径解析必须 canonicalize 项目根目录和目标文件,只允许访问项目内 `game/` 与 `assets/`,拒绝 `memory/`、`.agent/`、`exports/`、`..`、反斜杠和符号链接越界;常见图片、音频、视频和 Web 资源必须返回对应 MIME。这样上传和画板回流资产能被生成游戏引用,但记忆、trace 和导出包不会被预览服务暴露。 +- 2026-06-26 调整:AI 游戏创作 App 借鉴 Harbour 的控制平面思想,但不搬 Harbour 后台。最近 run 在 `.agent/run.latest.json` 增加可选 `lifecycleStatus`,并通过 `/agent-status`、`/agent-kill`、`/agent-retry`、`/agent-resume [说明]` 控制本地生命周期,写入 `.agent/activity.jsonl`、`.agent/output.jsonl` 和 `.agent/context.bundle.json`。v1 的 kill/retry/resume 只更新本地状态和上下文包,不伪装成能中断已发出的上游 LLM 请求;后续引入独立 runner 后再把 `pending` 接入 claim。 - 2026-06-25 调整:本地 HTTP 预览静态 `HEAD` 必须返回与 `GET` 相同的真实 `Content-Length`,但不返回 body;浏览器、图片、音频和视频探测不能拿到 `Content-Length: 0` 的假响应。 - 2026-06-25 调整:普通用户通过聊天输入 `/run` 触发待确认 `game.run_local`,确认后只能复用白名单 `game.static_smoke` 自检当前 `game/index.html`,通过后启动 `127.0.0.1` 本地 HTTP 预览。独立执行 `game.static_smoke` 时如果已有 `.agent/run.latest.json`,必须追加 `Playtest / game.static_smoke` trace step,避免“运行了代码但编排 trace 不可见”。 - 2026-06-25 调整:普通用户通过聊天输入 `/trace` 触发只读 `agent.trace_read`,读取 `.agent/run.latest.json` 并在聊天里摘要 loop 轮次、stopReason、nextStep、active / carry-over 任务、repairRoutes、agent 建议命令和最近 step。trace 面板仍只在开发窗口展示,普通用户窗口不新增面板。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index a0ba19ab5..b3e303b27 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -11,6 +11,7 @@ - 本地能力:使用 Tauri Rust command;正式用户 App 只启动 `127.0.0.1` 本地 HTTP preview 并交给外部浏览器,不在正式用户窗口内承载游戏预览画面。 - Agent Runtime:扩展 `server-rs/crates/platform-agent`,不引入 LangChain、AutoGen、Microsoft Agent Framework 或 OpenAI Agents SDK sidecar 作为核心。 - 设计参考:借鉴 OpenAI Agents SDK 的 Agent、Tools、Handoffs、Guardrails、Tracing 抽象,但运行时由 Genarrative 自己掌控。 +- Run 控制参考:借鉴 Harbour 的控制平面思想,只吸收 `run lifecycle`、activity/output stream、context bundle、kill/retry/resume 等本地运行治理能力;不引入 Harbour 的多租户后台、调度 UI、通用 shell workflow 或远程 runner 作为 v1 依赖。 ## Runtime 边界 @@ -47,6 +48,9 @@ game-project/ agent.db manifest.json run.latest.json + activity.jsonl + output.jsonl + context.bundle.json runs/ logs/ ``` @@ -115,6 +119,7 @@ game-project/ - 通过 Evaluator 和 `game.static_smoke` 后,Agent loop 会把本次 runId、状态、轮次、下一步、active / carry-over 任务和最终本地产物摘要追加到 `memory/session.md` 与 `memory/project.md`;下一次 Planner、组内角色和 Generator 会通过记忆输入自然读取上一轮稳定原型状态,而不只依赖开发窗口 trace。 - `.agent/agent.db` 当前作为最小本地索引文件使用 JSONL:初始化写入 `project.init`,每次 `game.generate_draft` 追加目标、标题和本地产物路径,上传 / 登记 / 画板导入资产时追加 `asset.register` 或 `asset.update`;v1 不引入 SQLite 依赖。 - `.agent/run.latest.json` 的 schema 固定为共享契约 `GAME_CREATION_AGENT_RUN_SCHEMA_VERSION = game-creator-agent-run.v1`;TS 与 Rust 都从共享契约读取 run trace 类型,避免开发窗口和 Tauri 写入结构漂移。 +- `.agent/run.latest.json` 增加可选 `lifecycleStatus`,把一次生成 run 映射到本地最小生命周期:`scheduled / running / waiting / pending / done / failed / killed`。聊天命令 `/agent-status` 读取最近 run,`/agent-kill` 标记为 `killed`,`/agent-retry` 与 `/agent-resume [说明]` 标记为 `pending`,并写入 `.agent/activity.jsonl`、`.agent/output.jsonl` 和 `.agent/context.bundle.json`。v1 只做本地状态控制,不承诺真正中断已在上游执行中的 LLM 请求;后续引入独立 runner 后再把 `pending` 接入 claim。 - `game.generate_draft` 写入最终产物后会复用白名单受限命令 `game.static_smoke` 做一次生成后自检,至少检查 `game/index.html` 包含 canvas、canvas 渲染上下文、绘制调用、主循环、非空输入监听、明确目标、失败或胜利状态和重开路径,且不使用远程资源、`eval`、`new Function`、`localStorage`、`fetch`、`WebSocket` 或 `ServiceWorker`,也不得包含固定星核传送门模板词、纯按钮计分模板或 `TODO` / `待实现` / `这里省略` 等未完成实现;画板资源占位引用允许出现在 asset id 或说明中,并把该工具调用写入 `.agent/run.latest.json` 与 `.agent/logs/command.log`;自检失败则本次命令失败,不继续启动预览。 - `ArtifactWriter` step 使用 `file.write.local_artifacts` 工具调用记录最终写入的 `memory/`、`game/`、`assets/`、`exports/` 和 `.agent/manifest.json` 路径;写入完成后 `nextStep` 指向 `game.static_smoke`。 - `preview.start` / `preview.stop` 会追加 `.agent/logs/preview.log`,并在 `.agent/run.latest.json` 已存在时追加 `Preview` step 和 `preview.*` toolCall,记录本地 HTTP 预览 URL 与停止事件;单全局本地预览被新项目替换时,会 best-effort 把旧项目 manifest、preview log 和 trace 记录为 stopped,避免旧项目残留 running;本地 HTTP server 的 `/` 映射到 `game/index.html`,只允许读取 canonical 后仍位于项目真实 `game/` 或真实 `assets/` 下的文件,拒绝 `memory/`、`.agent/`、`exports/`、`..`、一级 `game` / `assets` 符号链接目录和内部符号链接越界,并为常见图片、音频、视频和 Web 资源返回对应 MIME;静态 `HEAD` 返回真实 `Content-Length` 但不返回 body,确保浏览器和媒体资源探测可用;上传和画板回流资产可被生成游戏引用但不会暴露记忆或 trace;没有 run trace 的手动预览启动不阻断。 diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index 745aa5e8f..57fff1909 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -18,6 +18,10 @@ export const GAME_CREATION_APP_COMMANDS = [ { id: 'project.status', permission: 'auto' }, { id: 'task.list', permission: 'auto' }, { id: 'agent.trace_read', permission: 'auto' }, + { id: 'agent.run_status', permission: 'auto' }, + { id: 'agent.kill', permission: 'confirm' }, + { id: 'agent.retry', permission: 'confirm' }, + { id: 'agent.resume', permission: 'confirm' }, { id: 'agent.capabilities', permission: 'auto' }, { id: 'agent.audit', permission: 'auto' }, { id: 'llm.config_check', permission: 'auto' }, @@ -88,6 +92,7 @@ export const GAME_CREATION_AGENT_CAPABILITIES = [ title: '组内角色协作', }, { id: 'quality-review', area: 'agent-runtime', title: '质量评审' }, + { id: 'run-lifecycle', area: 'agent-runtime', title: 'Run 生命周期控制' }, { id: 'repair-loop-carryover', area: 'agent-runtime', @@ -433,6 +438,7 @@ export interface GameCreationAgentRunTrace { runId: string; commandId: string; status: string; + lifecycleStatus?: string | null; passes: number; maxPasses: number; toolCallCount: number; diff --git a/server-rs/crates/shared-contracts/src/game_creation_app.rs b/server-rs/crates/shared-contracts/src/game_creation_app.rs index 410dd9d4e..c0b636bd7 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -21,12 +21,16 @@ pub struct GameCreationAppCommandDescriptor { pub permission: GameCreationAppPermission, } -pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 29] = [ +pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 33] = [ command("help.show", GameCreationAppPermission::Auto), command("project.create", GameCreationAppPermission::Confirm), command("project.status", GameCreationAppPermission::Auto), command("task.list", GameCreationAppPermission::Auto), command("agent.trace_read", GameCreationAppPermission::Auto), + command("agent.run_status", GameCreationAppPermission::Auto), + command("agent.kill", GameCreationAppPermission::Confirm), + command("agent.retry", GameCreationAppPermission::Confirm), + command("agent.resume", GameCreationAppPermission::Confirm), command("agent.capabilities", GameCreationAppPermission::Auto), command("agent.audit", GameCreationAppPermission::Auto), command("llm.config_check", GameCreationAppPermission::Auto), @@ -68,7 +72,7 @@ pub struct GameCreationAgentCapabilityDescriptor { pub title: &'static str, } -pub const GAME_CREATION_AGENT_CAPABILITIES: [GameCreationAgentCapabilityDescriptor; 22] = [ +pub const GAME_CREATION_AGENT_CAPABILITIES: [GameCreationAgentCapabilityDescriptor; 23] = [ capability("chat", "user", "聊天入口"), capability("file-upload", "user", "上传文件"), capability("built-in-commands", "agent-runtime", "内置命令调用"), @@ -90,6 +94,7 @@ pub const GAME_CREATION_AGENT_CAPABILITIES: [GameCreationAgentCapabilityDescript capability("multi-agent-collaboration", "agent-runtime", "多智能体协作"), capability("role-level-collaboration", "agent-runtime", "组内角色协作"), capability("quality-review", "agent-runtime", "质量评审"), + capability("run-lifecycle", "agent-runtime", "Run 生命周期控制"), capability( "repair-loop-carryover", "agent-runtime", @@ -508,6 +513,8 @@ pub struct GameCreationAgentRunTrace { pub run_id: String, pub command_id: String, pub status: String, + #[serde(default)] + pub lifecycle_status: Option, pub passes: u8, #[serde(default = "default_game_creation_agent_run_max_passes")] pub max_passes: u8, @@ -636,6 +643,23 @@ mod tests { .expect("command should exist"); assert_eq!(trace_read.permission, GameCreationAppPermission::Auto); + let run_status = GAME_CREATION_APP_COMMANDS + .iter() + .find(|command| command.id == "agent.run_status") + .expect("command should exist"); + assert_eq!(run_status.permission, GameCreationAppPermission::Auto); + + for command_id in ["agent.kill", "agent.retry", "agent.resume"] { + let lifecycle_command = GAME_CREATION_APP_COMMANDS + .iter() + .find(|command| command.id == command_id) + .expect("command should exist"); + assert_eq!( + lifecycle_command.permission, + GameCreationAppPermission::Confirm + ); + } + let agent_capabilities = GAME_CREATION_APP_COMMANDS .iter() .find(|command| command.id == "agent.capabilities") @@ -733,6 +757,7 @@ mod tests { "tool-call-budget", "multi-agent-collaboration", "role-level-collaboration", + "run-lifecycle", "repair-loop-carryover", "short-term-memory", "long-term-memory", @@ -761,6 +786,7 @@ mod tests { run_id: "run-1".to_string(), command_id: "game.generate_draft".to_string(), status: "preview-running".to_string(), + lifecycle_status: Some("running".to_string()), passes: 2, max_passes: GAME_CREATION_AGENT_RUN_MAX_PASSES, tool_call_count: 1,