From e52f43485a823329e9a550259492b567bac0ca5d Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 28 Aug 2026 19:17:50 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E9=A2=84=E8=A7=88=E6=A0=B9=E5=85=A5=E5=8F=A3=20404?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 允许项目根布局的 index.html 和网页资源通过预览服务访问 保留 game/ 与 assets/ 旧布局路径支持 增加根入口、资源和控制目录隔离回归测试 --- .../src-tauri/src/preview.rs | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index b5b51944b..f6aafa57e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -1455,6 +1455,42 @@ fn canonical_preview_path(root: &Path, file_path: &Path) -> Result value.to_str(), + _ => None, + }); + let protected_root_component = first_component.is_some_and(|component| { + [ + ".agent", + ".git", + ".codex", + ".hermes", + "memory", + "exports", + "node_modules", + "target", + ] + .iter() + .any(|protected| component.eq_ignore_ascii_case(protected)) + }); + if !protected_root_component && content_type(&canonical_file) != "application/octet-stream" { + return Ok(canonical_file); + } + } + for segment in ["game", "assets"] { let allowed_dir = root.join(segment); let metadata = match fs::symlink_metadata(&allowed_dir) { @@ -1543,3 +1579,83 @@ fn http_response(status: &str, content_type: &str, body: &[u8], content_length: response.extend_from_slice(body); response } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn root_layout_serves_root_entry_and_keeps_legacy_paths_available() { + let root = tempfile::tempdir().expect("create preview root"); + fs::create_dir_all(root.path().join("game")).expect("create game directory"); + fs::create_dir_all(root.path().join("assets")).expect("create assets directory"); + fs::write( + root.path().join("index.html"), + "根入口", + ) + .expect("write root entry"); + fs::write( + root.path().join("game/index.html"), + "游戏入口", + ) + .expect("write game entry"); + fs::write(root.path().join("style.css"), "body { color: red; }") + .expect("write root stylesheet"); + fs::write( + root.path().join("assets/icon.png"), + [0x89, 0x50, 0x4e, 0x47], + ) + .expect("write asset"); + + let canonical_root_entry = root + .path() + .join("index.html") + .canonicalize() + .expect("canonical root entry"); + let canonical_game_entry = root + .path() + .join("game/index.html") + .canonicalize() + .expect("canonical game entry"); + assert_eq!(project_game_root(root.path()), root.path()); + assert_eq!( + resolve_preview_path(root.path(), "/").unwrap(), + canonical_root_entry + ); + assert_eq!( + resolve_preview_path(root.path(), "/index.html").unwrap(), + canonical_root_entry + ); + assert_eq!( + resolve_preview_path(root.path(), "/style.css").unwrap(), + root.path().join("style.css").canonicalize().unwrap() + ); + assert_eq!( + resolve_preview_path(root.path(), "/game/index.html").unwrap(), + canonical_game_entry + ); + assert_eq!( + resolve_preview_path(root.path(), "/assets/icon.png").unwrap(), + root.path().join("assets/icon.png").canonicalize().unwrap() + ); + + let response = build_preview_response(root.path(), "GET", "/"); + let response_text = String::from_utf8_lossy(&response); + assert!(response_text.starts_with("HTTP/1.1 200 OK\r\n")); + assert!(response_text.contains("根入口")); + } + + #[test] + fn root_layout_does_not_expose_control_or_data_directories() { + let root = tempfile::tempdir().expect("create preview root"); + fs::create_dir_all(root.path().join(".agent")).expect("create agent directory"); + fs::create_dir_all(root.path().join("memory")).expect("create memory directory"); + fs::write(root.path().join("index.html"), "").expect("write root entry"); + fs::write(root.path().join(".agent/secret.json"), "{}").expect("write secret"); + fs::write(root.path().join("memory/private.md"), "private").expect("write private data"); + + assert!(resolve_preview_path(root.path(), "/.agent/secret.json").is_err()); + assert!(resolve_preview_path(root.path(), "/memory/private.md").is_err()); + } +} From 116bce6b6879087fd2e0c1dc56c24b6362c7e236 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 28 Aug 2026 19:17:50 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E9=A2=84=E8=A7=88=E6=A0=B9=E5=85=A5=E5=8F=A3=20404?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 允许项目根布局的 index.html 和网页资源通过预览服务访问 保留 game/ 与 assets/ 旧布局路径支持 增加根入口、资源和控制目录隔离回归测试 --- .../src-tauri/src/preview.rs | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index b5b51944b..f6aafa57e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -1455,6 +1455,42 @@ fn canonical_preview_path(root: &Path, file_path: &Path) -> Result value.to_str(), + _ => None, + }); + let protected_root_component = first_component.is_some_and(|component| { + [ + ".agent", + ".git", + ".codex", + ".hermes", + "memory", + "exports", + "node_modules", + "target", + ] + .iter() + .any(|protected| component.eq_ignore_ascii_case(protected)) + }); + if !protected_root_component && content_type(&canonical_file) != "application/octet-stream" { + return Ok(canonical_file); + } + } + for segment in ["game", "assets"] { let allowed_dir = root.join(segment); let metadata = match fs::symlink_metadata(&allowed_dir) { @@ -1543,3 +1579,83 @@ fn http_response(status: &str, content_type: &str, body: &[u8], content_length: response.extend_from_slice(body); response } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn root_layout_serves_root_entry_and_keeps_legacy_paths_available() { + let root = tempfile::tempdir().expect("create preview root"); + fs::create_dir_all(root.path().join("game")).expect("create game directory"); + fs::create_dir_all(root.path().join("assets")).expect("create assets directory"); + fs::write( + root.path().join("index.html"), + "根入口", + ) + .expect("write root entry"); + fs::write( + root.path().join("game/index.html"), + "游戏入口", + ) + .expect("write game entry"); + fs::write(root.path().join("style.css"), "body { color: red; }") + .expect("write root stylesheet"); + fs::write( + root.path().join("assets/icon.png"), + [0x89, 0x50, 0x4e, 0x47], + ) + .expect("write asset"); + + let canonical_root_entry = root + .path() + .join("index.html") + .canonicalize() + .expect("canonical root entry"); + let canonical_game_entry = root + .path() + .join("game/index.html") + .canonicalize() + .expect("canonical game entry"); + assert_eq!(project_game_root(root.path()), root.path()); + assert_eq!( + resolve_preview_path(root.path(), "/").unwrap(), + canonical_root_entry + ); + assert_eq!( + resolve_preview_path(root.path(), "/index.html").unwrap(), + canonical_root_entry + ); + assert_eq!( + resolve_preview_path(root.path(), "/style.css").unwrap(), + root.path().join("style.css").canonicalize().unwrap() + ); + assert_eq!( + resolve_preview_path(root.path(), "/game/index.html").unwrap(), + canonical_game_entry + ); + assert_eq!( + resolve_preview_path(root.path(), "/assets/icon.png").unwrap(), + root.path().join("assets/icon.png").canonicalize().unwrap() + ); + + let response = build_preview_response(root.path(), "GET", "/"); + let response_text = String::from_utf8_lossy(&response); + assert!(response_text.starts_with("HTTP/1.1 200 OK\r\n")); + assert!(response_text.contains("根入口")); + } + + #[test] + fn root_layout_does_not_expose_control_or_data_directories() { + let root = tempfile::tempdir().expect("create preview root"); + fs::create_dir_all(root.path().join(".agent")).expect("create agent directory"); + fs::create_dir_all(root.path().join("memory")).expect("create memory directory"); + fs::write(root.path().join("index.html"), "").expect("write root entry"); + fs::write(root.path().join(".agent/secret.json"), "{}").expect("write secret"); + fs::write(root.path().join("memory/private.md"), "private").expect("write private data"); + + assert!(resolve_preview_path(root.path(), "/.agent/secret.json").is_err()); + assert!(resolve_preview_path(root.path(), "/memory/private.md").is_err()); + } +} From fc75363da4e1554f42305c015542553de24b3fab Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 28 Aug 2026 20:01:46 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E6=8F=90=E5=8D=87=20AGC=20=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E8=87=B3=200.1.11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步 npm、Tauri、Cargo 与锁文件版本 更新 workspace 和 release 版本校验 --- apps/ai-game-creator-shell/package.json | 2 +- apps/ai-game-creator-shell/scripts/check-config.mjs | 8 ++++---- apps/ai-game-creator-shell/src-tauri/Cargo.lock | 2 +- apps/ai-game-creator-shell/src-tauri/Cargo.toml | 2 +- apps/ai-game-creator-shell/src-tauri/tauri.conf.json | 2 +- package-lock.json | 2 +- scripts/check-npm-workspaces.mjs | 2 +- scripts/check-npm-workspaces.test.mjs | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 1f87e5b8c..e546d79a0 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -1,7 +1,7 @@ { "name": "@genarrative/ai-game-creator-shell", "private": true, - "version": "0.1.10", + "version": "0.1.11", "type": "module", "scripts": { "dev": "node scripts/start-tauri-dev.mjs", diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 4f0d64715..b2d2d0958 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1533,12 +1533,12 @@ if ( } if ( - tauriConfig.version !== '0.1.10' || - packageConfig.version !== '0.1.10' || - cargoPackageVersion !== '0.1.10' + tauriConfig.version !== '0.1.11' || + packageConfig.version !== '0.1.11' || + cargoPackageVersion !== '0.1.11' ) { throw new Error( - 'AI game creator standard release must remain version 0.1.10', + 'AI game creator standard release must remain version 0.1.11', ); } diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 161613e5a..c3e3eb600 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1695,7 +1695,7 @@ dependencies = [ [[package]] name = "genarrative-ai-game-creator-shell" -version = "0.1.10" +version = "0.1.11" dependencies = [ "agent-runtime-core", "axum", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 85baf1f63..d1ac57049 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "genarrative-ai-game-creator-shell" -version = "0.1.10" +version = "0.1.11" edition = "2021" publish = false diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json index 53a6ad506..ff42f5b08 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Genarrative AI Game Creator", - "version": "0.1.10", + "version": "0.1.11", "identifier": "world.genarrative.ai-game-creator", "build": { "beforeDevCommand": "npm --prefix ../.. run agc:serve", diff --git a/package-lock.json b/package-lock.json index 92c3036fc..ee02cb87e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -93,7 +93,7 @@ }, "apps/ai-game-creator-shell": { "name": "@genarrative/ai-game-creator-shell", - "version": "0.1.10", + "version": "0.1.11", "dependencies": { "@cubone/react-file-manager": "^1.35.0", "@genarrative/image-canvas-core": "0.1.0", diff --git a/scripts/check-npm-workspaces.mjs b/scripts/check-npm-workspaces.mjs index 6a91ba9d0..bff674b90 100644 --- a/scripts/check-npm-workspaces.mjs +++ b/scripts/check-npm-workspaces.mjs @@ -174,7 +174,7 @@ export function collectNpmWorkspaceErrors(rootDir) { ); } const expectedWorkspaceVersion = - workspacePath === 'apps/ai-game-creator-shell' ? '0.1.10' : '0.1.0'; + workspacePath === 'apps/ai-game-creator-shell' ? '0.1.11' : '0.1.0'; if (manifest.version !== expectedWorkspaceVersion) { errors.push( `${manifestPath}: workspace version must be ${expectedWorkspaceVersion}`, diff --git a/scripts/check-npm-workspaces.test.mjs b/scripts/check-npm-workspaces.test.mjs index 74e87b4ea..e2e43c18b 100644 --- a/scripts/check-npm-workspaces.test.mjs +++ b/scripts/check-npm-workspaces.test.mjs @@ -79,7 +79,7 @@ function createValidFixture() { name: workspaceNames[workspacePath], private: true, version: - workspacePath === 'apps/ai-game-creator-shell' ? '0.1.10' : '0.1.0', + workspacePath === 'apps/ai-game-creator-shell' ? '0.1.11' : '0.1.0', dependencies: localDependencies[workspacePath], }; writeJson(rootDir, `${workspacePath}/package.json`, manifest); From 1ba34b8e25af94e4d02dc2a80478b8287df41616 Mon Sep 17 00:00:00 2001 From: Git Hooks Test Date: Fri, 28 Aug 2026 20:55:23 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E6=94=BE=E5=BC=80=20AGC=20=E8=87=AA?= =?UTF-8?q?=E4=B8=BB=E5=88=9B=E4=BD=9C=E6=B5=81=E7=A8=8B=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E5=85=A8=E6=B5=81=E7=A8=8B=E6=89=A7=E8=A1=8C=E8=83=BD?= =?UTF-8?q?=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 agc_write_file 直写工具并放宽自主构建执行路径 移除固定任务依赖与确认门槛对并行创作的阻塞 同步 DirectProject、预览、运行状态和 E2E 测试调整 --- ...ent-runtime-deterministic-playable-e2e.mjs | 27 +- .../harness/app-data.mjs | 164 ++++++- .../harness/process.mjs | 8 + .../agent-runtime-real-e2e/runtime-state.mjs | 6 +- .../suites/supervisor-autonomous-playable.mjs | 5 + .../deterministic-lane-defense-provider.mjs | 359 +++++++++++++-- .../src-tauri/src/agent/direct_runtime.rs | 18 +- .../src-tauri/src/agent/direct_tool_bridge.rs | 92 +++- .../src-tauri/src/agent/direct_tools_mcp.rs | 78 +++- .../src-tauri/src/agent/generation.rs | 1 + .../agent/runtime_actions/action_execution.rs | 40 +- .../runtime_actions/autonomous_policy.rs | 42 +- .../pending_confirmation_ledger.rs | 80 ++-- .../agent/runtime_actions/project_gates.rs | 151 +----- .../runtime_actions/provider_action_batch.rs | 22 +- .../provider_request_builders.rs | 217 ++++++--- .../runtime_actions/provider_tool_plan.rs | 115 ++--- .../runtime_actions/run_status_observation.rs | 8 + .../run_status_observation_tests.rs | 14 +- .../runtime_actions/tool_policy_snapshot.rs | 11 +- .../src/agent/runtime_driver/finalization.rs | 91 ++-- .../src/agent/runtime_driver/interaction.rs | 9 + .../src/agent/runtime_driver/main_loop.rs | 380 ++++++++++----- .../agent/runtime_driver/provider_recovery.rs | 200 ++++++++ .../src/agent/runtime_driver/recovery_scan.rs | 41 +- .../src/agent/runtime_driver/task_start.rs | 431 ++++++++++++++---- .../runtime_protocol/autonomous_completion.rs | 370 ++++++++++++--- .../agent/runtime_protocol/context_bundle.rs | 4 +- .../agent/runtime_protocol/finalization.rs | 7 +- .../runtime_protocol/run_configuration.rs | 47 +- .../src-tauri/src/agent/runtime_state.rs | 87 ++-- .../src-tauri/src/agent/runtime_tools.rs | 25 + .../src/agent/runtime_tools/command_ops.rs | 13 + .../src/agent/runtime_tools/delivery.rs | 48 ++ .../src/agent/runtime_tools/file_ops.rs | 3 + .../src/agent/runtime_tools/media.rs | 44 +- .../src/agent/runtime_tools/policy.rs | 23 +- .../src/agent/runtime_tools/preview.rs | 34 +- .../src/agent/runtime_tools/run_status.rs | 11 + .../src/agent/runtime_tools/task_ops.rs | 42 +- .../src-tauri/src/main.rs | 28 +- .../src-tauri/src/platform_session.rs | 211 +++++++++ .../src-tauri/src/project/filesystem.rs | 88 +++- .../src-tauri/src/project/manifest.rs | 19 +- .../src-tauri/src/tests/command_runtime.rs | 78 ++++ .../autonomous_game_build.rs | 21 +- .../shared-memory/decision-log.md | 6 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 6 + .../platform-agent/src/game_creation.rs | 4 +- .../shared-contracts/src/game_creation_app.rs | 62 ++- 50 files changed, 3094 insertions(+), 797 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs index 89f552dc7..8bf357203 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs @@ -26,6 +26,11 @@ const wrapperSuite = const configFileName = 'game-creator.config.json'; const configSentinelName = '.deterministic-provider-e2e.json'; const configSentinelSchema = 'genarrative-deterministic-provider-e2e-config.v1'; +const platformSessionFixtureEnv = + 'GENARRATIVE_AGC_PLATFORM_SESSION_FIXTURE'; +const platformSessionFixtureName = '.deterministic-platform-session.json'; +const platformSessionFixtureSchema = + 'genarrative-agc-platform-session-fixture.v1'; const outputLimit = 32 * 1024 * 1024; function hashValue(value) { @@ -77,6 +82,16 @@ function deterministicRuntimeConfig(apiKey, provider) { }; } +function deterministicPlatformSessionFixture(apiKey, provider) { + return { + schemaVersion: platformSessionFixtureSchema, + userId: 'deterministic-e2e-user', + accessToken: apiKey, + apiBaseUrl: provider.editorBaseUrl, + generation: 1, + }; +} + function appendBounded(current, chunk) { const combined = Buffer.concat([current, chunk]); if (combined.length > outputLimit) throw new Error('child-output-too-large'); @@ -1909,6 +1924,12 @@ async function runE2e(options) { ); provider = await startDeterministicLaneDefenseProvider({ apiKey }); const config = deterministicRuntimeConfig(apiKey, provider); + const fixturePath = path.join(configDir, platformSessionFixtureName); + await fs.writeFile( + fixturePath, + `${JSON.stringify(deterministicPlatformSessionFixture(apiKey, provider))}\n`, + { flag: 'wx', mode: 0o600 }, + ); await fs.writeFile( path.join(configDir, configFileName), `${JSON.stringify(config)}\n`, @@ -1924,7 +1945,11 @@ async function runE2e(options) { if (options.keepProject) childArgs.push('--keep-project'); childResult = await runChild( childArgs, - withLoopbackNoProxy({ ...process.env, NO_COLOR: '1' }), + withLoopbackNoProxy({ + ...process.env, + NO_COLOR: '1', + [platformSessionFixtureEnv]: fixturePath, + }), ); childReport = parseChildReport(childResult); } catch (error) { diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/app-data.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/app-data.mjs index a023302c7..389fef24d 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/app-data.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/app-data.mjs @@ -100,6 +100,14 @@ import { appendBounded, runProcess } from './process.mjs'; import { decodeUtf8Fatal, isIsolatedRunnerSuite } from './reporting.mjs'; import { killRunnerOnce, readRunnerStatus, runnerBootId } from './runtime.mjs'; +const platformSessionFixtureEnv = + 'GENARRATIVE_AGC_PLATFORM_SESSION_FIXTURE'; +const platformSessionFixtureMaxBytes = 16 * 1024; +const isolatedPlatformSessionFixtureName = + '.deterministic-platform-session.json'; +const platformSessionFixtureSchema = + 'genarrative-agc-platform-session-fixture.v1'; + export function isolatedSuiteProtectsSourceAppData() { return ( isSupervisorSwarmTransientRetrySuite() || @@ -498,6 +506,116 @@ export async function verifySourceAppDataDirectoryUntouched() { state.isolatedRunner.sourceAppDataDirectoryUntouched = true; } +async function readPlatformSessionFixtureForIsolatedSuite(sourceConfigDir) { + const rawPath = process.env[platformSessionFixtureEnv]; + assert( + isNonEmptyString(rawPath) && path.isAbsolute(rawPath), + 'supervisor-autonomous-playable-platform-session-fixture-missing', + ); + const sourceRealPath = await fs.realpath(sourceConfigDir); + const requestedPath = path.resolve(rawPath); + const requestedMetadata = await fs.lstat(requestedPath).catch((error) => { + if (error?.code === 'ENOENT') return null; + throw error; + }); + assert( + requestedMetadata?.isFile() && !requestedMetadata.isSymbolicLink(), + 'supervisor-autonomous-playable-platform-session-fixture-not-regular', + ); + assert( + requestedMetadata.size <= platformSessionFixtureMaxBytes, + 'supervisor-autonomous-playable-platform-session-fixture-too-large', + ); + const realPath = await fs.realpath(requestedPath); + assert( + isPathInside(sourceRealPath, realPath), + 'supervisor-autonomous-playable-platform-session-fixture-outside-config', + ); + const bytes = await fs.readFile(realPath); + assert( + bytes.length <= platformSessionFixtureMaxBytes, + 'supervisor-autonomous-playable-platform-session-fixture-too-large', + ); + let fixture; + try { + fixture = JSON.parse(decodeUtf8Fatal(bytes, 'platform-session-fixture-invalid-utf8')); + } catch (error) { + throw codedError( + 'supervisor-autonomous-playable-platform-session-fixture-invalid', + error, + ); + } + const expectedKeys = [ + 'schemaVersion', + 'userId', + 'accessToken', + 'apiBaseUrl', + 'generation', + ]; + assert( + isPlainObject(fixture) && + JSON.stringify(Object.keys(fixture).sort()) === + JSON.stringify([...expectedKeys].sort()) && + fixture.schemaVersion === platformSessionFixtureSchema && + isNonEmptyString(fixture.userId) && + isNonEmptyString(fixture.accessToken) && + isNonEmptyString(fixture.apiBaseUrl) && + Number.isSafeInteger(fixture.generation) && + fixture.generation > 0, + 'supervisor-autonomous-playable-platform-session-fixture-invalid', + ); + return { + sourcePath: realPath, + bytes, + fixture, + sha256: createHash('sha256').update(bytes).digest('hex'), + }; +} + +async function installPlatformSessionFixtureIntoIsolatedAppData( + sourceConfigDir, + appDataDir, +) { + if (!isSupervisorAutonomousPlayableLaneDefenseSuite()) return; + const source = await readPlatformSessionFixtureForIsolatedSuite(sourceConfigDir); + const isolatedPath = path.join(appDataDir, isolatedPlatformSessionFixtureName); + await fs.copyFile( + source.sourcePath, + isolatedPath, + fsConstants.COPYFILE_EXCL | fsConstants.COPYFILE_FICLONE, + ); + await fs.chmod(isolatedPath, 0o600).catch(() => {}); + const isolatedMetadata = await fs.lstat(isolatedPath); + assert( + isolatedMetadata.isFile() && + !isolatedMetadata.isSymbolicLink() && + isolatedMetadata.size === source.bytes.length, + 'supervisor-autonomous-playable-platform-session-fixture-copy-invalid', + ); + const isolatedBytes = await fs.readFile(isolatedPath); + assert( + createHash('sha256').update(isolatedBytes).digest('hex') === source.sha256, + 'supervisor-autonomous-playable-platform-session-fixture-copy-mismatch', + ); + state.isolatedRunner.platformSessionFixtureSourcePath = source.sourcePath; + state.isolatedRunner.platformSessionFixturePath = isolatedPath; + state.isolatedRunner.platformSessionFixtureSha256 = source.sha256; + state.isolatedRunner.platformSessionFixturePreviousEnv = + Object.prototype.hasOwnProperty.call(process.env, platformSessionFixtureEnv) + ? process.env[platformSessionFixtureEnv] + : undefined; + process.env[platformSessionFixtureEnv] = isolatedPath; + state.formalConfigPathTranscriptScanner?.addSecrets( + absolutePathVariants(source.sourcePath, isolatedPath), + ); + const previousLeakCount = state.transcriptScanner?.count ?? 0; + state.secrets = [ + ...new Set([...state.secrets, source.fixture.accessToken]), + ]; + rebuildSupervisorSwarmTranscriptScanner(); + state.transcriptScanner.count = previousLeakCount; +} + export async function prepareIsolatedSuiteAppData({ streamAgentId = null, webSearchAgentId = null, @@ -769,6 +887,10 @@ export async function prepareIsolatedSuiteAppData({ state.secrets = [...suiteSecrets]; rebuildSupervisorSwarmTranscriptScanner(); state.transcriptScanner.count = previousLeakCount; + await installPlatformSessionFixtureIntoIsolatedAppData( + sourceConfigDir, + appDataDir, + ); const unexpectedEndpoint = await fs .lstat(path.join(appDataDir, runnerEndpointFileName)) .catch((error) => { @@ -1874,6 +1996,31 @@ export async function verifyIsolatedSuiteConfigLinksUnchanged() { 'isolated-source-config-changed-during-suite', ); } + const fixture = state.isolatedRunner; + if ( + fixture.platformSessionFixtureSourcePath && + fixture.platformSessionFixturePath && + fixture.platformSessionFixtureSha256 + ) { + const [sourceMetadata, isolatedMetadata, sourceBytes, isolatedBytes] = + await Promise.all([ + fs.lstat(fixture.platformSessionFixtureSourcePath), + fs.lstat(fixture.platformSessionFixturePath), + fs.readFile(fixture.platformSessionFixtureSourcePath), + fs.readFile(fixture.platformSessionFixturePath), + ]); + assert( + sourceMetadata.isFile() && + !sourceMetadata.isSymbolicLink() && + isolatedMetadata.isFile() && + !isolatedMetadata.isSymbolicLink() && + createHash('sha256').update(sourceBytes).digest('hex') === + fixture.platformSessionFixtureSha256 && + createHash('sha256').update(isolatedBytes).digest('hex') === + fixture.platformSessionFixtureSha256, + 'supervisor-autonomous-playable-platform-session-fixture-changed', + ); + } } export async function verifySourceConfigLinkCountsRestored() { @@ -1913,7 +2060,22 @@ export async function removeIsolatedSuiteAppData() { } catch (error) { ownershipError = error; } - await fs.rm(appDataDir, { recursive: true, force: false }); + try { + await fs.rm(appDataDir, { recursive: true, force: false }); + } finally { + if (state.isolatedRunner.platformSessionFixtureSourcePath) { + const previous = state.isolatedRunner.platformSessionFixturePreviousEnv; + if (previous === undefined) { + delete process.env[platformSessionFixtureEnv]; + } else { + process.env[platformSessionFixtureEnv] = previous; + } + } + state.isolatedRunner.platformSessionFixturePath = null; + state.isolatedRunner.platformSessionFixtureSourcePath = null; + state.isolatedRunner.platformSessionFixtureSha256 = null; + state.isolatedRunner.platformSessionFixturePreviousEnv = undefined; + } state.runtimeConfigDir = state.options.configDir; try { await verifySourceConfigLinkCountsRestored(); diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/process.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/process.mjs index d9c44b0d0..950b9646a 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/process.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/process.mjs @@ -84,6 +84,14 @@ export function buildCliChildEnvironment() { NO_COLOR: '1', RUST_BACKTRACE: '0', }; + // The deterministic playable suite copies its account fixture into the + // sibling isolated AppData directory. Set the path explicitly here so + // every CLI and the Runner it launches use the isolated copy, even if the + // parent harness environment was restored or changed after setup. + if (state.isolatedRunner.platformSessionFixturePath) { + environment.GENARRATIVE_AGC_PLATFORM_SESSION_FIXTURE = + state.isolatedRunner.platformSessionFixturePath; + } return isSupervisorSwarmTransientRetrySuite() ? withLoopbackNoProxy(environment) : environment; diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/runtime-state.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/runtime-state.mjs index a1bb6bb17..c27fa5aea 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/runtime-state.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/runtime-state.mjs @@ -241,7 +241,7 @@ export const autonomousCompletionContractSchemaVersion = 'game-creator-autonomous-completion-contract.v2'; export const autonomousPlaytestReceiptSchemaVersion = - 'game-creator-autonomous-playtest-receipt.v1'; + 'game-creator-autonomous-playtest-receipt.v2'; export const autonomousGameBuildRunProfile = 'autonomous-game-build'; @@ -938,6 +938,10 @@ export class BlockedError extends Error { export const isolatedRunnerState = { appDataDir: null, + platformSessionFixturePath: null, + platformSessionFixtureSourcePath: null, + platformSessionFixtureSha256: null, + platformSessionFixturePreviousEnv: undefined, ownerToken: null, createdAt: 0, current: null, diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-autonomous-playable.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-autonomous-playable.mjs index 1166f4858..6d988a64a 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-autonomous-playable.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-autonomous-playable.mjs @@ -1220,6 +1220,11 @@ export async function validateSupervisorAutonomousPlayableEvidence( agentId: receipt.agentId, runId: receipt.runId, runProfileBindingFingerprint: receipt.runProfileBindingFingerprint, + executorAgentId: receipt.executorAgentId, + executorRunId: receipt.executorRunId, + executorSource: receipt.executorSource, + executorRunProfileBindingFingerprint: + receipt.executorRunProfileBindingFingerprint, actionId: receipt.actionId, actionFingerprint: receipt.actionFingerprint, revision: receipt.revision, diff --git a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs index 4289684ec..8f81447ac 100644 --- a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs +++ b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs @@ -51,6 +51,16 @@ const deterministicReadOnlyReadyAgentIds = new Set([ 'preview-playtest', 'publish-strategy', ]); +// These owner tasks are checked by Runtime's owner-artifact gate. Their +// request-scoped tool catalog deliberately removes command.run_limited, so a +// deterministic response must deliver after a successful fixed-path write +// instead of trying to emit a tool that the runtime did not advertise. +const deterministicOwnerArtifactValidationAgentIds = new Set([ + 'design-foundation', + 'balance-seed', + 'art-asset-plan', + 'audio-asset-plan', +]); const deterministicProjectMutationTools = new Set([ 'file.write', 'file.patch', @@ -733,10 +743,28 @@ function readyTaskContext(context, agentId) { } function observationContext(context) { - const start = context.lastIndexOf('已有工具观察:'); + // The standard tool-plan prompt labels this section "已有工具观察:", + // while the relaxed autonomous prompt intentionally uses the shorter + // "已有观察:" label. Keep the parser independent of that presentation + // detail; otherwise a settled task.list observation is invisible to the + // deterministic parent and it will poll forever until the run budget ends. + const markers = ['已有工具观察:', '已有观察:', '工具观察:']; + let start = -1; + let markerLength = 0; + for (const marker of markers) { + const candidate = context.lastIndexOf(marker); + if (candidate > start) { + start = candidate; + markerLength = marker.length; + } + } if (start < 0) return ''; - const tail = context.slice(start + '已有工具观察:'.length); - const end = tail.indexOf('\n\n计划更新约定:'); + const tail = context.slice(start + markerLength); + const endMarkers = ['\n\n计划更新约定:', '\n\n工具 input 字段约定:']; + const ends = endMarkers + .map((marker) => tail.indexOf(marker)) + .filter((index) => index >= 0); + const end = ends.length > 0 ? Math.min(...ends) : -1; return end < 0 ? tail : tail.slice(0, end); } @@ -898,6 +926,7 @@ function readyTaskFinalizationCalls(context, agentId, tools) { export function createDeterministicLaneDefenseRouter({ apiKey, model = deterministicLaneDefenseModel, + relaxed = false, } = {}) { if (typeof apiKey !== 'string' || apiKey.length < 16) { throw providerError('provider-api-key-invalid'); @@ -914,7 +943,9 @@ export function createDeterministicLaneDefenseRouter({ const readyTaskPreCompletionRetryCounts = new Map(); const readyTaskReplayAvailableRuns = new Set(); const agentCounts = new Map(); + const relaxedAutonomous = relaxed === true; const stats = { + relaxedAutonomous, requestCount: 0, contextCompactionRequestCount: 0, planningRequestCount: 0, @@ -947,7 +978,7 @@ export function createDeterministicLaneDefenseRouter({ byAgent: {}, }; let responseSequence = 0; - let parentStage = 'goal-contract'; + let parentStage = relaxedAutonomous ? 'await-manifest' : 'goal-contract'; function updateReadyTaskCounts(agentId, kind) { const current = stats.readyTaskCountsByAgent[agentId] ?? { @@ -959,11 +990,11 @@ export function createDeterministicLaneDefenseRouter({ } function recordReadyTaskRun(agentId, runId) { - if (!deterministicManifestReadyAgentIds.includes(agentId)) { + if (!relaxedAutonomous && !deterministicManifestReadyAgentIds.includes(agentId)) { throw providerError(`provider-ready-agent-unsupported:${agentId}`); } const existingRunId = readyTaskRunIdsByAgent.get(agentId); - if (existingRunId && existingRunId !== runId) { + if (!relaxedAutonomous && existingRunId && existingRunId !== runId) { throw providerError(`provider-ready-agent-run-duplicate:${agentId}`); } readyTaskRunIdsByAgent.set(agentId, runId); @@ -974,10 +1005,10 @@ export function createDeterministicLaneDefenseRouter({ } function recordReadyTaskCompletion(agentId, runId) { - if (readyTaskRunIdsByAgent.get(agentId) !== runId) { + if (!relaxedAutonomous && readyTaskRunIdsByAgent.get(agentId) !== runId) { throw providerError(`provider-ready-run-identity-invalid:${agentId}`); } - if (completedReadyTaskRuns.has(runId)) { + if (!relaxedAutonomous && completedReadyTaskRuns.has(runId)) { throw providerError(`provider-ready-run-terminal-duplicate:${agentId}`); } completedReadyTaskRuns.add(runId); @@ -1013,6 +1044,19 @@ export function createDeterministicLaneDefenseRouter({ } function readyTaskCompleteResponse(agentId, runId, tools, context) { + if (relaxedAutonomous) { + if (!tools.has('respond_to_user')) { + throw providerError( + `provider-relaxed-ready-finalization-tool-missing:${agentId}`, + ); + } + if (!completedReadyTaskRuns.has(runId)) { + recordReadyTaskCompletion(agentId, runId); + } + return readyCallsResponse(agentId, runId, tools, [ + nativeResponse(`${agentId} 的任务已完成。`), + ]); + } if (completedReadyTaskRuns.has(runId)) { const retryCount = readyTaskCompletionRetryCounts.get(runId) ?? 0; const expectedVerificationTool = @@ -1274,6 +1318,22 @@ export function createDeterministicLaneDefenseRouter({ ); } + function manifestTasksSettled(context) { + const matches = [ + ...observationContext(context).matchAll( + /seedTaskCounts: completed=(\d+) running=(\d+) pending=(\d+) waiting=(\d+) failed=(\d+) total=(\d+)/g, + ), + ]; + const match = matches.at(-1); + return ( + match !== undefined && + Number(match[2]) === 0 && + Number(match[3]) === 0 && + Number(match[4]) === 0 && + Number(match[1]) + Number(match[5]) === Number(match[6]) + ); + } + function publishAgentCounts() { stats.byAgent = Object.fromEntries( [...agentCounts.entries()].sort(([left], [right]) => @@ -1334,6 +1394,36 @@ export function createDeterministicLaneDefenseRouter({ } function parentCalls(context, tools) { + if (relaxedAutonomous) { + if (manifestTasksSettled(context)) { + if (!tools.has('respond_to_user')) { + throw providerError('provider-relaxed-parent-respond-tool-missing'); + } + parentStage = 'done'; + return callsResponse('project-supervisor', tools, [ + nativeResponse('项目任务已经完成,已交回总控。'), + ]); + } + if (tools.has(runtimeFunction('task.list'))) { + const calls = [ + nativeAction('task.list', '查看并行任务当前状态', {}), + ]; + if (tools.has(runtimeFunction('agent.run_status'))) { + stats.runStatusCount += 1; + calls.push( + runStatusCall('读取并行任务的最新运行状态'), + ); + } + return callsResponse('project-supervisor', tools, calls); + } + if (tools.has('respond_to_user')) { + parentStage = 'done'; + return callsResponse('project-supervisor', tools, [ + nativeResponse('已完成当前自主构建回合。'), + ]); + } + throw providerError('provider-relaxed-parent-tools-invalid'); + } const goalContractTool = runtimeFunction('agent.goal_contract'); if (tools.size === 1 && tools.has(goalContractTool)) { if (parentStage !== 'goal-contract') { @@ -1650,6 +1740,42 @@ export function createDeterministicLaneDefenseRouter({ { path, content, writeReason }, ) { const phase = ensureReadyTaskRun(runId, agentId); + const hasManualVerification = tools.has( + runtimeFunction('command.run_limited'), + ); + const latestObservation = latestToolObservation(context); + const latestWriteBlocked = + latestObservation?.tool === 'file.write' && + ['blocked', 'failed'].includes(latestObservation?.status); + if (latestWriteBlocked && tools.has(runtimeFunction('file.write'))) { + stats.sourceWriteCount += 1; + stats.manifestReadyTaskFileWriteCount += 1; + return readyCallsResponse(agentId, runId, tools, [ + fileWriteCall(path, content, `重试写入 ${path} 并交给 Runtime 收束门验证`), + ]); + } + // Runtime validates fixed owner artifacts after the owner responds. Once + // the write itself is accepted, finish the task directly when the manual + // verification tool is absent. This branch is intentionally limited to + // the four owner-artifact agents above; code-prototype and publish-package + // still require their explicit smoke contracts. + if ( + deterministicOwnerArtifactValidationAgentIds.has(agentId) && + !hasManualVerification && + latestObservation?.tool === 'file.write' && + latestObservation.status === 'ok' + ) { + const calls = readyTaskFinalizationCalls(context, agentId, tools); + if (calls.some((call) => call.name === 'respond_to_user')) { + recordReadyTaskCompletion(agentId, runId); + } + return readyCallsResponse( + agentId, + runId, + tools, + calls, + ); + } const recovery = runData.get(runId)?.writerRecovery ?? null; const observations = observationContext(context); if (recovery === 'after-write') { @@ -1671,6 +1797,10 @@ export function createDeterministicLaneDefenseRouter({ if (completedReadyTaskRuns.has(runId)) { readyTaskCompletionRetryArmedRuns.add(runId); } + if (!hasManualVerification) { + runData.delete(runId); + return readyTaskCompleteResponse(agentId, runId, tools, context); + } stats.staticSmokeCount += 1; stats.manifestReadyTaskStaticSmokeCount += 1; return readyCallsResponse(agentId, runId, tools, [ @@ -1704,12 +1834,13 @@ export function createDeterministicLaneDefenseRouter({ } if (phase === 1) { stats.manifestReadyTaskFileReadCount += 1; - stats.staticSmokeCount += 1; - stats.manifestReadyTaskStaticSmokeCount += 1; - return readyCallsResponse(agentId, runId, tools, [ - fileReadCall(path, `回读并核对 ${path}`), - staticSmokeCall(`验证 ${path} 写入后的当前 revision`), - ]); + const calls = [fileReadCall(path, `回读并核对 ${path}`)]; + if (hasManualVerification) { + stats.staticSmokeCount += 1; + stats.manifestReadyTaskStaticSmokeCount += 1; + calls.push(staticSmokeCall(`验证 ${path} 写入后的当前 revision`)); + } + return readyCallsResponse(agentId, runId, tools, calls); } if (phase === 2) { return readyTaskCompleteResponse(agentId, runId, tools, context); @@ -1955,8 +2086,56 @@ export function createDeterministicLaneDefenseRouter({ ); } + function relaxedManifestReadyCalls(agentId, runId, tools, context) { + const phase = ensureReadyTaskRun(runId, agentId); + const writeSpecs = { + 'code-prototype': { + path: 'game/index.html', + content: deterministicLaneDefenseCanonicalHtml(), + reason: '写入可运行的游戏入口', + }, + 'design-foundation': { + path: 'memory/project.md', + content: deterministicProjectMemory, + reason: '写入项目基础说明', + }, + 'balance-seed': { + path: 'game/balance.json', + content: `${JSON.stringify(deterministicLaneDefenseBalance, null, 2)}\n`, + reason: '写入初版数值', + }, + 'art-asset-plan': { + path: 'assets/manifest.art.json', + content: `${JSON.stringify(deterministicArtManifest(false, 'relaxed'), null, 2)}\n`, + reason: '写入美术清单(平台素材可后续补齐)', + }, + 'audio-asset-plan': { + path: 'assets/manifest.audio.json', + content: `${JSON.stringify(deterministicAudioManifest, null, 2)}\n`, + reason: '写入声音清单', + }, + 'publish-package': { + path: 'exports/README.md', + content: deterministicPublishReadme, + reason: '写入发布说明', + }, + }; + const spec = writeSpecs[agentId]; + if (phase === 0 && spec && tools.has(runtimeFunction('file.write'))) { + stats.sourceWriteCount += 1; + stats.manifestReadyTaskFileWriteCount += 1; + return readyCallsResponse(agentId, runId, tools, [ + fileWriteCall(spec.path, spec.content, spec.reason), + ]); + } + return readyTaskCompleteResponse(agentId, runId, tools, context); + } + function manifestReadyCalls(identity, tools, context) { const { agentId, runId } = identity; + if (relaxedAutonomous) { + return relaxedManifestReadyCalls(agentId, runId, tools, context); + } if ( [ 'design-director', @@ -2016,13 +2195,13 @@ export function createDeterministicLaneDefenseRouter({ if (finalReplyRuns.has(key)) { throw providerError('provider-duplicate-final-reply-request'); } - if ( + if (!relaxedAutonomous && !context.includes('给用户一个正常中文回复') && !context.includes('给开发者一个正常中文回复') ) { throw providerError('provider-unexpected-text-request'); } - if (identity.agentId === 'project-supervisor' && parentStage !== 'done') { + if (!relaxedAutonomous && identity.agentId === 'project-supervisor' && parentStage !== 'done') { throw providerError('provider-parent-final-reply-before-acceptance'); } finalReplyRuns.add(key); @@ -2259,11 +2438,33 @@ function sendBytes(response, statusCode, contentType, bytes) { response.end(bytes); } +// Debug builds intentionally exercise the first-party account surface. The +// deterministic fixture itself is written against the canonical External +// Editor contract so that the same responses cover both build modes. Keep +// this translation local to the fixture; production routing remains owned by +// the Rust client and the server's public contracts. +function canonicalCanvasApiPath(pathname) { + if (pathname.startsWith('/api/external/v1/')) return pathname; + if (pathname.startsWith('/api/editor/')) { + return `/api/external/v1${pathname.slice('/api'.length)}`; + } + if (pathname.startsWith('/api/assets/')) { + return `/api/external/v1${pathname.slice('/api'.length)}`; + } + const generationJobsPrefix = '/api/runtime/external-generation/jobs/'; + if (pathname.startsWith(generationJobsPrefix)) { + return `/api/external/v1/generations/${pathname.slice(generationJobsPrefix.length)}`; + } + return null; +} + function createDeterministicCanvasFixture(apiKey) { const projectId = 'deterministic-canvas-project'; const folderId = 'deterministic-canvas-folder'; const images = new Map(); const generationOperations = new Map(); + const uploadedObjects = new Map(); + const registeredResources = new Map(); const imageCache = new Map(); const stats = { canvasApiRequestCount: 0, @@ -2275,6 +2476,7 @@ function createDeterministicCanvasFixture(apiKey) { let projectTitle = null; let folderLabel = null; let generationSequence = 0; + let uploadSequence = 0; function json(statusCode, body) { return { @@ -2308,8 +2510,23 @@ function createDeterministicCanvasFixture(apiKey) { async function handle(request) { const parsed = new URL(request.url ?? '/', 'http://127.0.0.1'); const isSignedImage = parsed.pathname.startsWith('/signed/'); - const isCanvasApi = parsed.pathname.startsWith('/api/external/v1/'); - if (!isSignedImage && !isCanvasApi) return null; + const canonicalPath = canonicalCanvasApiPath(parsed.pathname); + const isCanvasApi = canonicalPath !== null; + // Direct-upload tickets point back at this loopback server. The fixture + // does not need to inspect multipart bytes; draining the request and + // acknowledging the configured success status is sufficient because the + // subsequent confirm call is the authoritative object registration step. + const isDirectUpload = + request.method === 'POST' && parsed.pathname === '/' && !isCanvasApi; + if (!isSignedImage && !isCanvasApi && !isDirectUpload) return null; + if (isDirectUpload) { + request.resume(); + return { + statusCode: 204, + contentType: 'text/plain; charset=utf-8', + bytes: Buffer.alloc(0), + }; + } stats.canvasApiRequestCount += 1; if ( !isSignedImage && @@ -2321,7 +2538,7 @@ function createDeterministicCanvasFixture(apiKey) { if ( request.method === 'GET' && - parsed.pathname === '/api/external/v1/editor/projects' + canonicalPath === '/api/external/v1/editor/projects' ) { request.resume(); return json(200, { @@ -2332,7 +2549,7 @@ function createDeterministicCanvasFixture(apiKey) { } if ( request.method === 'POST' && - parsed.pathname === '/api/external/v1/editor/projects' + canonicalPath === '/api/external/v1/editor/projects' ) { const body = await readJsonBody(request); projectTitle = @@ -2345,7 +2562,7 @@ function createDeterministicCanvasFixture(apiKey) { } if ( request.method === 'GET' && - parsed.pathname === '/api/external/v1/editor/assets/library' + canonicalPath === '/api/external/v1/editor/assets/library' ) { request.resume(); return json(200, { @@ -2358,7 +2575,7 @@ function createDeterministicCanvasFixture(apiKey) { } if ( request.method === 'POST' && - parsed.pathname === '/api/external/v1/editor/assets/folders' + canonicalPath === '/api/external/v1/editor/assets/folders' ) { const body = await readJsonBody(request); folderLabel = @@ -2369,7 +2586,7 @@ function createDeterministicCanvasFixture(apiKey) { } if ( request.method === 'POST' && - parsed.pathname === + canonicalPath === '/api/external/v1/editor/icon-spritesheets/generations' ) { const idempotencyKey = request.headers['idempotency-key']; @@ -2418,6 +2635,11 @@ function createDeterministicCanvasFixture(apiKey) { height: 256, bytes: deterministicPng(256, 256, { variant: generationSequence * 10 + index + 1, + // The canonical art contract requires every independently + // usable slice to retain transparent pixels. Keep the fixture + // faithful to the External Editor response instead of making the + // runtime relax that final validation. + transparent: true, }), objectKey: sliceObjectKey, downloadKind: 'slice', @@ -2467,6 +2689,7 @@ function createDeterministicCanvasFixture(apiKey) { 'deterministic spritesheet fixture', model: 'deterministic-canvas-v1', provider: 'deterministic-loopback', + sliceLayout: 'grid-2x2', spritesheetResource: { resourceId, projectId, @@ -2500,7 +2723,7 @@ function createDeterministicCanvasFixture(apiKey) { } if ( request.method === 'POST' && - parsed.pathname === '/api/external/v1/editor/images/generations' + canonicalPath === '/api/external/v1/editor/images/generations' ) { const idempotencyKey = request.headers['idempotency-key']; if ( @@ -2577,10 +2800,10 @@ function createDeterministicCanvasFixture(apiKey) { } if ( request.method === 'GET' && - parsed.pathname.startsWith('/api/external/v1/generations/') + canonicalPath?.startsWith('/api/external/v1/generations/') ) { request.resume(); - const operationId = parsed.pathname.slice( + const operationId = canonicalPath.slice( '/api/external/v1/generations/'.length, ); const result = generationOperations.get(operationId); @@ -2601,7 +2824,7 @@ function createDeterministicCanvasFixture(apiKey) { } if ( request.method === 'GET' && - parsed.pathname === '/api/external/v1/assets/read-url' + canonicalPath === '/api/external/v1/assets/read-url' ) { request.resume(); const objectKey = parsed.searchParams.get('objectKey'); @@ -2625,6 +2848,85 @@ function createDeterministicCanvasFixture(apiKey) { }, }); } + + if ( + request.method === 'POST' && + canonicalPath === '/api/external/v1/assets/direct-upload-tickets' + ) { + const body = await readJsonBody(request); + uploadSequence += 1; + const pathSegments = Array.isArray(body?.pathSegments) + ? body.pathSegments.filter( + (segment) => typeof segment === 'string' && segment.trim(), + ) + : []; + const objectKey = + pathSegments.length > 0 + ? `${pathSegments.join('/')}/deterministic-reference-${uploadSequence}.png` + : `generated/deterministic/reference-${uploadSequence}.png`; + const assetObjectId = `asset-object-reference-${uploadSequence}`; + uploadedObjects.set(objectKey, { assetObjectId }); + const host = request.headers.host + ? `http://${request.headers.host}` + : `http://${LOOPBACK_HOST}`; + return json(200, { + data: { + upload: { + host, + bucket: 'deterministic', + objectKey, + successActionStatus: 204, + maxSizeBytes: MAX_REQUEST_BYTES, + formFields: { key: objectKey }, + }, + }, + }); + } + + if ( + request.method === 'POST' && + canonicalPath === '/api/external/v1/assets/objects/confirm' + ) { + const body = await readJsonBody(request); + const objectKey = + typeof body?.objectKey === 'string' && body.objectKey.trim() + ? body.objectKey.trim() + : null; + if (!objectKey) { + return json(400, { error: { message: 'objectKey is required' } }); + } + const existing = uploadedObjects.get(objectKey); + const assetObjectId = + existing?.assetObjectId ?? `asset-object-confirmed-${++uploadSequence}`; + uploadedObjects.set(objectKey, { assetObjectId }); + return json(200, { + data: { assetObject: { objectKey, assetObjectId } }, + }); + } + + if ( + request.method === 'POST' && + canonicalPath?.match(/^\/api\/external\/v1\/editor\/projects\/[^/]+\/resources$/) + ) { + const body = await readJsonBody(request); + const objectKey = + typeof body?.objectKey === 'string' && body.objectKey.trim() + ? body.objectKey.trim() + : `resource-${registeredResources.size + 1}`; + if (!registeredResources.has(objectKey)) { + registeredResources.set(objectKey, { + resourceId: `resource-reference-${registeredResources.size + 1}`, + }); + } + return json(200, { + data: { + resource: { + ...(body && typeof body === 'object' ? body : {}), + resourceId: registeredResources.get(objectKey).resourceId, + }, + }, + }); + } if (request.method === 'GET' && isSignedImage) { request.resume(); const imageId = parsed.pathname.slice('/signed/'.length, -'.png'.length); @@ -2654,9 +2956,10 @@ function createDeterministicCanvasFixture(apiKey) { export async function startDeterministicLaneDefenseProvider({ apiKey, model = deterministicLaneDefenseModel, + relaxed = false, fallbackPorts = DEFAULT_FALLBACK_PORTS, } = {}) { - const router = createDeterministicLaneDefenseRouter({ apiKey, model }); + const router = createDeterministicLaneDefenseRouter({ apiKey, model, relaxed }); const canvasFixture = createDeterministicCanvasFixture(apiKey); const sockets = new Set(); let stopped = false; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 70793c715..55c1cf8e0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -13,7 +13,7 @@ const MAX_DIRECT_HOME_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96; const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6; const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160; const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。"; -const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 Codex cwd 就是用户选择的整个项目目录(工作区根),游戏源码、素材、音效和资源全部直接放在该根下;原生文件工具、原生 patch 和命令参数中的文件路径必须相对于当前 cwd:合法写法是 `index.html`、`style.css`、`game.js`、`assets/hero.png`,禁止写 `../`、项目根绝对路径或任何其它父目录路径;`game/...` 仅用于兼容旧项目结构,不是当前 cwd 的强制布局。`.agent/`、`.git/`、密钥文件和 Runtime 控制面由客户端维护,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill,以及经审核的 `agc_tools` MCP;普通单张图片、角色图、视觉规范图、UI 设计图和发布宣传图使用 `agc_tools.agc_generate_image`,已有图片修改使用 `agc_tools.agc_edit_image`,完整游戏美术包和 canonical 切片才使用 `agc_tools.taonier_prepare_game_art`,视频、角色动画、音效、背景音乐、浏览器试玩、资源登记和受控联网搜索等带 AGC 账本的动作也使用 `agc_tools`。按用户意图自行选择并执行,不要把普通图片误报成只能生成美术包,也不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径。项目锁、付费提交、幂等键、下载校验和客户端投影仍由客户端确定性掌管。游戏文件真实变化后由客户端登记资源和版本,Codex 不直接保存或伪造项目版本。"; +const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同(仅说明项目边界,不是流程门槛):当前 Codex cwd 是用户选择的项目目录(工作区根),源码、素材、音效和其它资源按项目现有结构放置;先按需读取当前 cwd 下适用的 `AGENTS.md`、README 或项目说明,把它们当作项目规范参考。原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill,以及经审核的 `agc_tools` MCP。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图或发布宣传图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;`agc_read_skill_resource` 用于按需读取审核 Skill。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png"; const DIRECT_CODEX_BACKGROUND_ASSET_PATH: &str = "assets/direct-game-background.png"; const DIRECT_CODEX_SPRITESHEET_ASSET_PATH: &str = "assets/art-spritesheet.png"; @@ -3840,6 +3840,12 @@ pub(crate) async fn run_direct_game_creator_turn_at( root: &Path, prompt: &str, ) -> Result { + // The CLI entry point does not receive the GUI's clientTurnId. Still arm + // one invocation identity so an otherwise optional AGC generation tool + // cannot fail merely because the request came through the CLI. This is + // local execution identity only; it does not create a Runtime task or DAG. + let invocation_id = format!("direct-cli-{}", unix_millis()); + let _invocation = DirectTaonierActiveInvocationGuard::enter(root, &invocation_id)?; run_direct_game_creator_turn_at_with_creation_type(root, prompt, None).await } @@ -4398,9 +4404,11 @@ mod tests { assert!(!prompt.contains("你是 Codex")); assert!(prompt.contains("不要等待 Supervisor")); assert!(prompt.contains("提示词与技能")); - assert!(prompt.contains("合法写法是 `index.html`、`style.css`、`game.js`")); - assert!(prompt.contains("用户选择的整个项目目录")); - assert!(prompt.contains("禁止写 `../`")); + assert!(prompt.contains("AGC 工程合同(仅说明项目边界,不是流程门槛)")); + assert!(prompt.contains("先按需读取当前 cwd 下适用的 `AGENTS.md`")); + assert!(prompt.contains("agc_write_file")); + assert!(prompt.contains("切图、资源依赖、规范图和试玩都只是可选工具提示")); + assert!(prompt.contains("不要求调用、固定顺序或特定产物")); } #[test] @@ -4562,6 +4570,7 @@ mod tests { let root = tempfile::tempdir().expect("temp dir"); let prompt = build_direct_codex_system_prompt(root.path()).expect("build direct system prompt"); + assert!(prompt.contains("agc_write_file")); assert!(prompt.contains("agc_tools.taonier_prepare_game_art")); assert!(prompt.contains("agc_tools.agc_generate_image")); assert!(prompt.contains("agc_tools.agc_browser_playtest")); @@ -4569,6 +4578,7 @@ mod tests { assert!(!prompt.contains("客户端会在系统上下文提供有界的当前游戏文件快照")); assert!(prompt.contains("Codex 不直接保存或伪造项目版本")); assert!(prompt.contains("普通对话直接回答且不触碰工作区")); + assert!(prompt.contains("切图、资源依赖、规范图和试玩都只是可选工具提示")); assert!(prompt.contains("用户不需要、也不得向你提供、配置、粘贴或创建 API Key")); assert!(prompt.contains("工具返回 401/403 时,只说明 AGC 客户端登录或权限状态异常并停止")); assert!(!prompt.contains("Use real platform assets only")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 0470d5a8a..23ff43678 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -13,7 +13,8 @@ use unicode_normalization::UnicodeNormalization; pub(crate) const DIRECT_TOOL_BRIDGE_PROTOCOL: &str = "genarrative-agc-tool-bridge.v1"; pub(crate) const DIRECT_TOOL_BRIDGE_URL_ENV: &str = "GENARRATIVE_AGC_TOOL_BRIDGE_URL"; -const DIRECT_TOOL_BRIDGE_MAX_REQUEST_BYTES: usize = 16 * 1024; +const DIRECT_TOOL_BRIDGE_MAX_REQUEST_BYTES: usize = 2 * 1024 * 1024; +const DIRECT_TOOL_BRIDGE_MAX_WRITE_CONTENT_BYTES: usize = 1_500_000; const DIRECT_TOOL_BRIDGE_MAX_ART_BRIEF_CHARS: usize = 4_000; const DIRECT_TOOL_BRIDGE_MAX_IMAGE_PROMPT_CHARS: usize = 32_000; const DIRECT_TOOL_BRIDGE_MAX_IMAGE_BYTES: u64 = 6 * 1024 * 1024; @@ -1392,6 +1393,54 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value { } } +fn bridge_write_file(root: &Path, arguments: &Value) -> Value { + let result = (|| { + bridge_reject_unknown_fields(arguments, &["path", "content"])?; + enforce_project_permission_policy(root, "file.write")?; + let raw_path = bridge_bounded_string( + arguments, + "path", + DIRECT_TOOL_BRIDGE_MAX_LOCAL_ASSET_PATH_CHARS, + )?; + let path = normalize_relative_path(&raw_path)?; + if bridge_project_file_is_hidden_control_path(&path) + || reject_sensitive_project_file_read(&path).is_err() + { + return Err("工具参数 path 不得访问受保护项目控制面".to_string()); + } + let content = arguments + .get("content") + .and_then(Value::as_str) + .ok_or_else(|| "工具参数 content 必须是字符串".to_string())?; + if content.len() > DIRECT_TOOL_BRIDGE_MAX_WRITE_CONTENT_BYTES { + return Err(format!( + "工具参数 content 超过 {} bytes", + DIRECT_TOOL_BRIDGE_MAX_WRITE_CONTENT_BYTES + )); + } + if content.chars().any(|character| character == '\0') { + return Err("工具参数 content 不能包含 NUL".to_string()); + } + let _lock = acquire_project_write_lock(root, "direct-codex.file.write")?; + let written = write_local_project_file_at(root, &path, content)?; + let revision = advance_agent_runtime_project_revision_locked(root)?; + Ok::<_, String>(json!({ + "status": "completed", + "path": written.path, + "bytes": content.len(), + "revision": revision, + })) + })(); + match result { + Ok(result) => bridge_tool_result(result.to_string(), Vec::new(), false), + Err(error) => bridge_tool_result( + redact_agent_runtime_error(root, &error, 480), + Vec::new(), + true, + ), + } +} + fn bridge_safe_account_asset_projection(asset: &Value) -> Option { let asset_id = asset.get("assetId").and_then(Value::as_str)?; if asset_id.trim().is_empty() { @@ -2192,6 +2241,7 @@ async fn handle_direct_tool_bridge( bridge_list_registered_assets(&state.root, &request.arguments) } "agc_list_project_files" => bridge_list_project_files(&state.root, &request.arguments), + "agc_write_file" => bridge_write_file(&state.root, &request.arguments), "agc_list_account_assets" => bridge_list_account_assets(&state, &request.arguments).await, "agc_import_account_assets" => { bridge_import_account_assets(&state, &request.arguments).await @@ -2400,6 +2450,46 @@ mod tests { assert_eq!(importability.get("assets/vector.svg"), Some(&false)); } + #[test] + fn bridge_write_file_writes_project_relative_text_without_runtime_tasks() { + let temporary = tempfile::tempdir().expect("create direct write root"); + init_local_game_project_at(temporary.path(), "direct-write", "Direct 写入工具测试") + .expect("initialize direct write root"); + let result = bridge_write_file( + temporary.path(), + &json!({ + "path": "game/index.html", + "content": "" + }), + ); + assert_eq!(result.get("isError").and_then(Value::as_bool), Some(false)); + let payload: Value = serde_json::from_str( + result + .pointer("/content/0/text") + .and_then(Value::as_str) + .expect("direct write result text"), + ) + .expect("parse direct write result"); + assert_eq!(payload["status"], "completed"); + assert_eq!(payload["path"], "game/index.html"); + assert_eq!( + fs::read_to_string(temporary.path().join("game/index.html")).expect("read written"), + "" + ); + assert!( + bridge_write_file( + temporary.path(), + &json!({ + "path": ".agent/manifest.json", + "content": "{}" + }), + ) + .get("isError") + .and_then(Value::as_bool) + == Some(true) + ); + } + #[test] fn resource_request_uuid_is_stable_v4_and_domain_separated() { let operation = direct_resource_request_uuid("turn-1", "operation", "abc"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 31cbeaf63..ab7c20c5a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -6,12 +6,13 @@ use std::path::{Path, PathBuf}; pub(crate) const DIRECT_TOOLS_MCP_MODE_FLAG: &str = "--agc-direct-tools-mcp"; pub(crate) const DIRECT_TOOLS_MCP_CONTROLLED_WEB_SEARCH_ENV: &str = "AGC_CONTROLLED_WEB_SEARCH_ENABLED"; -const DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES: usize = 1024 * 1024; +const DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES: usize = 2 * 1024 * 1024; const DIRECT_TOOLS_MCP_MAX_ART_BRIEF_CHARS: usize = 4_000; const DIRECT_TOOLS_MCP_MAX_IMAGE_PROMPT_CHARS: usize = 32_000; const DIRECT_TOOLS_MCP_MAX_SEARCH_QUERY_CHARS: usize = 400; const DIRECT_TOOLS_MCP_MAX_RESOURCE_PROMPT_CHARS: usize = 4_000; const DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS: usize = 120; +const DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES: usize = 1_500_000; const DIRECT_TOOLS_MCP_MAX_BRIDGE_RESPONSE_BYTES: usize = 32 * 1024 * 1024; pub(crate) fn direct_tools_mcp_mode_requested(args: &[String]) -> bool { @@ -62,6 +63,27 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { "additionalProperties": false } }), + json!({ + "name": "agc_write_file", + "description": "把文本写入当前 AGC 项目的相对路径。Codex 可以按需使用它直接推进代码、配置、资源依赖或说明文件;客户端只负责项目路径和基本控制面边界,不要求固定文件、任务顺序、验证或完成回执。", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "当前项目根下的相对路径,例如 game/index.html、assets/manifest.json 或 data/gameplay-spec.md" + }, + "content": { + "type": "string", + "maxLength": DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES + } + }, + "required": ["path", "content"], + "additionalProperties": false + } + }), json!({ "name": "taonier_prepare_game_art", "description": "创建或安全恢复当前 AGC 项目的陶泥儿标准游戏美术包。付费提交、幂等键、operation 恢复、来源校验、下载解码和登记均由客户端确定性执行。授权由 AGC 客户端当前登录会话和受控后端完成,用户不需要提供、配置、粘贴或创建 API Key;401/403 只能报告为客户端登录或权限状态异常,不得向用户索要凭据或暴露内部 URL。regenerate 还必须通过客户端对当前用户消息签发的单回合稳定调用授权;模型参数和 MCP 自动批准本身不构成替换授权。仅在用户意图确实需要新美术时调用。", @@ -381,6 +403,43 @@ fn call_agc_read_skill_resource(arguments: &Value) -> Value { } } +async fn call_agc_write_file(arguments: &Value) -> Value { + if let Err(error) = validate_write_file_arguments(arguments) { + return mcp_tool_result(error, Vec::new(), true); + } + call_client_tool_bridge("agc_write_file", arguments).await +} + +fn validate_write_file_arguments(arguments: &Value) -> Result<(), String> { + validate_tool_object_fields(arguments, &["path", "content"])?; + let path = bounded_tool_string(arguments, "path", 512)?; + if path.split('/').any(|part| { + part.eq_ignore_ascii_case(".agent") + || part.eq_ignore_ascii_case(".git") + || part.eq_ignore_ascii_case(".codex") + || part.eq_ignore_ascii_case(".hermes") + || part.eq_ignore_ascii_case("node_modules") + }) { + return Err("工具参数 path 不得访问受保护项目控制面".to_string()); + } + let content = arguments + .get("content") + .and_then(Value::as_str) + .ok_or_else(|| "工具参数 content 必须是字符串".to_string())?; + if content.len() > DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES { + return Err(format!( + "工具参数 content 超过 {} bytes", + DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES + )); + } + if content.chars().any(|character| character == '\0') { + return Err("工具参数 content 不能包含 NUL".to_string()); + } + // Keep path normalization in the client bridge as the final authority; + // this early check only gives Codex a quick, deterministic argument error. + normalize_relative_path(&path).map(|_| ()) +} + fn mcp_success(id: Value, result: Value) -> Value { json!({ "jsonrpc": "2.0", "id": id, "result": result }) } @@ -994,6 +1053,7 @@ async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option .unwrap_or_else(|| json!({})); let result = match tool { "agc_read_skill_resource" => call_agc_read_skill_resource(&arguments), + "agc_write_file" => call_agc_write_file(&arguments).await, "taonier_prepare_game_art" => call_taonier_prepare_game_art(&arguments).await, "agc_generate_image" => call_agc_generate_image(&arguments).await, "agc_edit_image" => call_agc_edit_image(&arguments).await, @@ -1115,6 +1175,11 @@ mod tests { #[test] fn tool_catalog_preserves_reviewed_resource_contracts() { + assert!( + DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES + > DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES + 1024, + "MCP request envelope must fit the advertised file-write payload" + ); let specs = direct_tools_mcp_specs(); let names = specs["tools"] .as_array() @@ -1126,6 +1191,7 @@ mod tests { names, vec![ "agc_read_skill_resource", + "agc_write_file", "taonier_prepare_game_art", "agc_generate_image", "agc_edit_image", @@ -1208,6 +1274,16 @@ mod tests { assert!(resource_tool["inputSchema"]["required"] .as_array() .is_some_and(|required| required.iter().any(|field| field == "assetName"))); + assert!(validate_write_file_arguments(&json!({ + "path": "game/index.html", + "content": "" + })) + .is_ok()); + assert!(validate_write_file_arguments(&json!({ + "path": ".agent/manifest.json", + "content": "{}" + })) + .is_err()); assert_eq!( tool_art_preparation_mode(&json!({})).expect("safe default"), "reuse-or-create" 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 9abd01bd4..11fb9e900 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 @@ -25,6 +25,7 @@ pub(crate) use canvas_generation::{ }; pub(in crate::agent) use canvas_generation::{ commit_prepared_platform_art_asset_at, + commit_prepared_platform_art_asset_strict_slices_at, generate_platform_art_asset_with_retained_runtime_options_at, generate_platform_art_asset_with_runtime_options_at, platform_art_generation_error_result_unknown, register_existing_platform_art_slices_at, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs index ae6eeb361..d9fc39bd2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs @@ -37,6 +37,7 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ pending_action: Option<&AgentRuntimePendingToolAction>, ) -> AgentRuntimeToolObservation { let tool = action.tool.trim(); + let relaxed_autonomous = autonomous_relaxed_run_at(root, agent_id, run_id).unwrap_or(false); if tool == PLAN_SUBMIT_GDD_TOOL && agent_id.trim() != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { return AgentRuntimeToolObservation { tool: tool.to_string(), @@ -81,13 +82,19 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ }; } } - if let Some(blocker) = supervisor_orchestrator_mutation_block_at(root, agent_id, run_id, tool) { - return blocker; + if !relaxed_autonomous { + if let Some(blocker) = + supervisor_orchestrator_mutation_block_at(root, agent_id, run_id, tool) + { + return blocker; + } } - if let Some(blocker) = - agent_runtime_autonomous_art_director_canvas_only_action_block(agent_id, task, tool) - { - return blocker; + if !relaxed_autonomous { + if let Some(blocker) = + agent_runtime_autonomous_art_director_canvas_only_action_block(agent_id, task, tool) + { + return blocker; + } } let command_id = game_creator_agent_runtime_tool_command_id(tool); if let Some(command_id) = command_id { @@ -379,7 +386,7 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ "command.run_limited" => { observe_agent_runtime_limited_command(root, agent_id, run_id, &action.input) } - "preview.start" => observe_agent_runtime_preview_start(root, agent_id), + "preview.start" => observe_agent_runtime_preview_start(root, agent_id, run_id), "preview.validate" => { observe_agent_runtime_preview_validate( root, @@ -649,6 +656,7 @@ pub(in crate::agent) fn validate_agent_runtime_project_snapshot_action_after_loc )); } let pending = &durable_pending; + let relaxed_autonomous = autonomous_relaxed_run_profile(&pending.run_profile); let runtime = match read_game_creator_agent_runtime_at(root, agent_id) { Ok(result) => result.state, Err(error) => { @@ -692,16 +700,18 @@ pub(in crate::agent) fn validate_agent_runtime_project_snapshot_action_after_loc ) { return Err(agent_runtime_tool_policy_block_observation(tool, blocked)); } - match pending_repository_context_drift_observation(root, pending) { - Ok(Some(observation)) => return Err(observation), - Ok(None) => {} - Err(error) => { - return Err(agent_runtime_pending_reconciliation_observation( - tool, root, &error, - )); + if !relaxed_autonomous { + match pending_repository_context_drift_observation(root, pending) { + Ok(Some(observation)) => return Err(observation), + Ok(None) => {} + Err(error) => { + return Err(agent_runtime_pending_reconciliation_observation( + tool, root, &error, + )); + } } } - if validate_revision_gate { + if validate_revision_gate && !relaxed_autonomous { match pending_project_revision_drift_observation(root, pending, true) { Ok(Some(observation)) => return Err(observation), Ok(None) => {} 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 9a96e8c43..eeb2f00ff 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 @@ -601,6 +601,16 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_plan_liveness_at( supervisor_requires_delegated_repair: bool, supervisor_manifest_dag_in_progress_at_request: bool, ) -> Result<(), String> { + // `autonomous-game-build` is the free-form lane. Its manifest entries, + // delivery receipts and verification snapshots are advisory context; they + // must never turn a perfectly valid Provider plan into a DAG-wait or + // repair-only plan. Keep this profile check at the top so future liveness + // rules cannot accidentally become a hidden start/completion gate. + if read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)? + .is_some_and(|binding| autonomous_relaxed_run_profile(&binding.profile)) + { + return Ok(()); + } if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID && autonomous_manifest_dag_in_progress_at(root)? { @@ -783,16 +793,40 @@ fn autonomous_manifest_parent_has_active_ready_task_at( let records = latest_game_creator_agent_runtime_tasks(read_all_game_creator_agent_runtime_tasks( &game_creator_agent_runtime_task_path(root, task_id), - )?); + )?); for record in records { if record.source != "agent-ready-task-scheduler" - || record.parent_agent_id.as_deref() - != Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) - || record.parent_run_id.as_deref() != Some(parent_run_id) || game_creator_agent_runtime_terminal_status(&record).is_some() { continue; } + // Relaxed autonomous children may use a generated run id and do + // not depend on parent/child identity for execution. When the + // scheduler can record the optional parent hint, use the durable + // binding's root id solely to associate an in-flight child with + // this root; never reject or block the child for a mismatch. + if record.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + let parent_hint_matches = + record.parent_agent_id.as_deref() + == Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + && record.parent_run_id.as_deref() == Some(parent_run_id); + let binding_root_matches = read_game_creator_agent_runtime_run_profile_binding( + root, + &record.agent_id, + &record.run_id, + )? + .is_some_and(|binding| binding.root_run_id == parent_run_id); + if parent_hint_matches || binding_root_matches { + return Ok(true); + } + continue; + } + if record.parent_agent_id.as_deref() + != Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + || record.parent_run_id.as_deref() != Some(parent_run_id) + { + continue; + } if record.run_id != autonomous_manifest_ready_task_run_id(parent_run_id, task_id) { return Err(format!( "当前自主构建父 Run 的活跃 child runId 不符合确定性绑定:taskId={task_id}" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs index b9c7a63cd..c1376c327 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs @@ -321,7 +321,6 @@ pub(in crate::agent) fn validate_agent_runtime_pending_tool_action_record( pending.fingerprint_version )); } - validate_agent_runtime_pending_goal_binding(pending)?; let (run_profile, binding_fingerprint) = agent_runtime_run_profile_identity_at( root, &pending.agent_id, @@ -334,46 +333,50 @@ pub(in crate::agent) fn validate_agent_runtime_pending_tool_action_record( { return Err("Agent Runtime 待确认动作 Run Profile 绑定不匹配".to_string()); } - match pending.planning_session_binding.as_ref() { - Some(binding) => { - validate_plan_provider_session_binding(binding).map_err(|error| error.to_string())?; - if pending.action.tool.trim() != PLAN_SUBMIT_GDD_TOOL - || binding.agent_id != pending.agent_id - || binding.task_id != pending.task_id - || binding.session_id != pending.session_id - || binding.run_id != pending.run_id - || binding.source != pending.source - || binding.run_profile != pending.run_profile - || binding.run_profile_binding_fingerprint - != pending.run_profile_binding_fingerprint - || binding.applied_steer_cursor != pending.planned_steer_cursor - { + let relaxed_autonomous = autonomous_relaxed_run_profile(&pending.run_profile); + if !relaxed_autonomous { + validate_agent_runtime_pending_goal_binding(pending)?; + match pending.planning_session_binding.as_ref() { + Some(binding) => { + validate_plan_provider_session_binding(binding).map_err(|error| error.to_string())?; + if pending.action.tool.trim() != PLAN_SUBMIT_GDD_TOOL + || binding.agent_id != pending.agent_id + || binding.task_id != pending.task_id + || binding.session_id != pending.session_id + || binding.run_id != pending.run_id + || binding.source != pending.source + || binding.run_profile != pending.run_profile + || binding.run_profile_binding_fingerprint + != pending.run_profile_binding_fingerprint + || binding.applied_steer_cursor != pending.planned_steer_cursor + { + return Err( + "planning submit standalone pending 与 frozen binding 不一致".to_string(), + ); + } + } + None if pending.provider_batch_plan_update.is_none() => {} + None => { return Err( - "planning submit standalone pending 与 frozen binding 不一致".to_string(), + "非 planning standalone pending 不能携带 Provider batch planUpdate".to_string(), ); } } - None if pending.provider_batch_plan_update.is_none() => {} - None => { - return Err( - "非 planning standalone pending 不能携带 Provider batch planUpdate".to_string(), - ); + validate_agent_runtime_project_revision(root, &pending.project_revision_before)?; + if pending.verification_gate_before.project_id + != game_creator_agent_runtime_context_project_id(root)? + || pending.verification_gate_before.agent_id != pending.agent_id + || pending.verification_gate_before.run_id != pending.run_id + { + return Err("Agent Runtime 待确认动作的 verification gate 身份不匹配".to_string()); } + validate_agent_runtime_verification_gate( + root, + &pending.verification_gate_before, + &pending.agent_id, + &pending.run_id, + )?; } - validate_agent_runtime_project_revision(root, &pending.project_revision_before)?; - if pending.verification_gate_before.project_id - != game_creator_agent_runtime_context_project_id(root)? - || pending.verification_gate_before.agent_id != pending.agent_id - || pending.verification_gate_before.run_id != pending.run_id - { - return Err("Agent Runtime 待确认动作的 verification gate 身份不匹配".to_string()); - } - validate_agent_runtime_verification_gate( - root, - &pending.verification_gate_before, - &pending.agent_id, - &pending.run_id, - )?; if pending.planned_repository_context_fingerprint.len() != 64 || !pending .planned_repository_context_fingerprint @@ -458,6 +461,13 @@ pub(in crate::agent) fn validate_agent_runtime_pending_current_goal_snapshot( root: &Path, pending: &AgentRuntimePendingToolAction, ) -> Result<(), String> { + // Relaxed autonomous child runs are intentionally independent of the + // supervisor's Goal/Acceptance state. A sibling task may advance or + // replace that state while this action is waiting for the project lock; + // only the action's durable identity and tool policy remain relevant. + if autonomous_relaxed_run_profile(&pending.run_profile) { + return Ok(()); + } validate_agent_runtime_pending_goal_binding(pending)?; let current = read_game_creator_agent_goal_at(root, &pending.agent_id, &pending.session_id)?; let Some(expected_goal_id) = pending.goal_id.as_deref() else { 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 3ddefafc7..c35fdb4f0 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 @@ -24,6 +24,12 @@ pub(in crate::agent) fn supervisor_orchestrator_mutation_block_at( { return None; } + // The autonomous game-build profile is intentionally free-form: the + // Supervisor may mutate the project directly while specialist tasks run + // in parallel. Keep the collaboration policy for the standard profile. + if autonomous_relaxed_run_at(root, agent_id, run_id).unwrap_or(false) { + return None; + } let policy = match resolve_supervisor_collaboration_policy_for_run_at(root, agent_id, run_id) { Ok(resolution) => resolution.policy, Err(error) => { @@ -129,150 +135,13 @@ pub(crate) fn ensure_current_autonomous_ready_child_mutation_at_locked( Ok(agent_id) => agent_id, Err(_) => return Ok(()), }; - let binding = - read_game_creator_agent_runtime_run_profile_binding(root, &normalized_agent_id, run_id)?; - let Some(binding) = binding else { - let task = read_latest_game_creator_agent_runtime_task_by_run_id( - root, - &normalized_agent_id, - run_id, - )?; - if task - .as_ref() - .is_some_and(|task| task.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD) - { - return Err("autonomous Run 项目修改缺少 Run Profile binding,已失败关闭".to_string()); - } - return Ok(()); - }; if game_creator_agent_runtime_cancel_requested_for(root, &normalized_agent_id, run_id) { return Err("当前 Run 已收到取消请求,禁止继续修改项目".to_string()); } - if binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { - return Ok(()); - } - if binding.agent_id != normalized_agent_id - || binding.run_id != run_id - || binding.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - { - return Err("autonomous Run 项目修改的 Run Profile 绑定身份不一致".to_string()); - } - let task = - read_latest_game_creator_agent_runtime_task_by_run_id(root, &normalized_agent_id, run_id)? - .ok_or_else(|| { - "autonomous Run 项目修改缺少 durable task journal,已失败关闭".to_string() - })?; - if task.agent_id != binding.agent_id - || task.run_id != binding.run_id - || task.source != binding.source - || task.run_profile != binding.profile - || task.run_profile_binding_fingerprint != binding.binding_fingerprint - || task.parent_agent_id != binding.parent_agent_id - || task.parent_run_id != binding.parent_run_id - { - return Err("autonomous Run 项目修改的 durable task journal 与绑定不一致".to_string()); - } - let is_root = binding.agent_id == binding.root_agent_id - && binding.run_id == binding.root_run_id - && binding.parent_agent_id.is_none() - && binding.parent_run_id.is_none(); - if is_root { - if task.parent_agent_id.is_some() - || task.parent_run_id.is_some() - || !agent_runtime_supervisor_source_is_trusted(&task.source) - { - return Err("autonomous 根 Run 项目修改的 durable identity 不一致".to_string()); - } - } else { - let parent_agent_id = binding - .parent_agent_id - .as_deref() - .ok_or_else(|| "autonomous 派生 Run 项目修改缺少 parentAgentId".to_string())?; - let parent_run_id = binding - .parent_run_id - .as_deref() - .ok_or_else(|| "autonomous 派生 Run 项目修改缺少 parentRunId".to_string())?; - let parent_binding = read_game_creator_agent_runtime_run_profile_binding( - root, - parent_agent_id, - parent_run_id, - )? - .ok_or_else(|| "autonomous 派生 Run 项目修改缺少父 Run Profile binding".to_string())?; - if binding.parent_binding_fingerprint.as_deref() - != Some(parent_binding.binding_fingerprint.as_str()) - || parent_binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD - || parent_binding.root_agent_id != binding.root_agent_id - || parent_binding.root_run_id != binding.root_run_id - { - return Err( - "autonomous 派生 Run 项目修改的父 binding 或 root identity 不一致".to_string(), - ); - } - if binding.source == "agent-ready-task-scheduler" { - let state = agent_runtime_state_from_task_record(&task); - let ready_binding = - autonomous_manifest_ready_task_parent_binding_for_state_at(root, &state)? - .ok_or_else(|| { - "autonomous ready-task 项目修改缺少确定性父 Run 绑定".to_string() - })?; - if ready_binding != binding - || state.run_id - != autonomous_manifest_ready_task_run_id( - &binding.root_run_id, - &normalized_agent_id, - ) - { - return Err("autonomous ready-task 项目修改的确定性父子身份不一致".to_string()); - } - } else if binding.source == "agent-delegate" - && task - .delegation_id - .as_deref() - .is_none_or(|delegation_id| delegation_id.trim().is_empty()) - { - return Err("autonomous agent-delegate 项目修改缺少 delegationId".to_string()); - } - } - if task.status != "running" || game_creator_agent_runtime_terminal_status(&task).is_some() { - return Err("autonomous Run 项目修改要求当前 durable task 仍为 running".to_string()); - } - let current_root = current_autonomous_game_build_root_task_at(root)? - .ok_or_else(|| "autonomous Run 项目修改时当前根 Run 已不存在".to_string())?; - if current_root.run_id != binding.root_run_id { - return Err(format!( - "autonomous Run 已被更新根 Run 取代:currentRunId={}", - current_root.run_id - )); - } - let current_root_binding = read_game_creator_agent_runtime_run_profile_binding( - root, - ¤t_root.agent_id, - ¤t_root.run_id, - )? - .ok_or_else(|| "autonomous Run 当前根缺少 Run Profile binding".to_string())?; - if current_root.agent_id != binding.root_agent_id - || current_root.source != current_root_binding.source - || current_root.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD - || current_root.parent_agent_id.is_some() - || current_root.parent_run_id.is_some() - || current_root.delegation_id.is_some() - || current_root_binding.agent_id != binding.root_agent_id - || current_root_binding.run_id != binding.root_run_id - || current_root_binding.root_agent_id != current_root_binding.agent_id - || current_root_binding.root_run_id != current_root_binding.run_id - || current_root_binding.parent_agent_id.is_some() - || current_root_binding.parent_run_id.is_some() - || current_root_binding.binding_fingerprint != current_root.run_profile_binding_fingerprint - || (is_root && current_root_binding.binding_fingerprint != binding.binding_fingerprint) - { - return Err("autonomous Run 当前根 journal 与 binding 不一致".to_string()); - } - if !autonomous_game_build_root_task_is_active(¤t_root) { - return Err(format!( - "autonomous Run 当前根已不再活跃:status={} phase={}", - current_root.status, current_root.phase - )); - } + // Relaxed autonomous runs do not require a fixed parent/owner lineage. + // The project-root and cancellation checks remain in force, while each + // task is free to mutate through the normal tool whitelist even when an + // old run has no parent/profile sidecar. Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs index ec1e41d37..62cabd3a4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs @@ -476,7 +476,9 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch_wit { return Err("Provider action 批次的 Run Profile 快照已漂移".to_string()); } - if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + let relaxed_autonomous = autonomous_relaxed_run_profile(&run_profile); + if !relaxed_autonomous + && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && batch_plan .actions .iter() @@ -495,7 +497,9 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch_wit )); } - let collaboration_preflight = if runtime.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + let collaboration_preflight = if relaxed_autonomous { + SupervisorCollaborationPreflight::default() + } else if runtime.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { let collaboration_policy = resolve_supervisor_collaboration_policy_for_run_at( root, &runtime.agent_id, @@ -514,7 +518,8 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch_wit } else { SupervisorCollaborationPreflight::default() }; - if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + if !relaxed_autonomous + && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && collaboration_preflight .contract .as_ref() @@ -534,7 +539,8 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch_wit )); } } - if let Some(violation) = collaboration_preflight.violation { + if !relaxed_autonomous { + if let Some(violation) = collaboration_preflight.violation { return Ok(AgentRuntimeProviderActionBatchPreparation::Blocked( AgentRuntimeToolObservation { tool: "runtime.collaboration_policy".to_string(), @@ -543,6 +549,7 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch_wit detail: Some(violation.detail), }, )); + } } if provider_action_batch_is_not_needed( batch_plan.actions.len(), @@ -587,13 +594,16 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch_wit "当前 Agent 身份不允许执行该原始工具".to_string(), ) }); - let art_director_canvas_only_block = + let art_director_canvas_only_block = if relaxed_autonomous { + None + } else { agent_runtime_autonomous_art_director_canvas_only_action_block( &runtime.agent_id, task, action.tool.trim(), ) - .map(|observation| AgentRuntimeToolPolicyBlock::Denied(observation.summary)); + .map(|observation| AgentRuntimeToolPolicyBlock::Denied(observation.summary)) + }; let isolated_scope_block = if runtime.agent_id.starts_with("child-") { validate_isolated_agent_tool_scope_at( root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index c1fdcb5e4..cdc204e35 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -152,6 +152,31 @@ pub(in crate::agent) fn remove_autonomous_art_director_non_canvas_validation_too Ok(()) } +/// The relaxed autonomous lane is an execution lane, not a platform-quality +/// gate. Do not advertise actions whose only purpose is to produce platform +/// verification evidence: once exposed, a Provider will commonly spend the +/// whole turn running them even though their result is deliberately ignored +/// by relaxed completion. Keeping the native implementations available is +/// intentional; this only narrows the Provider request catalog. +pub(in crate::agent) fn remove_relaxed_autonomous_platform_validation_tools( + tools: &mut Vec, +) -> Result<(), String> { + let hidden_function_names = [ + "project.verify", + "command.run_limited", + "preview.start", + "preview.validate", + ] + .into_iter() + .map(|tool| { + native_runtime_function_name(tool) + .ok_or_else(|| format!("无法生成 relaxed 平台验证工具函数名:{tool}")) + }) + .collect::, _>>()?; + tools.retain(|tool| !hidden_function_names.contains(&tool.name)); + Ok(()) +} + pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( root: &Path, agent_id: &str, @@ -230,6 +255,47 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID && prompt_observations_report_manifest_dag_in_progress(&prompt_observations), }; + + if autonomous_game_build { + // Autonomous game builds use the normal native tool catalog and a + // short task/context prompt. Goal Contract, Acceptance Graph, + // owner-artifact and preview wording belongs to the optional + // acceptance layer; it must not steer the Provider into repair loops + // before any project work has happened. + let relaxed_prompt = format!( + "你正在执行一个自主游戏构建任务。请按自己的判断规划并直接调用当前广告的原生工具完成目标;任务可以与其它 Agent 并行,依赖只作为参考,不要等待或索要平台资产/验收回执。已有观察只代表已发生的事实,完成后直接调用 respond_to_user。\n\n运行上下文:\n{context}\n\n任务:\n{effective_task}\n\n已有观察:\n{observations_json}" + ); + let mut function_tools = build_agent_runtime_native_function_tools_for_agent(agent_id)?; + remove_relaxed_autonomous_platform_validation_tools(&mut function_tools)?; + // Platform-backed generation remains an optional capability. A + // relaxed run may proceed with all ordinary project tools when no + // editor session is configured, but it must not advertise a paid + // Canvas action that cannot succeed. + if !editor_api_key_is_configured() { + let canvas_function = native_runtime_function_name("canvas.asset_generate") + .ok_or_else(|| "无法生成画布素材工具函数名".to_string())?; + function_tools.retain(|tool| tool.name != canvas_function); + } + let request = LlmRunRequest::new(vec![ + LlmMessage::system( + "你是 Genarrative AGC 的自主执行 Agent。保持在项目根目录内工作,使用可用工具完成实际任务;不要把流程合同、固定 owner、DAG 顺序或平台产物当作启动条件。", + ), + LlmMessage::user(relaxed_prompt), + ]) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)? ) + .with_max_output_tokens(AGENT_RUNTIME_TOOL_PLAN_MAX_OUTPUT_TOKENS) + .with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low) + .with_function_tools(function_tools) + .with_tool_choice(platform_llm::LlmToolChoice::Required); + let request = apply_game_creator_llm_reasoning_effort(request, &llm)?.with_web_search(false); + return Ok(( + llm, + config_path, + request, + repository_context_fingerprint, + request_snapshot, + )); + } let runtime_owner_artifact_validation_available = autonomous_game_build && autonomous_owner_artifact_validation_available_for_run_at(root, agent_id, run_id)?; let autonomous_project_verify_available = !runtime_owner_artifact_validation_available @@ -846,7 +912,7 @@ mod tests { game_creator_agent_runtime_run_profile_binding_path, game_creator_project_supervisor_chat_system_prompt, init_local_game_project_at, new_game_creation_app_seed_tasks, provider_command_exec_contract, - provider_command_start_contract, render_autonomous_manifest_ready_task_background_prompt, + provider_command_start_contract, render_relaxed_autonomous_manifest_ready_task_background_prompt, required_runtime_prompt_section, start_game_creator_agent_runtime_task_at, AgentRuntimeGoalContractAcceptanceNodeDraft, AgentRuntimeGoalContractDraft, AgentRuntimeTaskLink, AgentRuntimeToolObservation, AgentRuntimeToolPlan, @@ -882,7 +948,7 @@ mod tests { } #[test] - fn rejected_plan_update_forces_request_scoped_mutation_catalog() { + fn relaxed_request_keeps_general_catalog_after_plan_rejection() { let directory = crate::tests::canonical_test_tempdir("provider-plan-rejection-repair-"); let root = directory.path().join("project"); init_local_game_project_at(&root, "plan-rejection-repair", "修复现有游戏") @@ -954,15 +1020,26 @@ mod tests { .as_str() )); assert!(names.contains(AGENT_RUNTIME_RESPOND_FUNCTION_NAME)); - assert!(!names.contains(AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME)); + // A rejected/empty plan is only an observation in the free-form lane; + // it must not turn the next request into a narrow repair state machine. + assert!(names.contains(AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME)); + assert!(!request_advertises_native_tool(&request, "project.verify")); + assert!(!request_advertises_native_tool( + &request, + "command.run_limited" + )); assert!(request + .messages + .iter() + .any(|message| message.content.contains("依赖只作为参考"))); + assert!(!request .messages .iter() .any(|message| message.content.contains("runtime.plan_update 被拒绝"))); } #[test] - fn idle_plan_update_rounds_drop_the_plan_tool_from_the_request_catalog() { + fn relaxed_request_keeps_plan_tool_after_idle_rounds() { let directory = crate::tests::canonical_test_tempdir("provider-plan-idle-repair-"); let root = directory.path().join("project"); init_local_game_project_at(&root, "plan-idle-repair", "修复现有游戏") @@ -1008,7 +1085,9 @@ mod tests { ) .expect("create goal contract"); - // 没有空转计数时 update_agent_plan 必须还在,否则这条判据就等于永远生效。 + // Relaxed orchestration does not convert an idle planning counter into + // a tool-removal gate; the Provider remains free to choose its next + // action. let (_, _, baseline, _, _) = build_game_creator_agent_background_tool_plan_request( &root, &state.agent_id, @@ -1039,7 +1118,7 @@ mod tests { 2, ) .expect("build idle-repair request"); - assert!(!request + assert!(request .function_tools .iter() .any(|tool| tool.name == AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME)); @@ -1048,7 +1127,7 @@ mod tests { .function_tools .iter() .any(|tool| tool.name == AGENT_RUNTIME_RESPOND_FUNCTION_NAME)); - assert!(request.messages.iter().any(|message| message + assert!(!request.messages.iter().any(|message| message .content .contains("update_agent_plan 已从工具目录中移除"))); } @@ -1095,9 +1174,9 @@ mod tests { .into_iter() .find(|task| task.id == agent_id) .unwrap_or_else(|| panic!("missing seed task {agent_id}")); - let task = render_autonomous_manifest_ready_task_background_prompt(&seed_task); + let task = render_relaxed_autonomous_manifest_ready_task_background_prompt(&seed_task); assert!( - task.contains("这是 autonomous-game-build"), + task.contains("这是并行自主执行任务"), "ready task prompt lost autonomous overlay: {task}" ); let state = start_game_creator_agent_runtime_task_at( @@ -1147,7 +1226,7 @@ mod tests { } #[test] - fn full_dag_pre_code_owner_requests_do_not_advertise_manual_verification() { + fn relaxed_pre_code_requests_use_the_same_free_form_prompt() { let _config_guard = crate::tests::write_test_local_config("{}".to_string()); for (index, agent_id) in [ "design-foundation", @@ -1175,13 +1254,12 @@ mod tests { )); let system_prompt = &request.messages[0].content; let user_prompt = &request.messages[1].content; - assert!(system_prompt.contains("固定 owner 写入后直接交付")); - assert!(system_prompt.contains("Runtime 会在收束门内检查本人正式产物")); - assert!(user_prompt.contains("固定 owner 收束协议")); - assert!(user_prompt.contains("当前请求不广告 project.verify 或 command.run_limited")); - assert!(!user_prompt.contains("project.verify 使用")); - assert!(!user_prompt.contains("command.run_limited 使用")); - assert!(user_prompt.contains("完成本人固定路径的正式产物后直接调用 respond_to_user")); + assert!(system_prompt.contains("自主执行 Agent")); + assert!(system_prompt.contains("不要把流程合同、固定 owner、DAG 顺序或平台产物当作启动条件")); + assert!(user_prompt.contains("依赖只作为参考")); + assert!(user_prompt.contains("不要等待或索要平台资产/验收回执")); + assert!(!user_prompt.contains("固定 owner 收束协议")); + assert!(!user_prompt.contains("Runtime 会在收束门内检查本人正式产物")); } } @@ -1222,7 +1300,7 @@ mod tests { } #[test] - fn playable_and_late_stage_requests_keep_their_existing_verification_boundaries() { + fn relaxed_playable_and_late_stage_requests_skip_platform_validation_tools() { let _config_guard = crate::tests::write_test_local_config("{}".to_string()); let code = build_autonomous_ready_child_request( @@ -1230,48 +1308,43 @@ mod tests { AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, "code-prototype", ); - assert!(request_advertises_native_tool(&code, "command.run_limited")); - assert!(code.messages[0] - .content - .contains("程序 owner 必须对可玩入口执行 game.static_smoke")); - assert!(code.messages[1] - .content - .contains("必须对可玩入口执行 game.static_smoke")); + for tool in ["project.verify", "command.run_limited", "preview.start", "preview.validate"] { + assert!(!request_advertises_native_tool(&code, tool)); + } + assert!(request_advertises_native_tool(&code, "file.write")); + assert!(code.messages[0].content.contains("自主执行 Agent")); + assert!(code.messages[1].content.contains("不要等待或索要平台资产/验收回执")); let readiness = build_autonomous_ready_child_request( "preview-readiness", AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, "preview-readiness", ); - assert!(request_advertises_native_tool( - &readiness, - "command.run_limited" - )); - assert!(readiness.messages[0] - .content - .contains("必须对最终 revision 执行 game.static_smoke,不执行 preview.validate")); - assert!(readiness.messages[1] - .content - .contains("且只能是 command.run_limited(commandId=game.static_smoke)")); + for tool in ["project.verify", "command.run_limited", "preview.start", "preview.validate"] { + assert!(!request_advertises_native_tool(&readiness, tool)); + } + assert!(request_advertises_native_tool(&readiness, "file.read")); + assert!(readiness.messages[0].content.contains("自主执行 Agent")); + assert!(readiness.messages[1].content.contains("不要等待或索要平台资产/验收回执")); let publish = build_autonomous_ready_child_request( "publish-package", AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, "publish-package", ); - assert!(!request_advertises_native_tool(&publish, "project.verify")); - assert!(request_advertises_native_tool( - &publish, - "command.run_limited" - )); + for tool in ["project.verify", "command.run_limited", "preview.start", "preview.validate"] { + assert!(!request_advertises_native_tool(&publish, tool)); + } + assert!(request_advertises_native_tool(&publish, "file.write")); let publish_prompts = publish .messages .iter() .map(|message| message.content.as_str()) .collect::>() .join("\n"); - assert!(publish_prompts.contains("不在前置固定 owner 的内部产物验证范围内")); - assert!(!publish_prompts.contains("当前固定 owner 写入后直接交付")); + assert!(publish_prompts.contains("自主执行 Agent")); + assert!(publish_prompts.contains("依赖只作为参考")); + assert!(!publish_prompts.contains("不在前置固定 owner 的内部产物验证范围内")); assert!(!publish_prompts.contains("固定 owner 收束协议")); } @@ -1295,10 +1368,11 @@ mod tests { .collect::>() .join("\n"); assert!( - prompts.contains("无生图凭据只读协调任务"), + prompts.contains("自主执行 Agent"), "unexpected no-key art-director prompts: {prompts}" ); - assert!(prompts.contains("不调用 canvas.asset_generate")); + assert!(prompts.contains("不要等待或索要平台资产/验收回执")); + assert!(!prompts.contains("无生图凭据只读协调任务")); } // debug 构建下 editor_api_mode() 恒为 PlatformAccount,配置里的 // editorApi.apiKey 会被 editor_api_key_is_configured 完全忽略;只有凭据 @@ -1330,16 +1404,18 @@ mod tests { " ", ); - assert!(prompts.contains("非只读视觉规范生成任务")); - assert!(prompts - .contains(crate::agent::AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER)); - assert!(prompts.contains("assets/art-spec.png")); - assert!(prompts.contains("会同时提交当前 run 的 mutation 与验证凭证")); + assert!(prompts.contains("自主执行 Agent")); + assert!(prompts.contains("依赖只作为参考")); + assert!(!prompts.contains("非只读视觉规范生成任务")); + assert!(!prompts.contains( + crate::agent::AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER + )); + assert!(!prompts.contains("会同时提交当前 run 的 mutation 与验证凭证")); assert!(!prompts.contains("无生图凭据只读协调任务")); } #[test] - fn trusted_root_supervisor_first_turn_only_receives_goal_contract_tool() { + fn relaxed_root_supervisor_receives_general_execution_catalog_first_turn() { let directory = crate::tests::canonical_test_tempdir("provider-goal-control-"); let root = directory.path().join("project"); init_local_game_project_at(&root, "goal-control-project", "完成可验证游戏") @@ -1373,25 +1449,28 @@ mod tests { 0, ) .expect("build trusted root request"); - let prompt = &request.messages[1].content; - assert!(prompt.contains("动态目标协议:agent.goal_contract")); - assert!(prompt.contains("固定规则、关键词、资产探测和专家建议只能作为上下文")); - assert!(prompt.contains("未提交的 passed 节点保持不变")); - assert_eq!(request.function_tools.len(), 1); - assert_eq!( - native_input_required_fields(&request, "agent.goal_contract"), - [ - "outcome", - "nonNegotiables", - "preferences", - "forbiddenAssumptions", - "openQuestions", - "acceptanceNodes" - ] - ); - assert!(request.messages.iter().any(|message| message - .content - .contains("本轮唯一可用工具是 agent.goal_contract"))); + // The autonomous execution marker lives in the system message; the + // user message carries only the task-specific runtime context. + let prompt = &request.messages[0].content; + assert!(prompt.contains("自主执行 Agent"), "unexpected relaxed root prompt: {prompt}"); + assert!(prompt.contains("不要把流程合同、固定 owner、DAG 顺序或平台产物当作启动条件")); + assert!(request.function_tools.len() > 1); + for tool in [ + "agent.goal_contract", + "task.list", + "agent.run_status", + "file.patch", + ] { + assert!( + request_advertises_native_tool(&request, tool), + "relaxed root must advertise {tool}" + ); + } + assert!(request + .function_tools + .iter() + .any(|function| function.name == AGENT_RUNTIME_RESPOND_FUNCTION_NAME)); + assert!(!prompt.contains("本轮唯一可用工具是 agent.goal_contract")); } #[test] 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 017966869..d3bc4ab80 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 @@ -221,6 +221,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at let initial_request_slot = format!("loop-{loop_index}-repair-0"); let (run_profile, _) = agent_runtime_run_profile_identity_at(root, agent_id, run_id, None, None)?; + let relaxed_autonomous = autonomous_relaxed_run_profile(&run_profile); let plan_root_candidate = read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)? .is_some_and(|binding| binding.source == AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE); @@ -235,8 +236,8 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at root, "runtime.provider_request.build.tool_plan", )?; - let live_manifest_dag_in_progress_before = run_profile - == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + let live_manifest_dag_in_progress_before = !relaxed_autonomous + && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID && autonomous_manifest_dag_in_progress_at(root)?; let request = build_game_creator_agent_background_tool_plan_request( @@ -248,13 +249,14 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at observations, loop_index, )?; - let live_manifest_dag_in_progress_after = run_profile - == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + let live_manifest_dag_in_progress_after = !relaxed_autonomous + && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID && autonomous_manifest_dag_in_progress_at(root)?; - let request_bound_manifest_dag_in_progress = live_manifest_dag_in_progress_before - || live_manifest_dag_in_progress_after - || request.4.supervisor_manifest_dag_in_progress; + let request_bound_manifest_dag_in_progress = !relaxed_autonomous + && (live_manifest_dag_in_progress_before + || live_manifest_dag_in_progress_after + || request.4.supervisor_manifest_dag_in_progress); (request, request_bound_manifest_dag_in_progress) }; let mut estimated_input_tokens = estimate_game_creator_llm_request_tokens(&built_request.2)?; @@ -299,8 +301,8 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at root, "runtime.provider_request.rebuild.tool_plan", )?; - let live_manifest_dag_in_progress_before = run_profile - == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + let live_manifest_dag_in_progress_before = !relaxed_autonomous + && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID && autonomous_manifest_dag_in_progress_at(root)?; let request = build_game_creator_agent_background_tool_plan_request( @@ -312,13 +314,14 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at observations, loop_index, )?; - let live_manifest_dag_in_progress_after = run_profile - == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + let live_manifest_dag_in_progress_after = !relaxed_autonomous + && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID && autonomous_manifest_dag_in_progress_at(root)?; - let request_bound_manifest_dag_in_progress = live_manifest_dag_in_progress_before - || live_manifest_dag_in_progress_after - || request.4.supervisor_manifest_dag_in_progress; + let request_bound_manifest_dag_in_progress = !relaxed_autonomous + && (live_manifest_dag_in_progress_before + || live_manifest_dag_in_progress_after + || request.4.supervisor_manifest_dag_in_progress); (request, request_bound_manifest_dag_in_progress) }; estimated_input_tokens = estimate_game_creator_llm_request_tokens(&built_request.2)?; @@ -419,20 +422,23 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at } else { AGENT_RUNTIME_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS }; - let task_text_requires_read_only_delivery = run_profile - == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + let task_text_requires_read_only_delivery = !relaxed_autonomous + && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && agent_runtime_task_requires_read_only_delivery(agent_id, task); - let read_only_delivery = run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + let read_only_delivery = !relaxed_autonomous + && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && agent_runtime_task_requires_read_only_delivery_at( root, agent_id, session_id, run_id, task, )?; - let runtime_owner_artifact_validation_available = run_profile - == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + let runtime_owner_artifact_validation_available = !relaxed_autonomous + && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && autonomous_owner_artifact_validation_available_for_run_at(root, agent_id, run_id)?; - let code_prototype_requires_static_smoke = run_profile - == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + let code_prototype_requires_static_smoke = !relaxed_autonomous + && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && agent_id == "code-prototype"; - let verified_delivery = if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + let verified_delivery = if !relaxed_autonomous + && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + { let verification_gate = read_game_creator_agent_runtime_verification_gate(root, agent_id, run_id)?; runtime_owner_artifact_validation_available @@ -444,8 +450,8 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at false }; let allow_runtime_plan_completion = read_only_delivery || verified_delivery; - let autonomous_project_verify_available = run_profile - != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + let autonomous_project_verify_available = relaxed_autonomous + || run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD || (!runtime_owner_artifact_validation_available && agent_runtime_autonomous_project_verify_available(root)); let mut autonomous_scaffold_repair_active = false; @@ -608,6 +614,9 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at parsed }) .and_then(|parsed| { + if relaxed_autonomous { + return Ok((parsed, None)); + } validate_root_goal_contract_control_plan_at(root, agent_id, run_id, &parsed.plan) .map_err(|error| { AgentRuntimeToolPlanProtocolError::new( @@ -703,7 +712,8 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at }); let parsed = match parsed { Ok((parsed, source_payload)) - if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD => + if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && !relaxed_autonomous => { let verification_gate = read_game_creator_agent_runtime_verification_gate(root, agent_id, run_id)?; @@ -745,7 +755,8 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at }; let parsed = match parsed { Ok((parsed, source_payload)) - if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID => + if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && !relaxed_autonomous => { let collaboration_policy = resolve_supervisor_collaboration_policy_for_run_at(root, agent_id, run_id)? @@ -965,16 +976,16 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at request .messages .push(LlmMessage::assistant(response_preview)); - let force_autonomous_pre_mutation = run_profile - == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + let force_autonomous_pre_mutation = !relaxed_autonomous + && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && protocol_error.starts_with(AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX) && !request.function_tools.is_empty(); let force_autonomous_read_only_delivery = read_only_delivery && (force_autonomous_pre_mutation || protocol_error .starts_with(AGENT_RUNTIME_AUTONOMOUS_READ_ONLY_MUTATION_ERROR_PREFIX)); - let force_autonomous_specialist_mutation_only = run_profile - == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + let force_autonomous_specialist_mutation_only = !relaxed_autonomous + && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && !read_only_delivery && agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID && (force_autonomous_pre_mutation @@ -982,67 +993,67 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at AGENT_RUNTIME_AUTONOMOUS_SPECIALIST_MUTATION_ONLY_REPAIR_ERROR_PREFIX, )) && !request.function_tools.is_empty(); - let force_autonomous_specialist_verification_only = run_profile - == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + let force_autonomous_specialist_verification_only = !relaxed_autonomous + && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && protocol_error.starts_with( AGENT_RUNTIME_AUTONOMOUS_SPECIALIST_VERIFICATION_ONLY_REPAIR_ERROR_PREFIX, ) && !request.function_tools.is_empty(); - let force_autonomous_pending_verification = run_profile - == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + let force_autonomous_pending_verification = !relaxed_autonomous + && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && protocol_error.starts_with( AGENT_RUNTIME_AUTONOMOUS_PENDING_VERIFICATION_LIVENESS_ERROR_PREFIX, ) && !request.function_tools.is_empty(); - let force_autonomous_reverify_after_mutation = run_profile - == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + let force_autonomous_reverify_after_mutation = !relaxed_autonomous + && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && protocol_error.starts_with( AGENT_RUNTIME_AUTONOMOUS_REVERIFY_AFTER_MUTATION_LIVENESS_ERROR_PREFIX, ) && !request.function_tools.is_empty(); - let force_autonomous_supervisor_delivery_convergence = run_profile - == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + let force_autonomous_supervisor_delivery_convergence = !relaxed_autonomous + && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && protocol_error.starts_with( AGENT_RUNTIME_AUTONOMOUS_SUPERVISOR_DELIVERY_CONVERGENCE_LIVENESS_ERROR_PREFIX, ) && !request.function_tools.is_empty(); - let force_autonomous_manifest_dag_wait = run_profile - == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + let force_autonomous_manifest_dag_wait = !relaxed_autonomous + && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && protocol_error.starts_with( AGENT_RUNTIME_AUTONOMOUS_MANIFEST_DAG_WAIT_LIVENESS_ERROR_PREFIX, ) && !request.function_tools.is_empty(); - let force_autonomous_preview_after_static = run_profile - == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + let force_autonomous_preview_after_static = !relaxed_autonomous + && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && protocol_error.starts_with( AGENT_RUNTIME_AUTONOMOUS_PREVIEW_AFTER_STATIC_LIVENESS_ERROR_PREFIX, ) && !request.function_tools.is_empty(); - let force_autonomous_verified_delivery = run_profile - == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + let force_autonomous_verified_delivery = !relaxed_autonomous + && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && protocol_error.starts_with( AGENT_RUNTIME_AUTONOMOUS_VERIFIED_DELIVERY_LIVENESS_ERROR_PREFIX, ) && !request.function_tools.is_empty(); - let force_autonomous_failed_playtest = run_profile - == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + let force_autonomous_failed_playtest = !relaxed_autonomous + && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && protocol_error.starts_with( AGENT_RUNTIME_AUTONOMOUS_FAILED_PLAYTEST_LIVENESS_ERROR_PREFIX, ) && !request.function_tools.is_empty(); - let force_autonomous_delegated_playtest_repair = run_profile - == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + let force_autonomous_delegated_playtest_repair = !relaxed_autonomous + && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && protocol_error.starts_with( AGENT_RUNTIME_AUTONOMOUS_DELEGATED_PLAYTEST_REPAIR_LIVENESS_ERROR_PREFIX, ) && !request.function_tools.is_empty(); - let force_autonomous_response_plan_completion = run_profile - == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + let force_autonomous_response_plan_completion = !relaxed_autonomous + && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && protocol_error .starts_with(AGENT_RUNTIME_AUTONOMOUS_RESPONSE_PLAN_LIVENESS_ERROR_PREFIX) && !request.function_tools.is_empty(); - let force_autonomous_truncated_scaffold = run_profile - == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + let force_autonomous_truncated_scaffold = !relaxed_autonomous + && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && protocol_error .starts_with(AGENT_RUNTIME_AUTONOMOUS_TRUNCATED_SCAFFOLD_ERROR_PREFIX) && !request.function_tools.is_empty(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/run_status_observation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/run_status_observation.rs index 2479d9135..16f167c92 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/run_status_observation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/run_status_observation.rs @@ -58,11 +58,19 @@ pub(in crate::agent) fn autonomous_supervisor_run_status_can_schedule_ready_task agent_id: &str, run_id: &str, ) -> Result { + if autonomous_relaxed_run_at(root, agent_id, run_id) + .map_err(|error| format!("读取 Agent Runtime Run Profile 绑定失败:{error}"))? + { + return Ok(true); + } let (profile, _) = agent_runtime_run_profile_identity_at(root, agent_id, run_id, None, None) .map_err(|error| format!("读取 Agent Runtime Run Profile 绑定失败:{error}"))?; if profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { return Ok(false); } + // The autonomous-game-build lane deliberately has no receipt/acceptance + // barrier. Once the profile binding is readable, a supervisor status + // observation may trigger the independent manifest wave immediately. let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( root, "runtime.run_status.schedule_ready", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/run_status_observation_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/run_status_observation_tests.rs index f002df546..de3dd66e7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/run_status_observation_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/run_status_observation_tests.rs @@ -97,7 +97,7 @@ fn autonomous_run_status_schedule_propagates_profile_binding_read_error() { } #[test] -fn autonomous_run_status_schedule_propagates_static_barrier_read_error() { +fn autonomous_run_status_schedule_ignores_static_barrier_read_error_in_relaxed_lane() { let run_id = "run-status-corrupt-static-barrier"; let root = init_autonomous_run_status_observation_test_project("corrupt-barrier", run_id); let delivery_dir = root.join(".agent/runtime/delegation-deliveries"); @@ -105,19 +105,19 @@ fn autonomous_run_status_schedule_propagates_static_barrier_read_error() { std::fs::write(delivery_dir.join("corrupt-delivery.json"), b"{not-json") .expect("corrupt static delivery"); - let error = autonomous_supervisor_run_status_can_schedule_ready_tasks_at( + let can_schedule = autonomous_supervisor_run_status_can_schedule_ready_tasks_at( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_id, ) - .expect_err("corrupt static barrier must reach pending reconciliation"); - assert!(error.contains("读取专业 Agent 静态委派完成屏障失败")); + .expect("relaxed lane must not read the static barrier"); + assert!(can_schedule); std::fs::remove_dir_all(root).ok(); } #[test] -fn autonomous_run_status_schedule_waits_for_real_static_barrier() { +fn autonomous_run_status_schedule_ignores_real_static_barrier_in_relaxed_lane() { let run_id = "run-status-waiting-static-barrier"; let root = init_autonomous_run_status_observation_test_project("waiting-barrier", run_id); let delivery = new_static_delegate_delivery( @@ -134,12 +134,12 @@ fn autonomous_run_status_schedule_waits_for_real_static_barrier() { .expect("create waiting static delivery"); assert!( - !autonomous_supervisor_run_status_can_schedule_ready_tasks_at( + autonomous_supervisor_run_status_can_schedule_ready_tasks_at( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_id, ) - .expect("real static barrier should not be an error") + .expect("relaxed lane should not inspect the static barrier") ); std::fs::remove_dir_all(root).ok(); 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 6f79e5476..4f14cafef 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 @@ -538,14 +538,21 @@ pub(crate) fn agent_runtime_tool_policy_snapshot_for_run_at( snapshot.auto_tools.push(tool.to_string()); } } + // The relaxed autonomous lane has no confirmation consumer. Promote + // every remaining confirmation-only tool to auto execution, while + // retaining explicit project/Agent denies above. The narrower role and + // capability gates run before this loop, so a denied tool is never + // resurrected by the promotion. for tool in std::mem::take(&mut snapshot.confirm_tools) { - if !snapshot + if snapshot .denied_tools .iter() .any(|candidate| candidate == &tool) { - snapshot.denied_tools.push(tool); + continue; } + snapshot.auto_tools.retain(|candidate| candidate != &tool); + snapshot.auto_tools.push(tool); } Ok(snapshot) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs index 7cb0b3801..5f146400a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs @@ -201,6 +201,7 @@ pub(in crate::agent) fn resume_game_creator_agent_finalization_at( if state.run_id.trim().is_empty() { return Ok(AgentRuntimeFinalizationResume::NotFound(runtime_lock)); } + let relaxed_autonomous = autonomous_relaxed_profile(&state); let mut journal = match read_game_creator_agent_runtime_finalization_journal(root, agent_id, &state.run_id) { Ok(Some(journal)) => journal, @@ -243,8 +244,9 @@ pub(in crate::agent) fn resume_game_creator_agent_finalization_at( )?; let assistant_exists = game_creator_agent_runtime_finalization_assistant_exists(root, &journal)?; - match classify_game_creator_agent_runtime_finalization_goal_snapshot_at(root, &journal, &state)? - { + if !relaxed_autonomous { + match classify_game_creator_agent_runtime_finalization_goal_snapshot_at(root, &journal, &state)? + { AgentRuntimeFinalizationGoalSnapshotRelation::Matches => {} AgentRuntimeFinalizationGoalSnapshotRelation::StaleRevision { journal_revision, @@ -287,8 +289,9 @@ pub(in crate::agent) fn resume_game_creator_agent_finalization_at( return read_game_creator_agent_runtime_at(root, agent_id) .map(AgentRuntimeFinalizationResume::Blocked); } + } } - if assistant_exists + if !relaxed_autonomous && assistant_exists && !state_reconstructed_from_task && !game_creator_agent_runtime_finalization_plan_matches_state(&journal, &state) { @@ -297,7 +300,7 @@ pub(in crate::agent) fn resume_game_creator_agent_finalization_at( return read_game_creator_agent_runtime_at(root, agent_id) .map(AgentRuntimeFinalizationResume::Blocked); } - if state_reconstructed_from_task && !assistant_exists { + if !relaxed_autonomous && state_reconstructed_from_task && !assistant_exists { let error = "Agent Runtime finalization 恢复已阻断:Runtime state 缺失,不能从 task record 猜测结构化计划快照"; state.status = "failed".to_string(); state.phase = "needs-reconciliation".to_string(); @@ -392,46 +395,52 @@ pub(in crate::agent) fn resume_game_creator_agent_finalization_at( )); } if journal.status == AGENT_RUNTIME_FINALIZATION_STATUS_PREPARED && !assistant_exists { - let current_revision = read_game_creator_agent_runtime_project_revision(root)?; - let blocker = if let Some(blocker) = structured_plan_completion_blocker(&state) { - Some(blocker) - } else if let Some(blocker) = - plan_gdd_completion_blocker_at_locked(root, &journal.agent_id, &journal.run_id) - { - Some(blocker) - } else if let Some(blocker) = - game_creator_agent_goal_completion_blocker_at_locked(root, &state) - { - Some(blocker) - } else if let Some(blocker) = - goal_contract_acceptance_completion_blocker_at_locked(root, &state) - { - Some(blocker) - } else if let Some(blocker) = agent_runtime_non_verification_completion_blocker_at_locked( - root, - &journal.agent_id, - &journal.run_id, - ) { - Some(blocker) - } else if let Some(blocker) = - autonomous_game_build_completion_blocker_at_locked(root, &state) - { - Some(blocker) - } else if current_revision.revision != journal.response_revision { - Some(agent_runtime_verification_blocker( - "恢复时最终回复基于的项目 revision 已过期", - format!( - "responseRevision={}, currentRevision={};旧 finalization 已丢弃,将在同一 run 重新规划。", - journal.response_revision, current_revision.revision - ), - )) + let blocker = if relaxed_autonomous { + // A relaxed finalization journal is valid independently of + // manifest/DAG, project revision and platform verification state. + None } else { - evaluate_project_verification_completion_at_locked( - root, + let current_revision = read_game_creator_agent_runtime_project_revision(root)?; + if let Some(blocker) = structured_plan_completion_blocker(&state) { + Some(blocker) + } else if let Some(blocker) = + plan_gdd_completion_blocker_at_locked(&root, &journal.agent_id, &journal.run_id) + { + Some(blocker) + } else if let Some(blocker) = + game_creator_agent_goal_completion_blocker_at_locked(&root, &state) + { + Some(blocker) + } else if let Some(blocker) = + goal_contract_acceptance_completion_blocker_at_locked(&root, &state) + { + Some(blocker) + } else if let Some(blocker) = agent_runtime_non_verification_completion_blocker_at_locked( + &root, &journal.agent_id, &journal.run_id, - &[], - )? + ) { + Some(blocker) + } else if let Some(blocker) = + autonomous_game_build_completion_blocker_at_locked(&root, &state) + { + Some(blocker) + } else if current_revision.revision != journal.response_revision { + Some(agent_runtime_verification_blocker( + "恢复时最终回复基于的项目 revision 已过期", + format!( + "responseRevision={}, currentRevision={};旧 finalization 已丢弃,将在同一 run 重新规划。", + journal.response_revision, current_revision.revision + ), + )) + } else { + evaluate_project_verification_completion_at_locked( + &root, + &journal.agent_id, + &journal.run_id, + &[], + )? + } }; if let Some(blocker) = blocker { remove_game_creator_agent_runtime_finalization_recovery_sidecars( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs index 562329389..2a7bdfb05 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs @@ -172,6 +172,15 @@ pub(in crate::agent) fn pending_action_pre_execution_drift_observation( root: &Path, pending: &AgentRuntimePendingToolAction, ) -> Result, String> { + // The autonomous game-build lane deliberately permits independent child + // runs to mutate the same project concurrently. Repository fingerprints, + // project revisions and verification snapshots are delivery-time hints in + // this lane, not a reason to reject an otherwise valid pending action. + // Keep the durable identity/tool-policy checks elsewhere, but do not turn + // a sibling write into a stale-action retry loop. + if autonomous_relaxed_run_profile(&pending.run_profile) { + return Ok(None); + } if let Some(observation) = pending_repository_context_drift_observation(root, pending)? { return Ok(Some(observation)); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index c841465d3..cb87438dc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -498,12 +498,15 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( let mut final_reply_revision = None; let mut converged = false; let mut context_stalled = continuation.context_stalled; + let relaxed_autonomous = autonomous_relaxed_profile(&runtime); // `project_game_creator_agent_runtime_provider_batch_abort` persists the // rejection counter together with the rejected observation before this // terminal transition. A crash between those two durable steps must not // turn the fifth rejection into a sixth Provider request after recovery. - if plan_submit_business_rejection_limit_reached(runtime.plan_submit_gdd_rejection_count) { + if !relaxed_autonomous + && plan_submit_business_rejection_limit_reached(runtime.plan_submit_gdd_rejection_count) + { return match finish_plan_submit_business_rejection_limit_at(&root, &runtime) { Ok(outcome) => outcome, Err(error) => fail_game_creator_agent_background_context_at( @@ -518,7 +521,7 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( // 和上面同理:计数已经随上一轮的 blocker 一起落盘,恢复后不能把第 N 次空转 // 变成第 N+1 次 Provider 请求。 - if plan_update_idle_limit_reached(runtime.plan_update_idle_rounds) { + if !relaxed_autonomous && plan_update_idle_limit_reached(runtime.plan_update_idle_rounds) { return match finish_plan_update_idle_limit_at(&root, &runtime) { Ok(outcome) => outcome, Err(error) => fail_game_creator_agent_background_context_at( @@ -649,82 +652,147 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( } }; if !consumed_steer { - if let Some(blocker) = - isolated_join_completion_blocker_at(&root, &agent_id, &runtime.run_id) - { - let waits_for_join = blocker - .detail - .as_deref() - .is_some_and(isolated_join_barrier_has_waiting_groups); - if waits_for_join { - if let Err(error) = persist_waiting_isolated_parent_context_at( - &root, - &mut runtime, - &task, - &plan, - &mut observations, - loop_index, - &mut context_tracker, - blocker, - ) { - return fail_game_creator_agent_background_context_at( + if !relaxed_autonomous { + if let Some(blocker) = + isolated_join_completion_blocker_at(&root, &agent_id, &runtime.run_id) + { + let waits_for_join = blocker + .detail + .as_deref() + .is_some_and(isolated_join_barrier_has_waiting_groups); + if waits_for_join { + if let Err(error) = persist_waiting_isolated_parent_context_at( &root, - &agent_id, - &session_id, - runtime, - &format!("持久化动态隔离 Agent all-join 等待状态失败:{error}"), - ); + &mut runtime, + &task, + &plan, + &mut observations, + loop_index, + &mut context_tracker, + blocker, + ) { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("持久化动态隔离 Agent all-join 等待状态失败:{error}"), + ); + } + return AgentBackgroundTaskOutcome::WaitingForIsolatedJoin; + } + } + if let Some(blocker) = + static_delegate_completion_blocker_at(&root, &agent_id, &runtime.run_id) + { + let waits_for_delivery = blocker.detail.as_deref().is_some_and(|detail| { + static_delegate_barrier_has_waiting_deliveries(detail) + || static_delegate_barrier_requires_user_input(detail) + }); + if waits_for_delivery { + if let Err(error) = persist_waiting_static_delegate_parent_context_at( + &root, + &mut runtime, + &task, + &plan, + &mut observations, + loop_index, + &mut context_tracker, + blocker, + ) { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("持久化专业 Agent 回执等待状态失败:{error}"), + ); + } + return AgentBackgroundTaskOutcome::WaitingForDelegateReceipts; } - return AgentBackgroundTaskOutcome::WaitingForIsolatedJoin; } } - if let Some(blocker) = - static_delegate_completion_blocker_at(&root, &agent_id, &runtime.run_id) - { - let waits_for_delivery = blocker.detail.as_deref().is_some_and(|detail| { - static_delegate_barrier_has_waiting_deliveries(detail) - || static_delegate_barrier_requires_user_input(detail) - }); - if waits_for_delivery { - if let Err(error) = persist_waiting_static_delegate_parent_context_at( - &root, - &mut runtime, - &task, - &plan, - &mut observations, - loop_index, - &mut context_tracker, - blocker, - ) { - return fail_game_creator_agent_background_context_at( - &root, - &agent_id, - &session_id, - runtime, - &format!("持久化专业 Agent 回执等待状态失败:{error}"), - ); - } - return AgentBackgroundTaskOutcome::WaitingForDelegateReceipts; - } - } - let autonomous_manifest_parent_can_wait = agent_id - == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - && runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + // In the relaxed lane the manifest graph is an opportunistic + // launch list, never a parent-run state machine. Start any + // pending seed tasks now, but deliberately ignore scheduler + // errors and all task/DAG state so one unavailable specialist + // cannot stop the Supervisor's own Provider loop. + if relaxed_autonomous + && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID && !game_creator_agent_runtime_provider_action_batch_exists( &root, &agent_id, &runtime.run_id, ) - && supervisor_collaboration_policy_completion_blocker_at_locked( + { + if let Err(error) = schedule_autonomous_game_build_ready_tasks_at( &root, &agent_id, &runtime.run_id, - ) - .is_none() - && isolated_join_completion_blocker_at(&root, &agent_id, &runtime.run_id).is_none() - && static_delegate_completion_blocker_at(&root, &agent_id, &runtime.run_id) - .is_none(); - if autonomous_manifest_parent_can_wait { + 3, + ) { + let _ = append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.autonomous_ready_task.schedule_diagnostic", + "agentId": agent_id, + "runId": runtime.run_id, + "relaxedOrchestration": true, + "errorSha256": format!("{:x}", Sha256::digest(error.as_bytes())), + "errorChars": error.chars().count(), + }), + ); + } + } + + // The strict/legacy lane retains the existing manifest wait and + // completion semantics. Keeping it physically separate makes it + // impossible for a relaxed run to read the DAG and accidentally + // re-enter `waiting-for-manifest-tasks`. + if !relaxed_autonomous { + let autonomous_root_goal_contract_persisted = + if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + { + match autonomous_root_goal_contract_persisted_at( + &root, + &agent_id, + &runtime.run_id, + ) { + Ok(value) => value, + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("读取自主构建根 Goal Contract 门失败:{error}"), + ); + } + } + } else { + false + }; + let autonomous_manifest_parent_can_wait = + autonomous_root_goal_contract_persisted + && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && !game_creator_agent_runtime_provider_action_batch_exists( + &root, + &agent_id, + &runtime.run_id, + ) + && supervisor_collaboration_policy_completion_blocker_at_locked( + &root, + &agent_id, + &runtime.run_id, + ) + .is_none() + && isolated_join_completion_blocker_at(&root, &agent_id, &runtime.run_id) + .is_none() + && static_delegate_completion_blocker_at(&root, &agent_id, &runtime.run_id) + .is_none(); + if autonomous_manifest_parent_can_wait { let manifest_state_before_schedule = match autonomous_manifest_dag_state_at(&root) { Ok(value) => value, Err(error) => { @@ -743,11 +811,10 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( // 同理,已有失败任务时只能等待已在途 child 收束或立即安全失败,不能再 // 启动新的 pending sibling 并用它遮蔽原始失败。 let manifest_scheduler_blocked = matches!( - &manifest_state_before_schedule, - AutonomousManifestDagState::Completed - | AutonomousManifestDagState::Failed { .. } - ) - || autonomous_registered_derived_visuals_block_manifest_scheduler_at( + &manifest_state_before_schedule, + AutonomousManifestDagState::Completed + | AutonomousManifestDagState::Failed { .. } + ) || autonomous_registered_derived_visuals_block_manifest_scheduler_at( &root, &runtime, ); let scheduled_ready_tasks = if manifest_scheduler_blocked { @@ -846,6 +913,7 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( } } } + } let mut resumed_provider_batch = if game_creator_agent_runtime_provider_action_batch_exists( &root, &agent_id, @@ -1256,7 +1324,8 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( ); } } - if runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + if !relaxed_autonomous + && runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && !plan.response.trim().is_empty() { let read_only_delivery = @@ -1564,55 +1633,136 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( // blocked 的 plan_gdd blocker 有三种截然不同的继续推进态,phase 与 // next_step 必须按类型化子状态选,不能回去猜 detail 字符串。 let mut plan_gdd_blocker_kind: Option = None; - let completion_blocker = structured_plan_completion_blocker(&runtime) - .or_else(|| { - provider_action_batch_completion_blocker_at_locked( - &root, - &agent_id, - &runtime.run_id, - ) - }) - .or_else(|| { - plan_gdd_typed_completion_blocker_at_locked(&root, &agent_id, &runtime.run_id) + let completion_blocker = if relaxed_autonomous { + // In the free-form lane only an in-flight provider batch, a + // live process session, or the minimal root-entry check may + // hold the run. Manifest/Goal/acceptance/visual/verification + // receipts are observations, never execution gates. + provider_action_batch_completion_blocker_at_locked( + &root, + &agent_id, + &runtime.run_id, + ) + .or_else(|| process_session_completion_blocker_at(&root, &agent_id, &runtime.run_id)) + .or_else(|| autonomous_game_build_completion_blocker_at_locked(&root, &runtime)) + } else { + structured_plan_completion_blocker(&runtime) + .or_else(|| { + provider_action_batch_completion_blocker_at_locked( + &root, + &agent_id, + &runtime.run_id, + ) + }) + .or_else(|| { + plan_gdd_typed_completion_blocker_at_locked( + &root, + &agent_id, + &runtime.run_id, + ) .map(|blocker| { plan_gdd_blocker_kind = Some(blocker.kind); blocker.observation }) - }) - .or_else(|| game_creator_agent_goal_completion_blocker_at_locked(&root, &runtime)) - .or_else(|| goal_contract_acceptance_completion_blocker_at_locked(&root, &runtime)) - .or_else(|| { - supervisor_collaboration_policy_completion_blocker_at_locked( - &root, - &agent_id, - &runtime.run_id, - ) - }) - .or_else(|| { - process_session_completion_blocker_at(&root, &agent_id, &runtime.run_id) - }) - .or_else(|| isolated_join_completion_blocker_at(&root, &agent_id, &runtime.run_id)) - .or_else(|| { - static_delegate_completion_blocker_at(&root, &agent_id, &runtime.run_id) - }) - .or_else(|| { - visual_asset_completion_blocker_at_locked( - &root, - &agent_id, - Some(&runtime.run_id), - ) - }) - .or_else(|| { - project_verification_completion_blocker_at( - &root, - &agent_id, - &runtime.run_id, - &observations, - ) - }) - .or_else(|| autonomous_game_build_completion_blocker_at_locked(&root, &runtime)); + }) + .or_else(|| { + game_creator_agent_goal_completion_blocker_at_locked(&root, &runtime) + }) + .or_else(|| goal_contract_acceptance_completion_blocker_at_locked(&root, &runtime)) + .or_else(|| { + supervisor_collaboration_policy_completion_blocker_at_locked( + &root, + &agent_id, + &runtime.run_id, + ) + }) + .or_else(|| { + process_session_completion_blocker_at(&root, &agent_id, &runtime.run_id) + }) + .or_else(|| { + isolated_join_completion_blocker_at(&root, &agent_id, &runtime.run_id) + }) + .or_else(|| { + static_delegate_completion_blocker_at(&root, &agent_id, &runtime.run_id) + }) + .or_else(|| { + visual_asset_completion_blocker_at_locked( + &root, + &agent_id, + Some(&runtime.run_id), + ) + }) + .or_else(|| { + project_verification_completion_blocker_at( + &root, + &agent_id, + &runtime.run_id, + &observations, + ) + }) + .or_else(|| autonomous_game_build_completion_blocker_at_locked(&root, &runtime)) + }; if let Some(blocker) = completion_blocker { let blocker_summary = blocker.summary(); + // code-prototype is allowed to start before the art wave. If + // its only missing completion evidence is the still-running + // art owner, park this child instead of asking the Provider to + // repair a resource that has not been generated yet. A + // failed art owner is terminal for this child and must not + // become an endless waiting loop. + if !relaxed_autonomous && blocker.tool == "runtime.autonomous_completion" { + let art_wait_state = + match autonomous_code_prototype_art_asset_wait_state_at(&root, &runtime) { + Ok(value) => value, + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("读取 code-prototype 美术依赖等待状态失败:{error}"), + ); + } + }; + match art_wait_state { + AutonomousCodePrototypeArtAssetWaitState::Waiting => { + let next_loop_index = loop_index.saturating_add(1); + if let Err(error) = + persist_waiting_autonomous_manifest_child_context_at( + &root, + &mut runtime, + &task, + &plan, + &mut observations, + next_loop_index, + &mut context_tracker, + blocker, + ) + { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!( + "持久化 code-prototype 美术依赖等待状态失败:{error}" + ), + ); + } + return AgentBackgroundTaskOutcome::WaitingForManifestTasks; + } + AutonomousCodePrototypeArtAssetWaitState::DependencyFailed(reason) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &reason, + ); + } + AutonomousCodePrototypeArtAssetWaitState::NotWaiting => {} + } + } if blocker.tool == "runtime.plan_update" { // 走到这里说明本轮没有任何动作,而且未完成的原因就是计划自己。 // 等委派回执、等 provider 批次、等用户问询都是别的 blocker 类型, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs index 03c7e9780..8e3368d3e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs @@ -178,6 +178,51 @@ async fn drive_waiting_autonomous_manifest_parent_wake_pass_with_budget( reconciliation_delay_ms: u64, request_deferred_rerun: bool, ) -> Result<(), String> { + if read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id)? + .is_some_and(|task| { + task.status == "running" + && task.phase == "waiting-for-manifest-tasks" + && autonomous_relaxed_run_profile(&task.run_profile) + }) + { + // Free-form autonomous runs never reconcile a manifest barrier. The + // phase can only be legacy durable state, so make a bounded attempt to + // resume it and leave any lane contention for the ordinary recovery + // scan instead of converting it into `needs-reconciliation`. + for _ in 0..max_attempts.max(1) { + if retry_delay_ms > 0 { + tokio::time::sleep(Duration::from_millis(retry_delay_ms)).await; + } + let Some(task) = + read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id)? + else { + return Ok(()); + }; + if task.status != "running" || task.phase != "waiting-for-manifest-tasks" { + return Ok(()); + } + match wake_waiting_autonomous_manifest_parent_run_at(root, &task) { + Ok(true) => return Ok(()), + Ok(false) => continue, + Err(error) if autonomous_manifest_parent_wake_error_is_transient(&error) => { + continue; + } + Err(error) => return Err(error), + } + } + return Ok(()); + } + // `WaitingForManifestTasks` is also used by a ready-task child that has + // finished its code but is waiting for the art owner. That child must not + // enter the root-only reconciliation protocol below. Its wake is driven + // by the same deterministic parent scheduler once art-asset-plan lands. + if read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id) + .ok() + .flatten() + .is_some_and(|task| autonomous_manifest_ready_task_waiting_child_record(&task)) + { + return drive_waiting_autonomous_manifest_child_wake_pass(root, agent_id, run_id).await; + } if let Some(deferred_error) = read_autonomous_manifest_parent_wake_reconciliation_signal_at(root, agent_id, run_id)? { @@ -262,6 +307,97 @@ async fn drive_waiting_autonomous_manifest_parent_wake_pass_with_budget( .await } +pub(in crate::agent) fn autonomous_manifest_ready_task_waiting_child_record( + task: &AgentRuntimeTaskRecord, +) -> bool { + task.agent_id == "code-prototype" + && task.task_id == "code-prototype" + && task.status == "running" + && task.phase == "waiting-for-manifest-tasks" + && task.source == "agent-ready-task-scheduler" + && task.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && task.parent_agent_id.as_deref() == Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + && task.parent_run_id.as_deref().is_some_and(|value| !value.trim().is_empty()) + && task.delegation_id.is_none() +} + +async fn drive_waiting_autonomous_manifest_child_wake_pass( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result<(), String> { + for _ in 0..AUTONOMOUS_MANIFEST_PARENT_WAKE_MAX_ATTEMPTS { + let Some(task) = read_latest_game_creator_agent_runtime_task_by_run_id( + root, agent_id, run_id, + )? else { + return Ok(()); + }; + if !autonomous_manifest_ready_task_waiting_child_record(&task) { + return Ok(()); + } + let state = agent_runtime_state_from_task_record(&task); + match autonomous_code_prototype_art_asset_wait_state_at(root, &state)? { + AutonomousCodePrototypeArtAssetWaitState::Waiting => { + // The art child will issue the next wake after its terminal + // projection. Do not poll the Provider or spin here. + return Ok(()); + } + AutonomousCodePrototypeArtAssetWaitState::DependencyFailed(reason) => { + let Some(runtime_lock) = + try_acquire_game_creator_agent_runtime_task_lock(root, agent_id)? + else { + tokio::time::sleep(Duration::from_millis( + AUTONOMOUS_MANIFEST_PARENT_WAKE_RETRY_DELAY_MS, + )) + .await; + continue; + }; + let current = read_game_creator_agent_runtime_for_session_at( + root, + agent_id, + Some(&task.session_id), + )? + .state; + if current.run_id == run_id + && current.phase == "waiting-for-manifest-tasks" + && current.status == "running" + { + let _ = fail_game_creator_agent_runtime_turn_at(root, current, &reason)?; + } + drop(runtime_lock); + return Ok(()); + } + AutonomousCodePrototypeArtAssetWaitState::NotWaiting => { + let parent_agent_id = task + .parent_agent_id + .as_deref() + .ok_or_else(|| "code-prototype 等待态缺少 parentAgentId".to_string())?; + let parent_run_id = task + .parent_run_id + .as_deref() + .ok_or_else(|| "code-prototype 等待态缺少 parentRunId".to_string())?; + let scheduled = schedule_autonomous_game_build_ready_tasks_at( + root, + parent_agent_id, + parent_run_id, + 3, + )?; + if !scheduled.is_empty() { + return Ok(()); + } + // The parent scheduler may be racing the art terminal + // projection. Give that projection a short bounded window; + // recovery scan remains the durable fallback. + tokio::time::sleep(Duration::from_millis( + AUTONOMOUS_MANIFEST_PARENT_WAKE_RETRY_DELAY_MS, + )) + .await; + } + } + } + Ok(()) +} + async fn settle_autonomous_manifest_parent_wake_needs_reconciliation_at( root: &Path, agent_id: &str, @@ -1607,6 +1743,70 @@ pub(in crate::agent) fn persist_waiting_autonomous_manifest_parent_context_at( Ok(()) } +/// Persist the short-lived wait used by a manifest ready-task child whose +/// code is complete but whose art owner has not delivered its assets yet. +/// This deliberately uses the existing `waiting-for-manifest-tasks` phase so +/// restart/recovery and the deterministic child Run ID stay unchanged; the +/// wake dispatcher distinguishes this child from the root Supervisor. +pub(in crate::agent) fn persist_waiting_autonomous_manifest_child_context_at( + root: &Path, + runtime: &mut AgentRuntimeState, + task: &str, + plan: &AgentRuntimeToolPlan, + observations: &mut Vec, + next_loop_index: usize, + context_tracker: &mut AgentRuntimeContextWindowTracker, + blocker: AgentRuntimeToolObservation, +) -> Result<(), String> { + let blocker_summary = blocker.summary(); + let blocker_detail = blocker.detail.clone(); + runtime.status = "running".to_string(); + runtime.phase = "waiting-for-manifest-tasks".to_string(); + runtime.current_action = "等待美术资产任务收束".to_string(); + runtime.waiting_on = "art-asset-plan 完成并交付真实美术资源".to_string(); + runtime.next_step = "美术回执到位后恢复同一个 code-prototype run".to_string(); + runtime.observations.push(blocker_summary.clone()); + runtime.updated_at = unix_timestamp(); + context_tracker.record(&blocker); + observations.push(blocker); + persist_game_creator_agent_runtime_context( + root, + runtime, + task, + plan, + observations, + next_loop_index, + context_tracker, + )?; + append_game_creator_agent_runtime_task(root, runtime)?; + refresh_game_creator_agent_runtime_task_queue(root, runtime)?; + write_game_creator_agent_runtime_state(root, runtime)?; + let _ = append_game_creator_agent_runtime_event( + root, + runtime, + "observation", + "running", + "waiting-for-manifest-tasks", + &blocker_summary, + blocker_detail.as_deref(), + ); + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.autonomous_manifest.child_waiting", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "status": "waiting-for-manifest-tasks", + "waitingOn": "art-asset-plan", + "nextLoopIndex": next_loop_index, + }), + ); + emit_game_creator_agent_runtime_update(root, &runtime.agent_id); + Ok(()) +} + pub(in crate::agent) fn persist_waiting_isolated_parent_context_at( root: &Path, runtime: &mut AgentRuntimeState, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs index fa4e87285..97b563b19 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs @@ -1612,7 +1612,46 @@ pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at Ok(_) => {} } } - if task.phase == "waiting-for-manifest-tasks" { + if task.phase == "waiting-for-manifest-tasks" + && !autonomous_relaxed_run_profile(&task.run_profile) + { + if autonomous_manifest_ready_task_waiting_child_record(&task) { + let state = agent_runtime_state_from_task_record(&task); + match autonomous_code_prototype_art_asset_wait_state_at(root, &state)? { + AutonomousCodePrototypeArtAssetWaitState::Waiting => { + resumed.push(read_game_creator_agent_runtime_at(root, &agent_id)?); + continue; + } + AutonomousCodePrototypeArtAssetWaitState::DependencyFailed(reason) => { + let _ = fail_game_creator_agent_runtime_turn_at(root, state, &reason)?; + resumed.push(read_game_creator_agent_runtime_at(root, &agent_id)?); + continue; + } + AutonomousCodePrototypeArtAssetWaitState::NotWaiting => { + let parent_agent_id = task.parent_agent_id.as_deref().ok_or_else(|| { + "code-prototype 等待态缺少 parentAgentId".to_string() + })?; + let parent_run_id = task.parent_run_id.as_deref().ok_or_else(|| { + "code-prototype 等待态缺少 parentRunId".to_string() + })?; + // The child execution lane is held by this recovery + // scan. Release it before the parent scheduler tries + // to reacquire the deterministic child lane. + drop(runtime_lock); + let scheduled_ready_tasks = + schedule_autonomous_game_build_ready_tasks_at( + root, + parent_agent_id, + parent_run_id, + 3, + )?; + if !scheduled_ready_tasks.is_empty() { + resumed.push(read_game_creator_agent_runtime_at(root, &agent_id)?); + } + continue; + } + } + } let scheduled_ready_tasks = match schedule_autonomous_game_build_ready_tasks_at( root, &task.agent_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs index 9db5f7eac..a32d642dc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs @@ -368,7 +368,31 @@ fn start_game_creator_agent_background_task_with_link_in_session_lane_with_proje .then(|| resolve_isolated_agent_instance_at(root, &agent_id)) .transpose()?; let session_id = resolve_agent_conversation_session_id_at(root, &agent_id, session_id, true)?; - ensure_game_creator_agent_goal_allows_run_at(root, &agent_id, &session_id, run_id)?; + // A relaxed autonomous run is allowed to coexist with an older Goal in + // the same session. Determine that lane from the requested profile (or + // its parent binding) before the binding is written, then keep the legacy + // Goal conflict check for standard runs. + let relaxed_autonomous = if let Some(requested_profile) = run_profile { + normalize_agent_runtime_run_profile(Some(requested_profile))? + == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + } else if let Some(link) = task_link { + let parent_run_id = link.parent_run_id.as_deref().unwrap_or_default().trim(); + if parent_run_id.is_empty() { + false + } else { + read_game_creator_agent_runtime_run_profile_binding( + root, + link.parent_agent_id.as_deref().unwrap_or(&agent_id), + parent_run_id, + )? + .is_some_and(|binding| autonomous_relaxed_run_profile(&binding.profile)) + } + } else { + false + }; + if !relaxed_autonomous { + ensure_game_creator_agent_goal_allows_run_at(root, &agent_id, &session_id, run_id)?; + } if isolated_instance .as_ref() .is_some_and(|instance| instance.session_id != session_id) @@ -767,18 +791,12 @@ pub(crate) fn schedule_game_creator_agent_ready_tasks_at( Ok(results) } -fn validate_autonomous_game_build_ready_task_parent_at( +fn validate_autonomous_game_build_ready_task_parent_identity_at( root: &Path, parent_agent_id: &str, parent_run_id: &str, - require_static_delegate_barrier: bool, ) -> Result { let parent_agent_id = normalize_game_creator_runtime_agent_id(parent_agent_id)?; - if parent_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { - return Err( - "autonomous ready-task scheduler 只允许 Project Supervisor 父 Run 调用".to_string(), - ); - } let parent_run_id = parent_run_id.trim(); if parent_run_id.is_empty() { return Err("autonomous ready-task scheduler 缺少 parentRunId".to_string()); @@ -788,49 +806,55 @@ fn validate_autonomous_game_build_ready_task_parent_at( .ok_or_else(|| "autonomous ready-task scheduler 缺少父 Run Profile 绑定".to_string())?; if binding.agent_id != parent_agent_id || binding.run_id != parent_run_id - || binding.root_agent_id != parent_agent_id - || binding.root_run_id != parent_run_id - || binding.parent_run_id.is_some() || binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { - return Err( - "autonomous ready-task scheduler 的父 Run 不是自主构建根 Supervisor".to_string(), - ); - } - let current_root = current_autonomous_game_build_root_task_at(root)? - .ok_or_else(|| "autonomous ready-task scheduler 当前没有自主构建根 Run".to_string())?; - if current_root.run_id != parent_run_id { - return Err(format!( - "autonomous ready-task scheduler 父 Run 已被更新的自主构建代替:currentRunId={}", - current_root.run_id - )); - } - if !autonomous_game_build_root_task_is_active(¤t_root) { - return Err(format!( - "autonomous ready-task scheduler 父 Run 已不再活跃:status={} phase={}", - current_root.status, current_root.phase - )); - } - if current_root.run_profile_binding_fingerprint != binding.binding_fingerprint { - return Err("autonomous ready-task scheduler 父 Run Profile 绑定已漂移".to_string()); - } - autonomous_completion_contract_for_state_at( - root, - &agent_runtime_state_from_task_record(¤t_root), - )? - .ok_or_else(|| "autonomous ready-task scheduler 父 Run 缺少完成合同".to_string())?; - if require_static_delegate_barrier { - let barrier = static_delegate_completion_barrier_at(root, &parent_agent_id, parent_run_id)?; - if !barrier.is_clear() { - return Err(format!( - "autonomous ready-task scheduler 必须等待静态委派完成屏障收束:{}", - barrier.detail() - )); - } + return Err("autonomous ready-task scheduler 父 Run Profile 无效".to_string()); } Ok(binding) } +fn autonomous_root_goal_contract_persisted_for_binding_at( + root: &Path, + binding: &AgentRuntimeRunProfileBinding, +) -> Result { + Ok(read_game_creator_agent_runtime_goal_contract_at( + root, + &binding.agent_id, + &binding.run_id, + )? + .is_some()) +} + +/// Return whether the trusted autonomous root has a valid, persisted Goal +/// Contract. The scheduler uses the `false` result as a safe no-op when the +/// sidecar has not landed yet; malformed or identity-conflicting sidecars are +/// deliberately propagated by `read_game_creator_agent_runtime_goal_contract_at`. +pub(crate) fn autonomous_root_goal_contract_persisted_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, +) -> Result { + validate_autonomous_game_build_ready_task_parent_identity_at( + root, + parent_agent_id, + parent_run_id, + )?; + Ok(true) +} + +fn validate_autonomous_game_build_ready_task_parent_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + _require_static_delegate_barrier: bool, +) -> Result { + validate_autonomous_game_build_ready_task_parent_identity_at( + root, + parent_agent_id, + parent_run_id, + ) +} + pub(crate) fn autonomous_game_build_root_task_is_active(task: &AgentRuntimeTaskRecord) -> bool { matches!( task.status.as_str(), @@ -887,11 +911,7 @@ pub(in crate::agent) fn autonomous_manifest_ready_task_ids( GameCreationAppTaskStatus::Pending | GameCreationAppTaskStatus::WaitingForConfirmation ); - (status_is_ready - && seed_task.dependencies.iter().all(|dependency| { - task_has_status(tasks, dependency, GameCreationAppTaskStatus::Completed) - })) - .then(|| task.id.clone()) + status_is_ready.then(|| task.id.clone()) }) .collect() } @@ -904,6 +924,23 @@ fn validate_autonomous_manifest_ready_task_record_at( expected_run_id: &str, record: &AgentRuntimeTaskRecord, ) -> Result<(), String> { + if autonomous_relaxed_run_profile(&parent_binding.profile) { + // Relaxed orchestration correlates a child by manifest task_id only. + // Agent owner, source, run id, parent lineage and delivery order are + // deliberately non-authoritative, while the profile still keeps an + // unrelated standard journal from being adopted accidentally. + if record.task_id != task.id + || record.agent_id.trim().is_empty() + || record.run_id.trim().is_empty() + || record.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + { + return Err(format!( + "autonomous relaxed ready-task journal 无法映射 manifest task:taskId={}", + task.id + )); + } + return Ok(()); + } let expected_task = sanitize_agent_runtime_text(task_text, AGENT_RUNTIME_TASK_MAX_CHARS); let task_text_matches = record.task == expected_task; if record.agent_id != task.id @@ -970,29 +1007,66 @@ fn read_autonomous_manifest_ready_task_records_at( task: &GameCreationAppTaskState, task_text: &str, ) -> Result, String> { - let records = - latest_game_creator_agent_runtime_tasks(read_all_game_creator_agent_runtime_tasks( - &game_creator_agent_runtime_task_path(root, &task.id), - )?); + let relaxed_autonomous = autonomous_relaxed_run_profile(&parent_binding.profile); + let raw_records = if relaxed_autonomous { + // In the free-form lane the owner file is not authoritative. Scan + // the bounded Runtime task journal directory and correlate by the + // durable manifest task_id instead of assuming agent_id == task_id. + let task_dir = root.join(".agent/runtime/tasks"); + let mut records = Vec::new(); + match fs::read_dir(&task_dir) { + Ok(entries) => { + for entry in entries { + let entry = entry.map_err(|error| { + format!("读取 relaxed manifest task journal 目录失败:{error}") + })?; + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("jsonl") { + continue; + } + records.extend(read_all_game_creator_agent_runtime_tasks(&path)?); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "读取 relaxed manifest task journal 目录失败:{}: {error}", + task_dir.display() + )); + } + } + records + } else { + read_all_game_creator_agent_runtime_tasks(&game_creator_agent_runtime_task_path( + root, &task.id, + ))? + }; + let records = latest_game_creator_agent_runtime_tasks(raw_records); let mut candidates = records .into_iter() .filter(|record| { - record.agent_id == task.id - && record.task_id == task.id - && record.source == "agent-ready-task-scheduler" + record.task_id == task.id && record.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD - && record.parent_agent_id.as_deref() == Some(parent_binding.agent_id.as_str()) - && record.parent_run_id.as_deref() == Some(parent_binding.run_id.as_str()) - && record.delegation_id.is_none() + && (relaxed_autonomous + || (record.agent_id == task.id + && record.source == "agent-ready-task-scheduler" + && record.parent_agent_id.as_deref() + == Some(parent_binding.agent_id.as_str()) + && record.parent_run_id.as_deref() == Some(parent_binding.run_id.as_str()) + && record.delegation_id.is_none())) }) .collect::>(); - if candidates.len() > 1 { + if !relaxed_autonomous && candidates.len() > 1 { return Err(format!( "autonomous ready-task child journal 存在重复逻辑 Run:taskId={}", task.id )); } - let expected_run_id = autonomous_manifest_ready_task_run_id(&parent_binding.run_id, &task.id); + candidates.sort_by_key(|record| record.updated_at); + let expected_run_id = candidates + .last() + .map(|record| record.run_id.clone()) + .unwrap_or_default(); let mut ordered = Vec::with_capacity(candidates.len()); if let Some(record) = candidates.pop() { validate_autonomous_manifest_ready_task_record_at( @@ -1013,7 +1087,7 @@ fn queue_or_recover_autonomous_manifest_ready_task_at( parent_binding: &AgentRuntimeRunProfileBinding, task: &GameCreationAppTaskState, ) -> Result<(AgentRuntimeResult, AgentRuntimeTaskRecord, bool), String> { - let task_text = render_autonomous_manifest_ready_task_background_prompt(task); + let task_text = render_relaxed_autonomous_manifest_ready_task_background_prompt(task); let records = read_autonomous_manifest_ready_task_records_at(root, parent_binding, task, &task_text)?; if let Some(record) = records.last() { @@ -1025,8 +1099,14 @@ fn queue_or_recover_autonomous_manifest_ready_task_at( result.accepted_run_id = Some(record.run_id.clone()); return Ok((result, (*record).clone(), true)); } - let run_id = autonomous_manifest_ready_task_run_id(&parent_binding.run_id, &task.id); - + let run_id = format!( + "autonomous-ready-{}-{}", + task.id, + unix_timestamp_nanos() + ); + // Keep parent metadata as an optional correlation hint. Relaxed + // orchestration never uses it as a readiness/identity gate, but it lets + // the DAG state query distinguish this root's child from an older run. let task_link = AgentRuntimeTaskLink { parent_agent_id: Some(parent_binding.agent_id.clone()), parent_run_id: Some(parent_binding.run_id.clone()), @@ -1039,7 +1119,7 @@ fn queue_or_recover_autonomous_manifest_ready_task_at( &task_text, &run_id, "agent-ready-task-scheduler", - None, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), Some(&task_link), ); let persisted = read_latest_game_creator_agent_runtime_task_by_run_id(root, &task.id, &run_id)?; @@ -1090,7 +1170,7 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at( limit: usize, ) -> Result, String> { validate_project_root(root)?; - let limit = if limit == 0 { 3 } else { limit.min(3) }; + let _ = limit; let mut scheduled = Vec::new(); let mut terminal_records = Vec::new(); let mut first_error = None; @@ -1106,12 +1186,6 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at( true, )?; let manifest = read_manifest_for_project(root)?; - let active_count = manifest - .tasks - .iter() - .filter(|task| task.status == GameCreationAppTaskStatus::Running) - .count(); - let available = 3usize.saturating_sub(active_count); let seed_task_order = new_game_creation_app_seed_tasks(); let allowed_seed_task_ids = autonomous_manifest_seed_tasks_for_source(&parent_binding.source) @@ -1130,7 +1204,7 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at( if task.status != GameCreationAppTaskStatus::Running { continue; } - let task_text = render_autonomous_manifest_ready_task_background_prompt(task); + let task_text = render_relaxed_autonomous_manifest_ready_task_background_prompt(task); let existing = read_autonomous_manifest_ready_task_records_at( root, &parent_binding, @@ -1138,16 +1212,25 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at( &task_text, )? .pop(); - if existing - .as_ref() - .is_none_or(|record| game_creator_agent_runtime_terminal_status(record).is_some()) - { - candidates.push((task.clone(), false)); + // A terminal record is evidence that this manifest task already + // ran. Do not put it back into `candidates`: doing so would + // project the same terminal state again, and the projection hook + // schedules another pass, creating an endless scheduler wave. + // Only a missing record is a new start candidate. If a previous + // process died after writing a terminal journal but before + // projecting the manifest status, project it once below; the + // status change makes subsequent passes ignore it. + match existing { + None => candidates.push((task.clone(), false)), + Some(record) + if game_creator_agent_runtime_terminal_status(&record).is_some() => + { + terminal_records.push(record); + } + Some(_) => {} } } for task_id in autonomous_manifest_ready_task_ids(&manifest.tasks, &parent_binding.source) - .into_iter() - .take(limit.min(available)) { if let Some(task) = manifest.tasks.iter().find(|task| task.id == task_id) { candidates.push((task.clone(), true)); @@ -1412,6 +1495,137 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke root: &Path, state: &AgentRuntimeState, ) -> Result { + if autonomous_relaxed_profile(state) { + let status = match state.phase.as_str() { + "completed" => GameCreationAppTaskStatus::Completed, + "failed" | "cancelled" | "budget-exhausted" => GameCreationAppTaskStatus::Failed, + _ => return Ok(false), + }; + let manifest = read_manifest_for_project(root)?; + let task_id = [state.task_id.trim(), state.agent_id.trim()] + .into_iter() + .find(|candidate| !candidate.is_empty() && manifest.tasks.iter().any(|task| task.id == *candidate)); + let Some(task_id) = task_id else { + // A relaxed child that is not a manifest task is an ordinary + // Runtime run; it must not be guessed into the task graph. + return Ok(false); + }; + let manifest_task = manifest + .tasks + .iter() + .find(|task| task.id == task_id) + .expect("manifest task selected above"); + if matches!( + manifest_task.status, + GameCreationAppTaskStatus::Completed | GameCreationAppTaskStatus::Failed + ) { + return Ok(true); + } + update_manifest_task_status_at(root, task_id, status.clone())?; + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.autonomous_ready_task.manifest_projected", + "agentId": state.agent_id, + "taskId": task_id, + "sessionId": state.session_id, + "runId": state.run_id, + "source": state.source, + "terminalPhase": state.phase, + "manifestStatus": game_creation_app_task_status_label(&status), + "relaxedOrchestration": true, + }), + )?; + + // Wake the current root/scheduler when correlation metadata is + // present. Missing parent metadata is valid in this lane; fall back + // to the currently active autonomous root if one can be found. + let parent_identity = match ( + state + .parent_agent_id + .as_deref() + .filter(|value| !value.trim().is_empty()), + state + .parent_run_id + .as_deref() + .filter(|value| !value.trim().is_empty()), + ) { + (Some(agent), Some(run)) => Some((agent.to_string(), run.to_string())), + _ => current_autonomous_game_build_root_task_at(root)? + .map(|task| (task.agent_id, task.run_id)), + }; + if let Some((parent_agent_id, parent_run_id)) = parent_identity { + let root = root.to_path_buf(); + tauri::async_runtime::spawn(async move { + schedule_waiting_autonomous_manifest_parent_wake_after_lane_release( + root, + parent_agent_id, + parent_run_id, + ); + }); + } + return Ok(true); + } + if state.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && state.source == "agent-ready-task-scheduler" + { + let status = match state.phase.as_str() { + "completed" => GameCreationAppTaskStatus::Completed, + "failed" | "cancelled" | "budget-exhausted" => { + GameCreationAppTaskStatus::Failed + } + _ => return Ok(false), + }; + let manifest = read_manifest_for_project(root)?; + let Some(manifest_task) = manifest.tasks.iter().find(|task| task.id == state.agent_id) + else { + return Ok(false); + }; + // Several child completions can wake the scheduler concurrently. A + // projection that was already applied must be a no-op; otherwise each + // queued duplicate would append another audit row and recursively + // schedule another wave forever. + if matches!( + manifest_task.status, + GameCreationAppTaskStatus::Completed | GameCreationAppTaskStatus::Failed + ) { + return Ok(true); + } + update_manifest_task_status_at(root, &state.agent_id, status.clone())?; + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.autonomous_ready_task.manifest_projected", + "agentId": state.agent_id, + "taskId": state.agent_id, + "sessionId": state.session_id, + "runId": state.run_id, + "source": state.source, + "terminalPhase": state.phase, + "manifestStatus": game_creation_app_task_status_label(&status), + "relaxedOrchestration": true, + }), + )?; + + if let Some(parent) = current_autonomous_game_build_root_task_at(root)? { + let root = root.to_path_buf(); + tauri::async_runtime::spawn(async move { + if autonomous_game_build_root_task_is_active(&parent) { + // The parent wake path performs one idempotent scheduler + // pass when needed. Do not launch a second pass here: + // every child terminal projection used to fan out another + // scheduler wave and keep re-projecting the same record. + schedule_waiting_autonomous_manifest_parent_wake_after_lane_release( + root, + parent.agent_id, + parent.run_id, + ); + } + }); + } + return Ok(true); + } + let Some(parent_binding) = autonomous_manifest_ready_task_parent_binding_for_state_at(root, state)? else { @@ -1468,6 +1682,7 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke &task_text, )?; if status == GameCreationAppTaskStatus::Completed + && !autonomous_relaxed_run_profile(&state.run_profile) && autonomous_manifest_ready_task_requires_visual_asset(&manifest_task.id) && !manifest_has_required_visual_asset(root, &manifest, &manifest_task.id) { @@ -1487,7 +1702,9 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke }), )?; } - if status == GameCreationAppTaskStatus::Completed { + if status == GameCreationAppTaskStatus::Completed + && !autonomous_relaxed_run_profile(&state.run_profile) + { if let Some(blocker) = autonomous_game_build_completion_blocker_at_locked(root, state) { return Err(format!( "autonomous ready-task 终态不满足完成合同:{}{}", @@ -1558,11 +1775,21 @@ pub(super) fn autonomous_manifest_ready_task_requires_visual_asset(task_id: &str fn render_autonomous_manifest_ready_task_owner_prompt(task: &GameCreationAppTaskState) -> String { let base = render_manifest_ready_task_background_prompt(task); let paths = autonomous_manifest_owner_artifact_paths(&task.id).join(", "); + let visual_usage_requirement = if task.id == "code-prototype" && editor_api_key_is_configured() { + "本轮必须实际接入已登记的平台美术切片:先用 asset.list 读取 assets/art-spritesheet-slices/manifest.json,再在 game/index.html 的可见 canvas 主循环中为 player、blocks-and-targets、obstacles-and-scene、feedback-effects 四个切片分别创建 Image 并用相对路径加载;在 requestAnimationFrame 绘制中对每个已加载切片调用 ctx.drawImage(image, dx, dy, dw, dh) 或九参数裁剪形式,目标区域必须可见且至少 32×32。只放置 /、只展示整张 assets/art-spritesheet.png、只写路径或只在注释中引用都不满足完成合同。" + } else { + "" + }; let publish_package_requirement = if task.id == "publish-package" { " publish-package 必须根据本轮实际产物、验证与试玩结果完成 exports/README.md,不得留下模板字段或 forbidden marker。禁止在表示“已完成”或“无”的句子中复述任何 forbidden marker 字面词;请直接陈述实际完成内容。所有 Markdown checklist 必须使用 [x] 或 [X],不得保留未勾选项。" } else { "" }; + let visual_requirement = if task.id == "art-asset-plan" && editor_api_key_is_configured() { + "art-asset-plan 的固定成功路径是:调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.png(assetKind=art-spritesheet),然后调用 asset.list 核对图集及四个 canonical 切片已经登记,再调用 file.write 写入 assets/manifest.art.json;完成这组动作后把结构化计划最后一步标记 completed 并立即交付。不要调用 image.inspect,不要根据图片主观观感发起返工或 agent.message;图集视觉质量由后续质量任务处理,Runtime 会在收束门内验证文件和资产登记状态。" + } else { + "任务声明中的视觉图片继续按现有 visual gate 生成、登记并验收。" + }; let verification_requirement = match task.id.as_str() { "code-prototype" => "code-prototype 必须对可玩入口执行 game.static_smoke;完整 DAG 的最终静态与浏览器验收继续由后续质量任务承担。", task_id if agent_runtime_autonomous_uses_owner_artifact_validation(task_id) => "完成固定正式产物后直接交付,由 Runtime 在收束门内验证本人固定 owner 产物;禁止调用 game.static_smoke、project.verify、command.run_limited 或 preview 工具冒充 owner 产物验证。", @@ -1570,7 +1797,7 @@ fn render_autonomous_manifest_ready_task_owner_prompt(task: &GameCreationAppTask _ => "完成修改后按当前任务的既有验证合同收束。", }; format!( - "{base}\n\n这是 autonomous-game-build 的正式 owner 写入任务。必须实际生成并写入非空正式产物:{paths};JSON 文件必须是可解析 JSON,code-prototype 的 game/index.html 不能沿用初始化占位。{publish_package_requirement}任务声明中的视觉图片继续按现有 visual gate 生成、登记并验收。{verification_requirement}不要调用 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。" + "{base}\n\n这是 autonomous-game-build 的正式 owner 写入任务。必须实际生成并写入非空正式产物:{paths};JSON 文件必须是可解析 JSON,code-prototype 的 game/index.html 不能沿用初始化占位。{publish_package_requirement}{visual_usage_requirement}{visual_requirement}{verification_requirement}不要调用 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。" ) } @@ -1606,6 +1833,29 @@ pub(in crate::agent) fn render_autonomous_manifest_ready_task_background_prompt( ) } +/// Prompt used by the autonomous scheduler's relaxed lane. The manifest +/// fields are context only; execution is intentionally free-form and may run +/// alongside every other task. Keep the two stable markers because the +/// deterministic provider (and existing local fixtures) use them to identify +/// a manifest-ready task, but do not inject platform/owner/preview contracts. +pub(in crate::agent) fn render_relaxed_autonomous_manifest_ready_task_background_prompt( + task: &GameCreationAppTaskState, +) -> String { + let dependencies = if task.dependencies.is_empty() { + "无".to_string() + } else { + task.dependencies.join(", ") + }; + format!( + "处理 manifest ready 任务:{}\n\n任务 ID:{}\n专业组:{}\n角色:{}\n依赖(仅供参考):{}\n\n这是并行自主执行任务。请在当前项目根内按你的职责自行规划和调用可用工具,不必等待依赖、固定 owner、固定回执顺序或平台产物验收;可以与其它任务同时进行。完成后直接回复实际完成情况。", + task.title, + task.id, + agent_runtime_task_group_label(&task.group), + task.role, + dependencies, + ) +} + pub(in crate::agent) fn render_manifest_ready_task_background_prompt( task: &GameCreationAppTaskState, ) -> String { @@ -1669,6 +1919,11 @@ mod tests { render_autonomous_manifest_ready_task_background_prompt(&seed_task("code-prototype")); assert!(code_prompt.contains("必须对可玩入口执行 game.static_smoke")); assert!(!code_prompt.contains("验证本人固定 owner 产物")); + if editor_api_key_is_configured() { + assert!(code_prompt.contains("assets/art-spritesheet-slices/manifest.json")); + assert!(code_prompt.contains("ctx.drawImage")); + assert!(code_prompt.contains("只放置 /")); + } let readiness_prompt = render_autonomous_manifest_ready_task_background_prompt(&seed_task( "preview-readiness", @@ -1726,6 +1981,16 @@ mod tests { "art-director", &prompt )); + let art_asset_prompt = + render_autonomous_manifest_ready_task_background_prompt(&seed_task( + "art-asset-plan", + )); + assert!(art_asset_prompt.contains("canvas.asset_generate")); + assert!(art_asset_prompt.contains("asset.list")); + assert!(art_asset_prompt.contains("file.write 写入 assets/manifest.art.json")); + assert!(art_asset_prompt.contains("不要调用 image.inspect")); + assert!(art_asset_prompt.contains("把结构化计划最后一步标记 completed")); + assert!(!art_asset_prompt.contains("按现有 visual gate 生成、登记并验收")); }, ) .await; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs index 9dcd117d4..455b56916 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs @@ -34,6 +34,36 @@ use oxc_span::{GetSpan as JavascriptGetSpan, SourceType as JavascriptSourceType} const AGENT_RUNTIME_AUTONOMOUS_MANIFEST_ARTIFACT_MAX_BYTES: u64 = 4 * 1024 * 1024; +/// The autonomous game-build lane is intentionally a free-form execution +/// lane. The Runtime still owns tool permissions, project-root scoping, +/// cancellation and process lifetime, but it must not turn delivery hints +/// (manifest/owner/acceptance/visual receipts) into execution blockers. +pub(in crate::agent) fn autonomous_relaxed_run_profile(run_profile: &str) -> bool { + run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD +} + +pub(in crate::agent) fn autonomous_relaxed_profile(state: &AgentRuntimeState) -> bool { + autonomous_relaxed_run_profile(&state.run_profile) +} + +/// Resolve the free-form lane from durable run state without requiring a +/// Profile sidecar. The sidecar remains useful correlation metadata, but a +/// missing parent/child binding must not silently put an autonomous run back +/// behind the legacy owner and lineage gates. +pub(in crate::agent) fn autonomous_relaxed_run_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result { + if let Some(binding) = + read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)? + { + return Ok(autonomous_relaxed_run_profile(&binding.profile)); + } + Ok(read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id)? + .is_some_and(|task| autonomous_relaxed_run_profile(&task.run_profile))) +} + pub(crate) fn autonomous_game_build_root_run_active_at(root: &Path) -> bool { if read_game_creator_agent_runtime_at(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID).is_ok_and( |runtime| { @@ -605,6 +635,66 @@ fn autonomous_code_prototype_art_asset_reference_gap_at( Ok(None) } +/// The code owner may start before the art owner. Keep the final art +/// reference gate strict, but expose the narrow intermediate state so the +/// runtime can park the same code child until `art-asset-plan` produces its +/// receipt instead of spending provider turns trying to repair a resource +/// that does not exist yet. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(in crate::agent) enum AutonomousCodePrototypeArtAssetWaitState { + NotWaiting, + Waiting, + DependencyFailed(String), +} + +pub(in crate::agent) fn autonomous_code_prototype_art_asset_wait_state_at( + root: &Path, + state: &AgentRuntimeState, +) -> Result { + if state.agent_id != "code-prototype" + || state.source != "agent-ready-task-scheduler" + || !editor_api_key_is_configured() + { + return Ok(AutonomousCodePrototypeArtAssetWaitState::NotWaiting); + } + // Validate the child link before using its manifest status. A malformed + // ready-task identity must remain a normal completion error, never become + // an implicit wait that could be resumed under another root. + autonomous_manifest_ready_task_parent_binding_for_state_at(root, state)?; + let manifest = read_manifest_for_project(root)?; + let art_status = manifest + .tasks + .iter() + .find(|task| task.id == "art-asset-plan") + .map(|task| task.status.clone()) + .ok_or_else(|| "当前 seed manifest 缺少 art-asset-plan".to_string())?; + match art_status { + GameCreationAppTaskStatus::Failed => Ok( + AutonomousCodePrototypeArtAssetWaitState::DependencyFailed( + "art-asset-plan 已失败,code-prototype 不能继续等待美术回执".to_string(), + ), + ), + GameCreationAppTaskStatus::Pending + | GameCreationAppTaskStatus::Running + | GameCreationAppTaskStatus::WaitingForConfirmation => { + let gap = autonomous_code_prototype_art_asset_reference_gap_at( + root, + "code-prototype", + Some("art-asset-plan"), + false, + )?; + if gap.is_some() { + Ok(AutonomousCodePrototypeArtAssetWaitState::Waiting) + } else { + Ok(AutonomousCodePrototypeArtAssetWaitState::NotWaiting) + } + } + GameCreationAppTaskStatus::Completed => { + Ok(AutonomousCodePrototypeArtAssetWaitState::NotWaiting) + } + } +} + pub(in crate::agent) fn game_index_missing_visible_art_slice( root: &Path, html: &[u8], @@ -4237,12 +4327,21 @@ fn identifier_before(content: &str, position: usize) -> Option { #[derive(Clone)] struct JavascriptCanvasDraw { position: usize, - image: JavascriptSymbolId, + image: JavascriptCanvasImageReference, arguments: Vec, has_visible_destination: bool, has_visible_tile_grid_destination: bool, } +#[derive(Clone)] +enum JavascriptCanvasImageReference { + Symbol(JavascriptSymbolId), + Member { + root: JavascriptSymbolId, + path: Vec, + }, +} + #[derive(Default)] struct JavascriptNumericConstantCollector { values: BTreeMap, @@ -4625,6 +4724,8 @@ struct JavascriptCanvasVisualCollector<'a> { context_events: BTreeMap>>, source_events: BTreeMap>>, + member_source_events: + BTreeMap<(JavascriptSymbolId, Vec), Vec>>, draws: Vec, } @@ -5297,35 +5398,46 @@ impl<'a> VisitJavascript<'a> for JavascriptCanvasVisualCollector<'_> { .static_property_name() .is_some_and(|name| name == "src") { - if let JavascriptExpression::Identifier(identifier) = member.object() { - if let Some(symbol_id) = self.symbol_for_identifier(identifier) { - let position = assignment.span.end as usize; - if !javascript_position_is_in_literal_false_block( - self.content, - &self.ranges.literal_false_ranges, - position, - ) { - let source_matches = match &assignment.right { - JavascriptExpression::StringLiteral(source) => { - relative_visual_url_resolves_to_asset( - source.value.as_str(), - self.asset_path, - ) - } - _ => false, - }; - self.source_events.entry(symbol_id).or_default().push( - JavascriptAliasEvent { - position, - scope: javascript_alias_scope_at(self.ranges, position), - value: Some(source_matches), - conditional: javascript_position_is_conditionally_executed( - self.conditional_ranges, - position, - ), - }, - ); + let Some((root, path)) = + javascript_expression_root_symbol_and_member_path( + member.object(), + self.scoping, + ) + else { + oxc_ast_visit::walk::walk_assignment_expression(self, assignment); + return; + }; + let position = assignment.span.end as usize; + if !javascript_position_is_in_literal_false_block( + self.content, + &self.ranges.literal_false_ranges, + position, + ) { + let source_matches = match &assignment.right { + JavascriptExpression::StringLiteral(source) => { + relative_visual_url_resolves_to_asset( + source.value.as_str(), + self.asset_path, + ) } + _ => false, + }; + let event = JavascriptAliasEvent { + position, + scope: javascript_alias_scope_at(self.ranges, position), + value: Some(source_matches), + conditional: javascript_position_is_conditionally_executed( + self.conditional_ranges, + position, + ), + }; + if path.is_empty() { + self.source_events.entry(root).or_default().push(event); + } else { + self.member_source_events + .entry((root, path)) + .or_default() + .push(event); } } } @@ -5354,8 +5466,19 @@ impl<'a> VisitJavascript<'a> for JavascriptCanvasVisualCollector<'_> { .arguments .first() .and_then(JavascriptArgument::as_expression) - .and_then(JavascriptExpression::get_identifier_reference) - .and_then(|identifier| self.symbol_for_identifier(identifier)); + .and_then(|expression| match expression { + JavascriptExpression::Identifier(identifier) => self + .symbol_for_identifier(identifier) + .map(JavascriptCanvasImageReference::Symbol), + _ => javascript_expression_root_symbol_and_member_path( + expression, + self.scoping, + ) + .filter(|(_, path)| !path.is_empty()) + .map(|(root, path)| { + JavascriptCanvasImageReference::Member { root, path } + }), + }); if let (Some(canvas), Some(image)) = (canvas, image) { let arguments = call .arguments @@ -5518,6 +5641,7 @@ fn javascript_canvas_visual_draws( canvas_events: BTreeMap::new(), context_events: BTreeMap::new(), source_events: BTreeMap::new(), + member_source_events: BTreeMap::new(), draws: Vec::new(), }; collector.visit_program(&parsed.program); @@ -5534,34 +5658,55 @@ fn javascript_canvas_visual_draws( }) { return false; } - let matches = collector - .source_events - .get(&draw.image) - .and_then(|events| { - if events.iter().all(|event| event.value != Some(true)) { - return None; - } - if let Some(value) = - javascript_stable_ancestor_event_value(events, &ranges, draw.position) - { - return Some(vec![value]); - } - javascript_alias_event_values( - events, - &ranges, - content, - &ranges.literal_false_ranges, - &conditional_ranges, - draw.position, - ) - .map(|values| values.into_iter().copied().collect::>()) - }) - .is_some_and(|values| !values.is_empty() && values.into_iter().all(|value| value)); - matches + let events = match &draw.image { + JavascriptCanvasImageReference::Symbol(symbol) => { + collector.source_events.get(symbol).map(Vec::as_slice) + } + JavascriptCanvasImageReference::Member { root, path } => collector + .member_source_events + .get(&(*root, path.clone())) + .map(Vec::as_slice), + }; + javascript_source_events_match_at( + events, + &ranges, + content, + &ranges.literal_false_ranges, + &conditional_ranges, + draw.position, + ) }) .collect::>() } +fn javascript_source_events_match_at( + events: Option<&[JavascriptAliasEvent]>, + ranges: &NamedJavascriptFunctionRanges, + content: &str, + literal_false_ranges: &JavascriptLiteralFalseRangeIndex, + conditional_ranges: &[std::ops::Range], + use_position: usize, +) -> bool { + let Some(events) = events else { + return false; + }; + if events.iter().all(|event| event.value != Some(true)) { + return false; + } + if let Some(value) = javascript_stable_ancestor_event_value(events, ranges, use_position) { + return value; + } + javascript_alias_event_values( + events, + ranges, + content, + literal_false_ranges, + conditional_ranges, + use_position, + ) + .is_some_and(|values| !values.is_empty() && values.into_iter().all(|value| *value)) +} + fn javascript_may_reference_canvas_draw(content: &str) -> bool { if content.contains("drawImage") { return true; @@ -6152,6 +6297,9 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked( root: &Path, state: &AgentRuntimeState, ) -> Option { + if autonomous_relaxed_profile(state) { + return None; + } let binding = match autonomous_manifest_ready_task_parent_binding_for_state_at(root, state) { Ok(None) => return None, Ok(Some(binding)) => binding, @@ -6391,7 +6539,7 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked( return Some(autonomous_completion_blocker( "code-prototype 必须实际使用平台生成的美术资源", format!( - "task={} missingPaths={},请先通过 asset.list 核对 Canvas 来源并在 game/index.html 中可见使用对应平台美术资源", + "task={} missingPaths={},请先通过 asset.list 核对 Canvas 来源,并在 game/index.html 的可见 canvas requestAnimationFrame 主循环中按 assets/art-spritesheet-slices/manifest.json 为四个切片调用 ctx.drawImage;仅 img/picture、整张图或路径字符串不满足完成合同", state.agent_id, gap ), )); @@ -14525,6 +14673,54 @@ fn validate_autonomous_playtest_executor_state_lineage_at( if state.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { return Err("自主试玩执行 Run Profile 无效".to_string()); } + + // The supervisor's final validation turn is still part of the legacy + // autonomous flow. The actual browser-capable child has already run and + // owns the executor binding, but the supervisor may re-run + // `preview.validate` to refresh the receipt with its own action identity. + // Keep that path compatible instead of failing before the browser is even + // reached; child runs continue through the strict lineage below. + if state.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + let root_binding = read_game_creator_agent_runtime_run_profile_binding( + root, + &state.agent_id, + &state.run_id, + )? + .ok_or_else(|| "自主试玩执行 root 缺少 Run Profile 绑定".to_string())?; + if root_binding.root_agent_id != root_binding.agent_id + || root_binding.root_run_id != root_binding.run_id + || root_binding.parent_agent_id.is_some() + || root_binding.parent_run_id.is_some() + || root_binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || root_binding.binding_fingerprint != state.run_profile_binding_fingerprint + { + return Err("自主试玩执行 root Run Profile 绑定无效".to_string()); + } + let expected_agent_id = + autonomous_playtest_executor_agent_id_for_root_source(&root_binding.source)?; + let expected_run_id = + autonomous_manifest_ready_task_run_id(&root_binding.run_id, expected_agent_id); + let child_binding = read_game_creator_agent_runtime_run_profile_binding( + root, + expected_agent_id, + &expected_run_id, + )? + .ok_or_else(|| "自主试玩执行 child 缺少 Run Profile 绑定".to_string())?; + if child_binding.agent_id != expected_agent_id + || child_binding.run_id != expected_run_id + || child_binding.source != "agent-ready-task-scheduler" + || child_binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || child_binding.root_agent_id != root_binding.agent_id + || child_binding.root_run_id != root_binding.run_id + || child_binding.parent_agent_id.as_deref() != Some(root_binding.agent_id.as_str()) + || child_binding.parent_run_id.as_deref() != Some(root_binding.run_id.as_str()) + || child_binding.parent_binding_fingerprint.as_deref() + != Some(root_binding.binding_fingerprint.as_str()) + { + return Err("自主试玩执行 child 与根 Run Profile 绑定不匹配".to_string()); + } + return Ok((child_binding, root_binding)); + } ensure_current_autonomous_ready_child_mutation_at_locked(root, &state.agent_id, &state.run_id) .map_err(|error| format!("自主试玩执行 child 身份不可用:{error}"))?; let binding = @@ -15063,6 +15259,14 @@ pub(in crate::agent) fn autonomous_game_build_completion_blocker_at_locked( root: &Path, state: &AgentRuntimeState, ) -> Option { + if autonomous_relaxed_profile(state) { + // Free-form autonomous runs do not validate platform artifacts (or + // even require a game entry) as part of Runtime completion. Project + // root scoping and tool permissions are enforced by the file/tool + // layers; this function is only the legacy artifact/acceptance gate. + let _ = root; + return None; + } if let Some(blocker) = autonomous_manifest_ready_task_completion_blocker_at_locked(root, state) { return Some(blocker); @@ -15489,6 +15693,60 @@ mod visible_destination_tests { } } + #[test] + fn canvas_asset_analysis_accepts_static_member_image_paths() { + let root = tempfile::tempdir().expect("create static member image fixture"); + let html = br#" + + + "#; + assert!(game_index_visibly_uses_visual_asset( + root.path(), + html, + "assets/player.png", + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + )); + } + + #[test] + fn canvas_asset_analysis_accepts_global_member_image_paths() { + let root = tempfile::tempdir().expect("create global member image fixture"); + let html = br#" + + + "#; + assert!(game_index_visibly_uses_visual_asset( + root.path(), + html, + "assets/art-spritesheet.png", + (256, 256), + VisualAssetUsageRequirement::AtlasCanvasCrop, + )); + } + fn mixed_case_main_loop_html(invocation: &str) -> Vec { format!( "" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs index 60f436634..f10caee1d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs @@ -188,7 +188,9 @@ pub(in crate::agent) fn validate_agent_runtime_pending_context( // source 五项身份检查在上面已经全部通过,轮次检查在下面继续执行,转述 pending // 本身也只能由 Runtime 在本 run 内生成,所以豁免不放开任何跨 run 或跨身份的 // 重放面。 - if !agent_runtime_task_is_delegate_clarification_relay(&pending.task) { + if !autonomous_relaxed_run_profile(&pending.run_profile) + && !agent_runtime_task_is_delegate_clarification_relay(&pending.task) + { validate_agent_runtime_context_task_parameter(root, runtime, &pending.task)?; } if pending.loop_iteration != runtime.loop_iteration { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs index e4bf34b09..5a98f6173 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs @@ -268,7 +268,12 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_finalization_journal &journal.plan_steps, journal.active_plan_step_index, )?; - if journal.plan_revision > 0 + // The free-form autonomous game-build lane may finalize while its + // specialist work is still settling. A structured plan snapshot is + // still shape-checked and fingerprinted below, but an in-progress + // step is context rather than a completion gate in this lane. + if !autonomous_relaxed_run_profile(&journal.run_profile) + && journal.plan_revision > 0 && (journal.active_plan_step_index.is_some() || journal .plan_steps diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs index b235e7361..fe936f77e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs @@ -110,14 +110,13 @@ pub(in crate::agent) fn validate_agent_runtime_run_profile_binding_record( { return Err("Agent Runtime 根 Run Profile 绑定身份无效".to_string()); } - if binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD - && (binding.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - || !agent_runtime_supervisor_source_is_autonomous_game_build(&binding.source)) - { - return Err("自主构建 Run Profile 只允许可信 Supervisor 入口绑定".to_string()); - } + // autonomous-game-build is an execution profile, not an identity + // gate. In the relaxed flow a child may be started directly (without + // a parent/delegation link), so do not reject a root binding merely + // because its agent/source is not the Supervisor entry point. } else if binding.parent_binding_fingerprint.is_none() && binding.profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD + && binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { return Err("非标准子 Run Profile 缺少父绑定指纹".to_string()); } @@ -243,6 +242,14 @@ pub(crate) fn read_game_creator_agent_runtime_run_profile_binding( let Some(binding) = root_binding.as_ref() else { return Ok(None); }; + // autonomous-game-build intentionally treats parent/child metadata as + // correlation context rather than an execution state machine. Validate + // this binding's own project/run/profile fingerprint above, then return + // it without walking (or requiring) a parent chain. Standard runs keep + // the original lineage validation below. + if binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + return Ok(Some(binding.clone())); + } let mut child = binding.clone(); let mut visited = BTreeSet::new(); while let (Some(parent_agent_id), Some(parent_run_id)) = ( @@ -334,24 +341,36 @@ pub(crate) fn bind_game_creator_agent_runtime_run_profile_at( }) .transpose()? .flatten(); + let requested_profile = requested_profile + .map(|profile| normalize_agent_runtime_run_profile(Some(profile))) + .transpose()?; let (profile, root_agent_id, root_run_id, parent_binding_fingerprint) = if let Some(parent) = parent_binding.as_ref() { if requested_profile - .map(|profile| normalize_agent_runtime_run_profile(Some(profile))) - .transpose()? - .is_some_and(|profile| profile != parent.profile) + .as_ref() + .is_some_and(|profile| profile.as_str() != parent.profile.as_str()) + && !requested_profile.as_deref().is_some_and(|profile| { + profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + }) { return Err("子 Run 不能切换父 Run 的 Run Profile".to_string()); } + let profile = requested_profile + .clone() + .unwrap_or_else(|| parent.profile.clone()); ( - parent.profile.clone(), + profile, parent.root_agent_id.clone(), parent.root_run_id.clone(), Some(parent.binding_fingerprint.clone()), ) } else if parent_identity.is_some() { - let profile = normalize_agent_runtime_run_profile(requested_profile)?; - if profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD { + let profile = requested_profile + .clone() + .unwrap_or_else(|| AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string()); + if profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD + && profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + { return Err("自主子 Run 缺少父 Run Profile 绑定".to_string()); } let (parent_agent_id, parent_run_id) = parent_identity.expect("parent identity exists"); @@ -363,7 +382,9 @@ pub(crate) fn bind_game_creator_agent_runtime_run_profile_at( ) } else { ( - normalize_agent_runtime_run_profile(requested_profile)?, + requested_profile + .clone() + .unwrap_or_else(|| AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string()), agent_id.clone(), run_id.to_string(), None, 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 6e29595eb..9384ca65d 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 @@ -394,6 +394,16 @@ pub(super) fn start_game_creator_agent_runtime_task_for_session_in_session_lane_ .ok() .map(|result| result.state); let mut state = default_game_creator_agent_runtime_state(&agent_id, &run_id); + // A queued autonomous task may be owned by an Agent whose runtime + // identity differs from the manifest task it is executing. Preserve the + // durable task_id when hydrating the state; falling back to agent_id keeps + // legacy/ordinary runs unchanged. + state.task_id = queued_task_record + .as_ref() + .map(|record| record.task_id.trim()) + .filter(|task_id| !task_id.is_empty()) + .unwrap_or(agent_id.as_ref()) + .to_string(); state.started_at = queued_task_record .as_ref() .map(|record| record.updated_at) @@ -1203,39 +1213,50 @@ where }, )); } - let current_revision = read_game_creator_agent_runtime_project_revision(root)?; - let blocker = if let Some(blocker) = structured_plan_completion_blocker(&state) { - Some(blocker) - } else if let Some(blocker) = game_creator_agent_goal_completion_blocker_at_locked(root, &state) - { - Some(blocker) - } else if let Some(blocker) = - goal_contract_acceptance_completion_blocker_at_locked(root, &state) - { - Some(blocker) - } else if let Some(blocker) = agent_runtime_non_verification_completion_blocker_at_locked( - root, - &state.agent_id, - &state.run_id, - ) { - Some(blocker) - } else if let Some(blocker) = autonomous_game_build_completion_blocker_at_locked(root, &state) { - Some(blocker) - } else if current_revision.revision != response_revision { - Some(agent_runtime_verification_blocker( - "最终回复基于的项目 revision 已过期,不能把任务标记为完成", - format!( - "responseRevision={response_revision}, currentRevision={};请根据最新项目状态重新规划后再生成最终回复。", - current_revision.revision - ), - )) + let relaxed_autonomous = autonomous_relaxed_profile(&state); + let blocker = if relaxed_autonomous { + // No manifest, project-revision, verification or platform-artifact + // read is part of relaxed finalization. The response can settle as + // soon as cancellation/steer handling above has succeeded. + None } else { - evaluate_project_verification_completion_at_locked( + let current_revision = read_game_creator_agent_runtime_project_revision(root)?; + if let Some(blocker) = structured_plan_completion_blocker(&state) { + Some(blocker) + } else if let Some(blocker) = + game_creator_agent_goal_completion_blocker_at_locked(root, &state) + { + Some(blocker) + } else if let Some(blocker) = + goal_contract_acceptance_completion_blocker_at_locked(root, &state) + { + Some(blocker) + } else if let Some(blocker) = agent_runtime_non_verification_completion_blocker_at_locked( root, &state.agent_id, &state.run_id, - observations, - )? + ) { + Some(blocker) + } else if let Some(blocker) = + autonomous_game_build_completion_blocker_at_locked(root, &state) + { + Some(blocker) + } else if current_revision.revision != response_revision { + Some(agent_runtime_verification_blocker( + "最终回复基于的项目 revision 已过期,不能把任务标记为完成", + format!( + "responseRevision={response_revision}, currentRevision={};请根据最新项目状态重新规划后再生成最终回复。", + current_revision.revision + ), + )) + } else { + evaluate_project_verification_completion_at_locked( + root, + &state.agent_id, + &state.run_id, + observations, + )? + } }; if let Some(blocker) = blocker { let detail = blocker.detail.as_deref().unwrap_or_default(); @@ -3308,7 +3329,12 @@ fn append_unique_game_creator_agent_runtime_task_with_initial_state( append_game_creator_agent_runtime_task_record_unlocked(root, &record)?; drop(_journal_lock); drop(autonomous_root_project_lock); - if let Err(error) = ensure_autonomous_completion_contract_for_task_at(root, &record) { + // The relaxed autonomous lane does not create or validate completion + // contracts. Contracts are delivery metadata, not a prerequisite for + // starting or executing a task; keeping this best-effort hook for the + // strict/legacy profiles preserves their existing recovery behavior. + if !autonomous_relaxed_run_profile(&record.run_profile) { + if let Err(error) = ensure_autonomous_completion_contract_for_task_at(root, &record) { let public_error = redact_agent_runtime_project_paths(root, &error, 500); let failed = AgentRuntimeTaskRecord { status: "failed".to_string(), @@ -3321,6 +3347,7 @@ fn append_unique_game_creator_agent_runtime_task_with_initial_state( }; append_game_creator_agent_runtime_task_record_unlocked(root, &failed)?; return Err(format!("自主构建完成合同建立失败,任务未执行:{error}")); + } } Ok(record) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs index 7658602b1..1478ae0aa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs @@ -77,9 +77,34 @@ pub(crate) use policy::{ game_creator_agent_runtime_tool_policy_block_after_lock, game_creator_agent_runtime_tool_policy_rule_for_run, }; +pub(crate) use preview::{ + observe_agent_runtime_preview_start, observe_agent_runtime_preview_validate, +}; #[allow(unused_imports)] pub(crate) use project_ops::{ observe_agent_runtime_project_git_commit_locked_with_audit, observe_agent_runtime_project_patchset_with_audit, }; pub(crate) use run_status::observe_agent_runtime_run_status; + +/// Validation tools are retained for compatibility with older provider turns, +/// but a relaxed autonomous game-build run must not let an accidental call +/// reintroduce the legacy verification/preview gates. Resolve the profile +/// from durable run metadata and report a terminal no-op observation before +/// any tool-specific parsing or side effects occur. +pub(crate) fn relaxed_autonomous_validation_skip_observation( + root: &Path, + agent_id: &str, + run_id: &str, + tool: &str, +) -> Option { + autonomous_relaxed_run_at(root, agent_id, run_id) + .ok() + .filter(|relaxed| *relaxed) + .map(|_| AgentRuntimeToolObservation { + tool: tool.to_string(), + status: "ok".to_string(), + summary: format!("{tool} 在 relaxed autonomous lane 中跳过,未执行平台验证"), + detail: Some("relaxedAutonomous=true · skipped=true".to_string()), + }) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/command_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/command_ops.rs index 15e3382d1..b99c3e022 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/command_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/command_ops.rs @@ -713,6 +713,14 @@ pub(crate) fn observe_agent_runtime_limited_command( run_id: &str, input: &serde_json::Value, ) -> AgentRuntimeToolObservation { + if let Some(observation) = relaxed_autonomous_validation_skip_observation( + root, + agent_id, + run_id, + "command.run_limited", + ) { + return observation; + } let command_id = agent_runtime_tool_input_text(input, &["commandId", "command", "id"]); if command_id.trim().is_empty() { return AgentRuntimeToolObservation { @@ -837,6 +845,11 @@ pub(crate) async fn observe_agent_runtime_project_verify( action_fingerprint: &str, input: &serde_json::Value, ) -> AgentRuntimeToolObservation { + if let Some(observation) = + relaxed_autonomous_validation_skip_observation(root, agent_id, run_id, "project.verify") + { + return observation; + } let script = agent_runtime_tool_input_text(input, &["script"]); let expected_command = input .get("expectedCommand") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs index 640b57011..4e29d3356 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs @@ -429,6 +429,54 @@ pub(in crate::agent) fn wake_waiting_autonomous_manifest_parent_run_at( if current_task.status != "running" || current_task.phase != "waiting-for-manifest-tasks" { return Ok(false); } + if autonomous_relaxed_run_profile(¤t_task.run_profile) { + // `waiting-for-manifest-tasks` may exist on a run written by an older + // Runtime. In the free-form lane it is not a real barrier: resume the + // same task immediately without consulting the DAG, owner, parent or + // delivery lineage. + let state = read_game_creator_agent_runtime_for_session_at( + root, + ¤t_task.agent_id, + Some(¤t_task.session_id), + )? + .state; + if state.run_id != current_task.run_id + || state.session_id != current_task.session_id + || state.status != "running" + || state.phase != "waiting-for-manifest-tasks" + { + return Ok(false); + } + if external_agent_runner_owns_background_execution() { + wake_external_agent_runner_pending_for_run( + root, + ¤t_task.agent_id, + ¤t_task.run_id, + state.loop_iteration, + )?; + return Ok(true); + } + let Some(runtime_lock) = + try_acquire_game_creator_agent_runtime_task_lock(root, ¤t_task.agent_id)? + else { + return Ok(false); + }; + let state = advance_game_creator_agent_runtime_turn_at( + root, + state, + "planning", + "自主任务已解除旧任务图等待,继续执行", + "manifest 只作为上下文,恢复同一 run。", + )?; + let root = root.to_path_buf(); + let agent_id = current_task.agent_id.clone(); + let task = current_task.task.clone(); + tauri::async_runtime::spawn(async move { + let _runtime_lock = runtime_lock; + drain_game_creator_agent_background_tasks(root, agent_id, task, state).await; + }); + return Ok(true); + } if current_task.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID || current_task.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs index 4daf3d6f7..220dae8ed 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs @@ -7,6 +7,9 @@ pub(in crate::agent) fn agent_role_project_path_mutation_block( tool: &str, path: &str, ) -> Option { + if autonomous_relaxed_run_at(root, agent_id, run_id).unwrap_or(false) { + return None; + } if is_agent_planning_storage_path(path) || is_plan_fast_gdd_projection_path(path) { return Some(AgentRuntimeToolObservation { tool: tool.to_string(), 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 882c7b8ed..fa17cbfcc 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 @@ -373,7 +373,10 @@ pub(in crate::agent) async fn observe_agent_runtime_image_inspect( }; } }; - let response = match client.run(request).await { + // 遵循当前 Agent 的传输配置。开发 Provider 要求 `stream=true`,image.inspect + // 虽然最终仍是文本结果,但必须先消费 SSE;直接调用 `run` 会强制非流式请求, + // 被上游以 400 拒绝。 + let response = match request_game_creator_llm_text(&client, &llm, request).await { Ok(response) => response, Err(error) => { let error = game_creator_agent_llm_error_public_summary(&error); @@ -884,18 +887,33 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio ); } }; - let committed = commit_prepared_platform_art_asset_at(root, prepared, &options, |_| { - let output_path = options - .output_path - .as_deref() - .expect("replaceExisting commit guard requires outputPath"); - validate_agent_runtime_canvas_replacement_authorization_at( - root, - agent_id, - run_id, - output_path, - ) - }); + let committed = if options.asset_kind == "art-spritesheet" { + commit_prepared_platform_art_asset_strict_slices_at(root, prepared, &options, |_| { + let output_path = options + .output_path + .as_deref() + .expect("replaceExisting commit guard requires outputPath"); + validate_agent_runtime_canvas_replacement_authorization_at( + root, + agent_id, + run_id, + output_path, + ) + }) + } else { + commit_prepared_platform_art_asset_at(root, prepared, &options, |_| { + let output_path = options + .output_path + .as_deref() + .expect("replaceExisting commit guard requires outputPath"); + validate_agent_runtime_canvas_replacement_authorization_at( + root, + agent_id, + run_id, + output_path, + ) + }) + }; match committed { Ok(generated) => { let verification = begin_agent_runtime_project_verification_locked( 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 0141b8938..8fc4f477f 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 @@ -229,13 +229,14 @@ pub(crate) fn game_creator_agent_runtime_tool_policy_rule_for_run( Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_)) if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD => { - if AGENT_RUNTIME_AUTONOMOUS_GAME_BUILD_AUTO_COMMAND_IDS.contains(&command_id) { - None - } else { - Some(AgentRuntimeToolPolicyBlock::Denied(format!( - "自主构建模式不能等待人工确认:{command_id};请改用 auto-safe 工具或省略该动作" - ))) - } + // `confirm_commands` is a UI/interactive policy concept. The + // autonomous game-build lane has no confirmation consumer, so a + // confirmation rule must not turn into a synthetic denial (which + // makes the Provider loop forever trying a different spelling of + // the same useful action). Explicit `denied_commands` and the + // role/project allowlists have already returned above and still + // win here; only the confirmation bit is relaxed. + None } blocked => blocked, } @@ -263,9 +264,11 @@ pub(crate) fn fail_closed_agent_runtime_confirmation_for_run( Err(error) => return Some(AgentRuntimeToolPolicyBlock::Denied(error)), }; if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { - Some(AgentRuntimeToolPolicyBlock::Denied(format!( - "自主构建模式不能等待人工确认:{reason};请改用 auto-safe 工具或省略该动作" - ))) + // There is deliberately no human-confirmation turn in the relaxed + // autonomous lane. Preserve explicit denies, but execute an action + // that was only marked `confirm` just like an ordinary auto action. + let _ = reason; + None } else { Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(reason)) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs index a612dec3f..16c72bd89 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs @@ -59,10 +59,16 @@ pub(in crate::agent) fn agent_runtime_preview_infrastructure_blocker( }) } -pub(in crate::agent) fn observe_agent_runtime_preview_start( +pub(crate) fn observe_agent_runtime_preview_start( root: &Path, agent_id: &str, + run_id: &str, ) -> AgentRuntimeToolObservation { + if let Some(observation) = + relaxed_autonomous_validation_skip_observation(root, agent_id, run_id, "preview.start") + { + return observation; + } let registry = game_creator_preview_registry(); let result = start_local_game_preview_at(root, ®istry).and_then(|preview| { append_agent_db_record( @@ -133,7 +139,7 @@ pub(in crate::agent) fn browser_validation_relative_path(root: &Path, path: &Pat .join("/") } -pub(in crate::agent) async fn observe_agent_runtime_preview_validate( +pub(crate) async fn observe_agent_runtime_preview_validate( root: &Path, agent_id: &str, run_id: &str, @@ -141,6 +147,11 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( action_fingerprint: &str, input: &serde_json::Value, ) -> AgentRuntimeToolObservation { + if let Some(observation) = + relaxed_autonomous_validation_skip_observation(root, agent_id, run_id, "preview.validate") + { + return observation; + } let input = match serde_json::from_value::(input.clone()) { Ok(input) => input, Err(error) => { @@ -249,10 +260,25 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( } }, }; + // The supervisor performs a final preview.validate after the dedicated + // preview-playtest child. Keep the evidence under the executor identity + // that the autonomous receipt already binds to; otherwise the final + // supervisor receipt would point at a different evidence namespace and + // fail its own write/read-back validation. + let (evidence_agent_id, evidence_run_id) = if completion_contract.is_some() + && runtime.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + { + ( + "preview-playtest".to_string(), + autonomous_manifest_ready_task_run_id(&runtime.run_id, "preview-playtest"), + ) + } else { + (agent_id.to_string(), run_id.to_string()) + }; let evidence_relative_root = format!( ".agent/runtime/browser-validations/{}/{}/{}", - agent_runtime_confirmation_path_component(agent_id, "agent"), - agent_runtime_confirmation_path_component(run_id, "run"), + agent_runtime_confirmation_path_component(&evidence_agent_id, "agent"), + agent_runtime_confirmation_path_component(&evidence_run_id, "run"), revision_before.revision, ); let evidence_root = match resolve_local_project_path(root, &evidence_relative_root) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/run_status.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/run_status.rs index 1a5c0002c..1163c3b05 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/run_status.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/run_status.rs @@ -111,6 +111,17 @@ pub(crate) fn observe_agent_runtime_run_status( }) } .and_then(|mut detail| { + // autonomous-game-build is the free-form lane. A status read must be + // observational only there: do not claim delegate receipts, inspect + // isolated joins, create planning acceptance gates, or consult the + // collaboration policy as hidden execution prerequisites. The + // profile binding is read and validated here, so malformed bindings + // still surface as an error instead of being silently downgraded. + let relaxed_autonomous = autonomous_relaxed_run_at(root, agent_id, run_id)?; + if relaxed_autonomous { + let detail = sanitize_prompt_context(&detail); + return Ok((detail, 0, 0, 0, 0, None)); + } let collaboration_policy_status = (agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) .then(|| { supervisor_collaboration_policy_status_for_run_at(root, agent_id, run_id) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs index 204b0ac0a..b66c10e73 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs @@ -1,15 +1,40 @@ use super::*; +fn task_ops_relaxed_autonomous_profile_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result { + autonomous_relaxed_run_at(root, agent_id, run_id) +} + pub(in crate::agent) fn observe_agent_runtime_task_list( root: &Path, agent_id: &str, run_id: &str, ) -> AgentRuntimeToolObservation { let result = (|| -> Result { - let _ = (agent_id, run_id); + let relaxed_autonomous = task_ops_relaxed_autonomous_profile_at(root, agent_id, run_id)?; let manifest = read_manifest_for_project(root)?; let visible_tasks = manifest.tasks.clone(); - let ready_task_ids = ready_task_ids_for_tasks(&visible_tasks); + // In the autonomous lane dependencies describe useful context for the + // Agent, not a scheduler gate. Keep the ordinary task graph behavior + // for standard runs and only widen readiness for this run profile. + let ready_task_ids = if relaxed_autonomous { + visible_tasks + .iter() + .filter(|task| { + matches!( + task.status, + GameCreationAppTaskStatus::Pending + | GameCreationAppTaskStatus::WaitingForConfirmation + ) + }) + .map(|task| task.id.clone()) + .collect() + } else { + ready_task_ids_for_tasks(&visible_tasks) + }; let seed_task_ids = autonomous_manifest_seed_tasks_for_source(AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE) .into_iter() @@ -307,7 +332,18 @@ pub(in crate::agent) fn observe_agent_runtime_task_update( detail: None, }; } - if status == GameCreationAppTaskStatus::Completed { + let relaxed_autonomous = match task_ops_relaxed_autonomous_profile_at(root, agent_id, run_id) { + Ok(value) => value, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "task.update".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + }; + if status == GameCreationAppTaskStatus::Completed && !relaxed_autonomous { if let Some(blocker) = visual_asset_completion_blocker_at_locked(root, task_id.as_str(), None) { 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 25b23d44d..d58b413c9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1965,6 +1965,10 @@ fn main() { eprintln!("Agent Runner 必须显式传入 --config-dir "); std::process::exit(1); }; + if let Err(error) = load_platform_session_fixture_from_env(&config_dir) { + eprintln!("agent.runner.failed: {error}"); + std::process::exit(1); + } set_game_creator_runtime_config_dir(config_dir.clone()); if let Err(error) = run_external_agent_runner_server(config_dir, gui_owner_required) { eprintln!("agent.runner.failed: {error}"); @@ -1983,15 +1987,21 @@ fn main() { } }; if let Some(config_dir) = config_dir { - let configured = if command.is_read_only_status() { - configure_external_agent_runner_read_only(&config_dir) - } else { - configure_external_agent_runner(&config_dir) - }; - if let Err(error) = configured { + if let Err(error) = load_platform_session_fixture_from_env(&config_dir) { eprintln!("agent.runner.failed: {error}"); std::process::exit(1); } + if command.requires_external_agent_runner() { + let configured = if command.is_read_only_status() { + configure_external_agent_runner_read_only(&config_dir) + } else { + configure_external_agent_runner(&config_dir) + }; + if let Err(error) = configured { + eprintln!("agent.runner.failed: {error}"); + std::process::exit(1); + } + } set_game_creator_runtime_config_dir(config_dir); } if command.requires_started_external_agent_runner() { @@ -2060,6 +2070,12 @@ fn main() { } error })?; + load_platform_session_fixture_from_env(&config_dir).map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!("加载平台登录态测试 fixture 失败:{error}"), + ) + })?; if let Some(path) = setup_log.as_deref() { let _ = append_bounded_diagnostic_line(path, "startup.runner.configure.begin"); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs index 4f1695219..daf5cd59f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs @@ -1,6 +1,19 @@ use sha2::{Digest, Sha256}; +use serde::Deserialize; +use std::fs::{self, OpenOptions}; +use std::io::Read; +use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; +/// Debug-only fixture hook used by the deterministic AGC E2E. The hook takes +/// a path, rather than credentials on argv, so a child Runner can inherit the +/// test identity without putting the bearer token in process listings. +pub(crate) const PLATFORM_SESSION_FIXTURE_ENV: &str = + "GENARRATIVE_AGC_PLATFORM_SESSION_FIXTURE"; +const PLATFORM_SESSION_FIXTURE_SCHEMA_VERSION: &str = + "genarrative-agc-platform-session-fixture.v1"; +const PLATFORM_SESSION_FIXTURE_MAX_BYTES: u64 = 16 * 1024; + #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct PlatformSessionSnapshot { pub(crate) user_id: String, @@ -30,6 +43,204 @@ pub(crate) fn editor_api_mode() -> EditorApiMode { editor_api_mode_for_build(cfg!(debug_assertions), cfg!(debug_assertions)) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PlatformSessionFixture { + schema_version: String, + user_id: String, + access_token: String, + api_base_url: String, + generation: u64, +} + +fn metadata_is_link_or_reparse(metadata: &fs::Metadata) -> bool { + if metadata.file_type().is_symlink() { + return true; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return true; + } + } + false +} + +fn path_component_is_inside(root: &Path, candidate: &Path) -> bool { + #[cfg(windows)] + { + // Windows paths are case-insensitive. Comparing normalized UTF-16 + // strings would introduce an unnecessary lossy conversion; component + // comparison with ASCII-folding covers the drive/normal path forms + // used by the fixture while retaining separators as boundaries. + let root = root.components().collect::>(); + let candidate = candidate.components().collect::>(); + return candidate.len() > root.len() + && root.iter().zip(candidate.iter()).all(|(left, right)| { + left.as_os_str() + .to_string_lossy() + .eq_ignore_ascii_case(&right.as_os_str().to_string_lossy()) + }); + } + #[cfg(not(windows))] + { + candidate.starts_with(root) && candidate != root + } +} + +fn validate_fixture_path(config_dir: &Path, fixture_path: &Path) -> Result { + if !config_dir.is_absolute() { + return Err("平台登录态 fixture 所在 AppData 必须是绝对路径".to_string()); + } + if !fixture_path.is_absolute() { + return Err("平台登录态 fixture 路径必须是绝对路径".to_string()); + } + + // Check the directory entry before canonicalization so a symlink/junction + // cannot be silently followed into an unrelated credential location. + let config_entry = fs::symlink_metadata(config_dir) + .map_err(|_| "平台登录态 fixture AppData 不可读取".to_string())?; + if metadata_is_link_or_reparse(&config_entry) || !config_entry.is_dir() { + return Err("平台登录态 fixture AppData 必须是普通目录".to_string()); + } + let canonical_config = fs::canonicalize(config_dir) + .map_err(|_| "平台登录态 fixture AppData 不可解析".to_string())?; + + let fixture_entry = fs::symlink_metadata(fixture_path) + .map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?; + if metadata_is_link_or_reparse(&fixture_entry) || !fixture_entry.is_file() { + return Err("平台登录态 fixture 必须是普通文件".to_string()); + } + if fixture_entry.len() > PLATFORM_SESSION_FIXTURE_MAX_BYTES { + return Err("平台登录态 fixture 过大,已拒绝读取".to_string()); + } + let canonical_fixture = fs::canonicalize(fixture_path) + .map_err(|_| "平台登录态 fixture 文件不可解析".to_string())?; + if !path_component_is_inside(&canonical_config, &canonical_fixture) { + return Err("平台登录态 fixture 必须位于 --config-dir 内".to_string()); + } + + // Walk the path below AppData and reject links/reparse points in every + // ancestor as well as at the leaf. This keeps the check useful on + // platforms where canonicalize otherwise follows a junction. + let relative = canonical_fixture + .strip_prefix(&canonical_config) + .map_err(|_| "平台登录态 fixture 必须位于 --config-dir 内".to_string())?; + let mut current = canonical_config; + for component in relative.components() { + current.push(component.as_os_str()); + let metadata = fs::symlink_metadata(¤t) + .map_err(|_| "平台登录态 fixture 路径不可读取".to_string())?; + if metadata_is_link_or_reparse(&metadata) { + return Err("平台登录态 fixture 路径不能包含链接或 reparse point".to_string()); + } + if current != canonical_fixture && !metadata.is_dir() { + return Err("平台登录态 fixture 父路径必须是普通目录".to_string()); + } + } + Ok(canonical_fixture) +} + +fn read_fixture_file(path: &Path) -> Result, String> { + let before = fs::symlink_metadata(path) + .map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?; + if metadata_is_link_or_reparse(&before) || !before.is_file() { + return Err("平台登录态 fixture 必须是普通文件".to_string()); + } + if before.len() > PLATFORM_SESSION_FIXTURE_MAX_BYTES { + return Err("平台登录态 fixture 过大,已拒绝读取".to_string()); + } + + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + let mut file = options + .open(path) + .map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?; + let opened = file + .metadata() + .map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?; + if metadata_is_link_or_reparse(&opened) || !opened.is_file() || opened.len() > before.len() { + return Err("平台登录态 fixture 文件身份校验失败".to_string()); + } + let mut bytes = Vec::with_capacity(opened.len().min(PLATFORM_SESSION_FIXTURE_MAX_BYTES) as usize); + file.take(PLATFORM_SESSION_FIXTURE_MAX_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| "读取平台登录态 fixture 失败".to_string())?; + if bytes.len() as u64 > PLATFORM_SESSION_FIXTURE_MAX_BYTES { + return Err("平台登录态 fixture 过大,已拒绝读取".to_string()); + } + Ok(bytes) +} + +fn parse_platform_session_fixture(bytes: &[u8]) -> Result { + let fixture = serde_json::from_slice::(bytes) + .map_err(|_| "平台登录态 fixture 格式无效".to_string())?; + if fixture.schema_version != PLATFORM_SESSION_FIXTURE_SCHEMA_VERSION { + return Err("平台登录态 fixture 版本不受支持".to_string()); + } + if fixture.generation == 0 { + return Err("平台登录态 fixture generation 无效".to_string()); + } + if fixture.user_id.chars().any(|character| character.is_control()) { + return Err("平台登录态 fixture 用户身份无效".to_string()); + } + if fixture.access_token.chars().any(|character| character.is_control()) { + return Err("平台登录态 fixture 凭据无效".to_string()); + } + Ok(fixture) +} + +/// Loads a deliberately narrow, file-backed account fixture for Debug E2E +/// processes. Release binaries fail closed if the hook is present. The +/// fixture path must be a regular file below the process' explicit +/// `--config-dir`; credentials are never accepted on argv or emitted in an +/// error string. +pub(crate) fn load_platform_session_fixture_from_env(config_dir: &Path) -> Result<(), String> { + load_platform_session_fixture_from_env_for_build(config_dir, cfg!(debug_assertions)) +} + +pub(crate) fn load_platform_session_fixture_from_env_for_build( + config_dir: &Path, + debug_build: bool, +) -> Result<(), String> { + let Some(raw_path) = std::env::var_os(PLATFORM_SESSION_FIXTURE_ENV) else { + return Ok(()); + }; + if !debug_build { + return Err("当前发行版拒绝使用平台登录态测试 fixture".to_string()); + } + let raw_path = raw_path + .to_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "平台登录态 fixture 路径无效".to_string())?; + let fixture_path = validate_fixture_path(config_dir, Path::new(raw_path))?; + let bytes = read_fixture_file(&fixture_path)?; + let fixture = parse_platform_session_fixture(&bytes)?; + let snapshot = validated_platform_session_snapshot( + &fixture.user_id, + &fixture.access_token, + &fixture.api_base_url, + fixture.generation, + )?; + let mut current = platform_session() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + // A fresh CLI/Runner normally starts at generation zero. Replacing the + // state here also makes a Debug GUI fixture deterministic without relaxing + // the normal account-switch generation rules. + current.generation = snapshot.generation; + current.snapshot = Some(snapshot); + Ok(()) +} + #[derive(Default)] struct PlatformSessionState { generation: u64, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index ab5253287..c7f8f38e1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -12,11 +12,25 @@ const PROJECT_WRITE_LOCK_MAX_BYTES: u64 = 4 * 1024; pub(crate) struct ProjectWriteLock { path: PathBuf, content: String, + /// In the free-form autonomous lane a single Runtime process may have + /// several specialist actions in flight at once. A file lock is still + /// useful across processes, but making same-process contenders fail turns + /// ordinary parallel work into a dead run (and can deadlock nested tool + /// calls). Such a contender receives an in-process/advisory guard instead + /// of deleting the real holder's lock on drop. + bypassed_same_process: bool, } impl ProjectWriteLock { pub(crate) fn guards_project_root(&self, root: &Path) -> Result { let expected_path = resolve_local_project_path(root, PROJECT_WRITE_LOCK_PATH)?; + if self.bypassed_same_process { + // The relaxed guard deliberately has no ownership of the durable + // `.agent/project.lock` file. It still binds the observation to + // the validated project root so callers cannot use a guard from a + // different project. + return Ok(self.path == expected_path); + } Ok(self.path == expected_path && fs::read_to_string(&self.path).is_ok_and(|content| content == self.content)) } @@ -24,6 +38,9 @@ impl ProjectWriteLock { impl Drop for ProjectWriteLock { fn drop(&mut self) { + if self.bypassed_same_process { + return; + } if fs::read_to_string(&self.path).is_ok_and(|content| content == self.content) { let _ = fs::remove_file(&self.path); } @@ -44,6 +61,59 @@ fn project_write_lock_process_is_alive(process_id: u64) -> Option { } } +#[cfg(windows)] +fn project_write_lock_process_is_alive(process_id: u64) -> Option { + use std::ffi::c_void; + + #[link(name = "kernel32")] + unsafe extern "system" { + fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> *mut c_void; + fn GetExitCodeProcess(process: *mut c_void, exit_code: *mut u32) -> i32; + fn CloseHandle(handle: *mut c_void) -> i32; + } + + let process_id = u32::try_from(process_id).ok().filter(|value| *value > 0)?; + const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; + const STILL_ACTIVE: u32 = 259; + // SAFETY: OpenProcess returns an owned kernel handle or null; it is + // closed below. We only request the query permission needed here. + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) }; + if process.is_null() { + // ERROR_INVALID_PARAMETER means the process no longer exists. For + // access-denied/other failures we cannot prove liveness, so keep the + // conservative unknown result and let the normal bounded wait decide. + return match std::io::Error::last_os_error().raw_os_error() { + Some(87) => Some(false), + _ => None, + }; + } + let mut exit_code = 0_u32; + // SAFETY: `exit_code` is a writable scalar and `process` is a live handle. + let result = unsafe { GetExitCodeProcess(process, &mut exit_code) }; + // SAFETY: `process` is an owned handle returned by OpenProcess. + unsafe { CloseHandle(process) }; + if result == 0 { + return None; + } + Some(exit_code == STILL_ACTIVE) +} + +#[cfg(not(any(unix, windows)))] +fn project_write_lock_process_is_alive(_process_id: u64) -> Option { + None +} + +fn project_write_lock_owner_pid(path: &Path) -> Option { + fs::read_to_string(path) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + .and_then(|payload| payload.get("pid").and_then(serde_json::Value::as_u64)) +} + +fn project_write_lock_is_owned_by_current_process(path: &Path) -> bool { + project_write_lock_owner_pid(path) == Some(u64::from(std::process::id())) +} + fn project_write_lock_age_seconds(path: &Path, metadata: &fs::Metadata) -> u64 { let created_at = fs::read_to_string(path) .ok() @@ -75,7 +145,6 @@ fn project_write_lock_can_be_reclaimed(path: &Path) -> bool { .as_deref() .and_then(|content| serde_json::from_str::(content).ok()) .and_then(|payload| payload.get("pid").and_then(serde_json::Value::as_u64)); - #[cfg(unix)] if let Some(owner_alive) = owner_pid.and_then(project_write_lock_process_is_alive) { return !owner_alive; } @@ -156,6 +225,7 @@ pub(crate) fn acquire_project_write_lock( return Ok(ProjectWriteLock { path, content: content.clone(), + bypassed_same_process: false, }); } Err(error) @@ -168,6 +238,22 @@ pub(crate) fn acquire_project_write_lock( })?; retried_after_reclaim = true; } + Err(error) + if project_write_lock_open_error_is_contention(&error) + && crate::agent::autonomous_game_build_root_run_active_at(root) + && project_write_lock_is_owned_by_current_process(&path) => + { + // The autonomous game-build lane intentionally permits + // parallel specialist actions. If the durable lock belongs + // to this very process, contention is an in-process overlap, + // not another application editing the project. Return an + // advisory guard and leave the real lock untouched. + return Ok(ProjectWriteLock { + path, + content: String::new(), + bypassed_same_process: true, + }); + } Err(error) if project_write_lock_open_error_is_contention(&error) => { return Err(format!("项目正在被其他写操作占用:{}", path.display())); } 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 8c6d7c027..5168849e5 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 @@ -797,26 +797,17 @@ pub(crate) fn ensure_manifest_seed_tasks(root: &Path, manifest: &mut GameCreatio return; } - let visual_assets_required = editor_api_key_is_configured(); for seed_task in seed_tasks { - let visual_asset_ready = manifest_has_required_visual_asset(root, manifest, &seed_task.id); if let Some(existing_task) = manifest .tasks .iter_mut() .find(|task| task.id == seed_task.id) { - let status = if existing_task.status == GameCreationAppTaskStatus::Completed - && visual_assets_required - && matches!( - seed_task.id.as_str(), - "art-director" | "design-foundation" | "art-asset-plan" - ) - && !visual_asset_ready - { - GameCreationAppTaskStatus::Pending - } else { - existing_task.status.clone() - }; + // Task status is execution state, not a projection of optional + // platform assets. In particular, a relaxed autonomous run may + // finish an art task without a Canvas/API asset; re-seeding the + // manifest must never turn that terminal state back into Pending. + let status = existing_task.status.clone(); *existing_task = seed_task; existing_task.status = status; } else { 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 ad5d30974..c4a403b0e 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 @@ -2176,6 +2176,84 @@ async fn invalid_project_verification_preserves_previous_run_credential() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn relaxed_autonomous_validation_tools_skip_without_side_effects() { + let root = unique_project_path(); + init_local_game_project_at(&root, "relaxed-validation-skip", "自主构建跳过验证工具测试") + .expect("project init"); + let run_id = "relaxed-validation-skip-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind relaxed autonomous run profile"); + + let agent_db_path = root.join(".agent/agent.db"); + let agent_db_before = fs::read(&agent_db_path).expect("read initial agent db"); + let verification_gate_path = game_creator_agent_runtime_verification_gate_path( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ); + assert!(!verification_gate_path.exists()); + + let project_verify = observe_agent_runtime_project_verify( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + None, + "relaxed-project-verify", + &serde_json::json!({}), + ) + .await; + let limited_command = observe_agent_runtime_limited_command( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + &serde_json::json!({}), + ); + let preview_start = observe_agent_runtime_preview_start( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ); + let preview_validate = observe_agent_runtime_preview_validate( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + None, + "relaxed-preview-validate", + &serde_json::json!({}), + ) + .await; + + for observation in [ + &project_verify, + &limited_command, + &preview_start, + &preview_validate, + ] { + assert_eq!(observation.status, "ok", "{observation:?}"); + assert!(observation.summary.contains("跳过"), "{observation:?}"); + assert!(observation + .detail + .as_deref() + .is_some_and(|detail| detail.contains("relaxedAutonomous=true"))); + } + assert_eq!( + fs::read(&agent_db_path).expect("read unchanged agent db"), + agent_db_before + ); + assert!(!verification_gate_path.exists()); + assert!(!root.join(".agent/logs/command.log").exists()); + + fs::remove_dir_all(root).ok(); +} + #[cfg(unix)] #[tokio::test] async fn executed_project_verification_audit_failure_requires_reconciliation() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_game_build.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_game_build.rs index 070fe8daa..202aaf2c1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_game_build.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_game_build.rs @@ -52,7 +52,7 @@ fn autonomous_seed_task_statuses_for_test( } #[test] -fn autonomous_game_build_profile_auto_grants_only_scoped_build_actions() { +fn autonomous_game_build_profile_promotes_confirmation_actions_but_keeps_explicit_denies() { let root = unique_project_path(); let config_dir = unique_project_path(); write_autonomous_editor_api_config_for_test(&config_dir, ""); @@ -120,6 +120,9 @@ fn autonomous_game_build_profile_auto_grants_only_scoped_build_actions() { .auto_tools .iter() .any(|tool| tool == "canvas.asset_generate")); + // The autonomous lane does not have a confirmation consumer. Commands + // that are `confirm` in the interactive policy are therefore executable + // here unless the project explicitly denies them. for tool in [ "project.git_commit", "command.exec", @@ -127,12 +130,13 @@ fn autonomous_game_build_profile_auto_grants_only_scoped_build_actions() { "command.stdin", "command.terminate", ] { - assert!(policy - .denied_tools + assert!(policy.auto_tools.iter().any(|candidate| candidate == tool)); + assert!(!policy + .confirm_tools .iter() .any(|candidate| candidate == tool)); assert!(!policy - .confirm_tools + .denied_tools .iter() .any(|candidate| candidate == tool)); } @@ -146,7 +150,7 @@ fn autonomous_game_build_profile_auto_grants_only_scoped_build_actions() { "file.write", ); assert!(auto_rule.is_none()); - let denied = game_creator_agent_runtime_tool_policy_rule_for_run( + let confirmation_relaxed = game_creator_agent_runtime_tool_policy_rule_for_run( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_id, @@ -154,12 +158,7 @@ fn autonomous_game_build_profile_auto_grants_only_scoped_build_actions() { Some(&binding.binding_fingerprint), "project.git_commit", ); - let Some(AgentRuntimeToolPolicyBlock::Denied(reason)) = denied else { - panic!("autonomous non-auto-safe tool must be denied"); - }; - assert!(reason.contains("自主构建模式不能等待人工确认")); - assert!(reason.contains("auto-safe")); - assert!(reason.contains("省略")); + assert!(confirmation_relaxed.is_none()); let art_run_id = "autonomous-profile-art-run"; let art_link = AgentRuntimeTaskLink { parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4ffd04577..479dc8736 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7747,3 +7747,9 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - DirectProject 的 Codex cwd 固定为真实 `game/` 目录时,原生文件工具和 patch 必须使用 cwd 相对路径(`index.html`、`style.css`、`game.js`);`game/...` 仅用于 AGC manifest、回执和客户端投影,不能作为 cwd 内原生 patch 路径,以避免 `writing outside of the project`。 - 直连 Runtime 已取得 Developer Key 时,资源编辑的 `remote_credentials` 是该操作的完整身份边界;其中冻结平台快照为空表示 Developer 模式,禁止再从进程全局 GUI 登录态补回账号快照。平台账号模式仍只使用同一组凭据捕获的快照。 - 回归覆盖 Direct 系统提示路径合同和 Developer Key / GUI 快照隔离;未触碰用户项目 `.agent` 锁、账本或凭据。 + +## 2026-08-28 AGC 自主构建放开编排约束 + +- `autonomous-game-build` 中,manifest `dependencies` 只作为上下文,不阻塞 ready;代码、设计、美术、音频和发布任务允许并行启动,child 不依赖固定回执顺序或固定 run 身份才能推进。 +- 任务最终状态不再提前绑定平台画布、preview、static smoke 或发布产物检查;这些内容不参与该档位的完成判定,也不会因缺失而重置已完成任务。父 run 在任务图进入终态后直接收束并回复。 +- 本档位仍沿用现有项目根和工具权限边界;本次调整只解除流程编排与平台产物验收前置,不新增第二套任务系统。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 09fff70fb..47c9b3f88 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1248,3 +1248,9 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过 ## 2026-08-24 AGC UI 原型桥接与自主 UI workflow - 2026-08-24 起,`ui-prototype` 与 UI 编辑器的 `UI` JSON 资源明确分离。设计图生成后必须由白名单 `ui.workflow.run` 按页面执行 `prepare → recognize → status → finalize`:为每个功能页面创建并关联 `UI` JSON,载入页面设计图和已登记图片/图标/字体,调用 UI Editor 的 provider-backed 结构识别、多树合并与分批组件绑定,持久化 State/revision,写入 `game/` 应用标记,并把 `reference-ready → structure-ready → merge-ready → binding-ready → application-ready → completed` 各阶段的 `generationKind` 和 manifest revision 投影给客户端。Provider 未配置、请求失败、工具调用缺失、结果不匹配、未知字体引用、未产出可渲染组件或仍有待审节点时保留最近真实阶段并返回 blocker,不得使用 deterministic seed 冒充完成。工作台点击 `ui-prototype` 时通过 `ensure_ui_design_resource_for_prototype` 幂等补齐关联资源;工作流完成后自动打开首个页面的 UI 编辑器 `visual-binding` 最终阶段,交给用户检查和手动调整。只生成图片、登记空 JSON 或进入普通图片画布均不构成 UI 工作流完成,详见 [`【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md`](../【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md)。 + +## 2026-08-28 AGC 自主构建 relaxed 编排覆盖 + +- `autonomous-game-build` 运行档位采用自主并行编排:manifest `dependencies` 仅作为上下文提示,不参与 ready 判定;代码、设计、美术、音频和发布 child 可以按调度器可用性并行启动。child 不要求固定 task ID、owner、parent-child lineage、run ID 或回执顺序,完成投影按同一项目事实幂等收束。 +- 该档位不把最终验收条件提前成启动条件,也不把平台画布、preview、static smoke、发布包或其它平台产物检查作为 child 或根 Supervisor 的完成门。缺少平台产物不会把已完成任务重置为 `Pending`;根 run 只等待任务图进入终态并交回结果。 +- 代码可先按约定的项目路径落地并完成自己的工作;后续任务状态变化只负责唤醒同一根 run 继续收束,不因 `art-polish`、`art-asset-plan` 等非代码任务失败而阻塞代码启动。平台产物和可玩性检查若需要,属于后续独立验收,不是本档位的运行前置条件。 diff --git a/server-rs/crates/platform-agent/src/game_creation.rs b/server-rs/crates/platform-agent/src/game_creation.rs index d41321bfc..5362c5cdd 100644 --- a/server-rs/crates/platform-agent/src/game_creation.rs +++ b/server-rs/crates/platform-agent/src/game_creation.rs @@ -961,7 +961,7 @@ pub fn build_game_creation_seed_task_graph( "预览并试玩验收", GameCreationAgentGroup::Code, "Playtest", - ["preview-readiness"], + ["publish-package"], [".agent/logs/preview.log"], ["预览不是空白页,主循环和基础输入可用"], ), @@ -970,7 +970,7 @@ pub fn build_game_creation_seed_task_graph( "整理运营定位", GameCreationAgentGroup::Publishing, "Director", - ["preview-playtest"], + ["preview-readiness"], [".agent/passes/pass-*/groups/publishing/director.md"], ["标题、卖点、标签和封面方向明确"], ), 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 092345171..5484cca0b 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -367,12 +367,7 @@ pub fn new_game_creation_app_seed_tasks() -> Vec { "生成可运行原型", GameCreationAppAgentGroup::Code, "Code", - [ - "code-director", - "balance-seed", - "art-polish", - "audio-asset-plan", - ], + ["code-director", "balance-seed", "audio-asset-plan"], ["game/"], ["本地 Web 游戏项目可以通过 HTTP server 打开"], ), @@ -399,7 +394,7 @@ pub fn new_game_creation_app_seed_tasks() -> Vec { "预览并试玩验收", GameCreationAppAgentGroup::Code, "Playtest", - ["preview-readiness"], + ["publish-package"], [".agent/logs/preview.log"], ["预览不是空白页,主循环和基础输入可用"], ), @@ -408,7 +403,7 @@ pub fn new_game_creation_app_seed_tasks() -> Vec { "整理运营定位", GameCreationAppAgentGroup::Publishing, "Director", - ["preview-playtest"], + ["preview-readiness"], [".agent/passes/pass-*/groups/publishing/director.md"], ["标题、卖点、标签和封面方向明确"], ), @@ -1609,12 +1604,7 @@ mod tests { .expect("code prototype task"); assert_eq!( code_prototype.dependencies, - [ - "code-director", - "balance-seed", - "art-polish", - "audio-asset-plan" - ] + ["code-director", "balance-seed", "audio-asset-plan"] ); } @@ -1662,7 +1652,7 @@ mod tests { vec!["balance-director", "art-asset-plan", "audio-director"] ); - for task_id in ["balance-director", "art-asset-plan", "audio-director"] { + for task_id in ["balance-director", "audio-director"] { manifest .tasks .iter_mut() @@ -1675,24 +1665,48 @@ mod tests { .iter() .map(|task| task.id.as_str()) .collect::>(), - vec!["balance-seed", "art-polish", "audio-asset-plan"] + vec!["balance-seed", "art-asset-plan", "audio-asset-plan"] ); - manifest - .tasks - .iter_mut() - .find(|task| task.id == "balance-seed") - .unwrap() - .status = GameCreationAppTaskStatus::Completed; + for task_id in ["balance-seed", "audio-asset-plan"] { + manifest + .tasks + .iter_mut() + .find(|task| task.id == task_id) + .unwrap() + .status = GameCreationAppTaskStatus::Completed; + } assert_eq!( select_game_creation_app_ready_tasks(&manifest) .iter() .map(|task| task.id.as_str()) .collect::>(), - vec!["art-polish", "audio-asset-plan"] + vec!["art-asset-plan", "code-prototype"] ); - for task_id in ["art-polish", "audio-asset-plan"] { + // code-prototype is ready even while both the art asset and polish + // tasks are still pending; the final completion gate checks the real + // art resources later. + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == "art-asset-plan") + .unwrap() + .status, + GameCreationAppTaskStatus::Pending + ); + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == "art-polish") + .unwrap() + .status, + GameCreationAppTaskStatus::Pending + ); + + for task_id in ["art-asset-plan", "art-polish"] { manifest .tasks .iter_mut() From 58db9fa637a5712c12d4784d6f6791c433de9688 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 28 Aug 2026 21:35:36 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E5=B1=95=E7=A4=BA=E5=8E=9F=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 素材查询详情弹窗并列展示原图与当前结果 精选审核详情弹窗保留原有元信息并补充原图 管理快照与后台 DTO 传递源资源媒体字段 限制源资源字段仅在后台审核读取路径回填 更新后台素材预览的架构踩坑说明 --- apps/admin-web/src/api/adminApiTypes.ts | 9 ++++ .../src/components/AdminEditorAssetMedia.tsx | 24 +++++++++- .../src/pages/AdminEditorAssetQueryPage.tsx | 37 +++++++++++---- .../pages/AdminEditorShowcaseReviewPage.tsx | 45 ++++++++++++++----- apps/admin-web/src/styles/admin.css | 37 +++++++++++++++ docs/project-memory/shared-memory/pitfalls.md | 2 + server-rs/crates/api-server/src/admin.rs | 17 +++++++ .../crates/api-server/src/editor_project.rs | 4 ++ .../crates/shared-contracts/src/admin.rs | 9 ++++ .../src/active/mapper/editor_project.rs | 16 +++++++ .../src/mapper/editor_project.rs | 16 +++++++ .../admin_editor_asset_snapshot_type.rs | 4 ++ .../editor_showcase_asset_snapshot_type.rs | 4 ++ .../src/editor_project_storage.rs | 45 ++++++++++++++++--- 14 files changed, 241 insertions(+), 28 deletions(-) diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 38c4a8e10..bcd2ee1db 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -362,6 +362,10 @@ export interface AdminEditorAssetPayload { assetKind?: string | null; generationInputs?: Record | null; sourceResourceId?: string | null; + sourceImageSrc?: string | null; + sourceObjectKey?: string | null; + sourceAssetObjectId?: string | null; + sourceLabel?: string | null; thumbnailSrc?: string | null; generationCostMudPoints: number; createdAt: string; @@ -404,6 +408,11 @@ export interface AdminEditorShowcaseAssetPayload { model?: string | null; provider?: string | null; taskId?: string | null; + sourceResourceId?: string | null; + sourceImageSrc?: string | null; + sourceObjectKey?: string | null; + sourceAssetObjectId?: string | null; + sourceLabel?: string | null; assetKind?: string | null; generationInputs?: Record | null; thumbnailSrc?: string | null; diff --git a/apps/admin-web/src/components/AdminEditorAssetMedia.tsx b/apps/admin-web/src/components/AdminEditorAssetMedia.tsx index c2f6fba40..36c13586f 100644 --- a/apps/admin-web/src/components/AdminEditorAssetMedia.tsx +++ b/apps/admin-web/src/components/AdminEditorAssetMedia.tsx @@ -25,6 +25,11 @@ export interface AdminPreviewableEditorAsset { thumbnailSrc?: string | null; imageSequenceFrames?: AdminEditorImageSequenceFramePayload[] | null; imageSequenceDurationMs?: number | null; + sourceResourceId?: string | null; + sourceImageSrc?: string | null; + sourceObjectKey?: string | null; + sourceAssetObjectId?: string | null; + sourceLabel?: string | null; } export function AdminEditorAssetThumbnail({ @@ -70,6 +75,15 @@ export function AdminEditorAssetPreviewDialog({ token: string; onClose: () => void; }) { + const sourceEntry = entry.sourceImageSrc?.trim() + ? { + assetId: entry.sourceResourceId ?? `${entry.assetId}-source`, + label: entry.sourceLabel?.trim() || '原图', + imageSrc: entry.sourceImageSrc, + objectKey: entry.sourceObjectKey, + } + : null; + return (
+ {sourceEntry ? ( +
+

原图

+ +
+ ) : null} ); } -function AdminEditorAssetPreviewMedia({ +export function AdminEditorAssetPreviewMedia({ entry, token, }: { @@ -716,7 +736,7 @@ function isAdminImageSequenceFrameUrlUsable( ): cached is AdminImageSequenceFrameCacheEntry { return Boolean( cached?.resolvedUrl && - (cached.expiresAtMs === null || cached.expiresAtMs > Date.now()), + (cached.expiresAtMs === null || cached.expiresAtMs > Date.now()), ); } diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx index 53eb3676e..82f4d43e2 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx @@ -16,6 +16,7 @@ import type { } from '../api/adminApiTypes'; import { AdminEditorAssetPreviewDialog, + AdminEditorAssetPreviewMedia, AdminEditorAssetThumbnail, } from '../components/AdminEditorAssetMedia'; import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton'; @@ -561,14 +562,34 @@ function AdminAssetDetailDialog({
- +
+ {entry.sourceImageSrc?.trim() ? ( +
+

原图

+ +
+ ) : null} +
+

当前结果

+ +
+
diff --git a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx index fb90af61a..2cdef7086 100644 --- a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx +++ b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx @@ -17,6 +17,7 @@ import type { } from '../api/adminApiTypes'; import { AdminEditorAssetPreviewDialog, + AdminEditorAssetPreviewMedia, AdminEditorAssetThumbnail, } from '../components/AdminEditorAssetMedia'; import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton'; @@ -709,18 +710,38 @@ function AdminShowcaseDetailDialog({
- +
+ {entry.sourceImageSrc?.trim() ? ( +
+

原图

+ +
+ ) : null} +
+

当前结果

+ +
+
diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index e7610f4f0..719ecf714 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -1655,6 +1655,18 @@ button:disabled { width: min(100%, 860px); } +.admin-asset-query-source-preview { + margin: 0 20px 16px; + padding-bottom: 16px; + border-bottom: 1px solid #e2e8f0; +} + +.admin-asset-query-source-preview h4 { + margin: 0 0 8px; + color: #64748b; + font-size: 13px; +} + .admin-asset-query-prompt-dialog .admin-panel-heading > div, .admin-asset-query-detail-dialog .admin-panel-heading > div, .admin-asset-query-preview-dialog .admin-panel-heading > div { @@ -1683,6 +1695,27 @@ button:disabled { align-items: start; } +.admin-asset-query-detail-media { + display: grid; + gap: 14px; +} + +.admin-asset-query-detail-media-card { + display: grid; + gap: 7px; +} + +.admin-asset-query-detail-media-card h4 { + margin: 0; + color: #64748b; + font-size: 13px; +} + +.admin-asset-query-detail-media-card .admin-asset-query-preview-media { + width: 220px; + height: 220px; +} + .admin-asset-query-detail-thumb-button .admin-asset-query-thumb { width: 220px; height: 220px; @@ -2332,6 +2365,10 @@ button:disabled { justify-self: center; } + .admin-asset-query-detail-media { + justify-items: center; + } + .admin-dashboard-tabs { width: 100%; } diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index bdf1d0eb5..8b7018d39 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -548,6 +548,8 @@ ## 后台素材查询不要用 SQL 直查 editor_asset +- 后台审核与素材查询的图片预览若要显示像素化原图,应由后台 read model 在 `sourceResourceId` 关联的项目资源上预先透传原图媒体引用,再复用管理员换签;不要让 admin-web 直接查询私有 `editor_project_resource`。 + - 现象:后台“素材查询”报 `HTTP 400:no such table: editor_asset. If the table exists, it may be marked private.`。 - 原因:`editor_asset` 是私有 SpacetimeDB 表,后台 SQL / schema HTTP 查询面看不到私有表;即使 api-server 有后台身份,也不能把私有表当 Dashboard SQL 表直接查。 - 处理:后台素材查询走 `spacetime-module` 内的 `admin_list_editor_assets_and_return` procedure,由 `spacetime-client` typed facade 调用后再在 `api-server` 映射作者展示名和陶泥号。新增类似后台只读能力时,优先补窄 procedure / read model,不要复用 `fetch_admin_dashboard_rows` 直查私有源表。 diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 5afb9ccf2..6a9a69ffe 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -1096,6 +1096,11 @@ fn admin_editor_showcase_asset_payload_from_record( model: record.model, provider: record.provider, task_id: record.task_id, + source_resource_id: record.source_resource_id, + source_image_src: record.source_image_src, + source_object_key: record.source_object_key, + source_asset_object_id: record.source_asset_object_id, + source_label: record.source_label, asset_kind, generation_inputs: sanitize_editor_generation_inputs(record.generation_inputs), thumbnail_src: record.thumbnail_src, @@ -1250,6 +1255,10 @@ fn admin_editor_asset_payload_from_record( asset_kind, generation_inputs: sanitize_editor_generation_inputs(record.generation_inputs), source_resource_id: record.source_resource_id, + source_image_src: record.source_image_src, + source_object_key: record.source_object_key, + source_asset_object_id: record.source_asset_object_id, + source_label: record.source_label, thumbnail_src: record.thumbnail_src, generation_cost_mud_points: record.generation_cost_mud_points, created_at: record.created_at, @@ -4136,6 +4145,10 @@ mod tests { provider: None, task_id: None, source_resource_id: None, + source_image_src: None, + source_object_key: None, + source_asset_object_id: None, + source_label: None, asset_kind: Some("character".to_string()), generation_inputs: None, thumbnail_src: None, @@ -4187,6 +4200,10 @@ mod tests { asset_kind: Some("character".to_string()), generation_inputs: None, source_resource_id: None, + source_image_src: None, + source_object_key: None, + source_asset_object_id: None, + source_label: None, thumbnail_src: None, generation_cost_mud_points, created_at: "2026-07-04T10:00:00Z".to_string(), diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 0e60b3ea0..d9f110905 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -14612,6 +14612,10 @@ mod tests { provider: None, task_id: None, source_resource_id: None, + source_image_src: None, + source_object_key: None, + source_asset_object_id: None, + source_label: None, asset_kind: None, generation_inputs: None, thumbnail_src: None, diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 84264e72e..513cff7aa 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -247,6 +247,10 @@ pub struct AdminEditorAssetPayload { pub asset_kind: Option, pub generation_inputs: Option, pub source_resource_id: Option, + pub source_image_src: Option, + pub source_object_key: Option, + pub source_asset_object_id: Option, + pub source_label: Option, pub thumbnail_src: Option, pub generation_cost_mud_points: u64, pub created_at: String, @@ -295,6 +299,11 @@ pub struct AdminEditorShowcaseAssetPayload { pub model: Option, pub provider: Option, pub task_id: Option, + pub source_resource_id: Option, + pub source_image_src: Option, + pub source_object_key: Option, + pub source_asset_object_id: Option, + pub source_label: Option, pub asset_kind: Option, pub generation_inputs: Option, pub thumbnail_src: Option, diff --git a/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs index 17918226c..671d20708 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs @@ -166,6 +166,10 @@ pub struct AdminEditorAssetRecord { pub asset_kind: Option, pub generation_inputs: Option, pub source_resource_id: Option, + pub source_image_src: Option, + pub source_object_key: Option, + pub source_asset_object_id: Option, + pub source_label: Option, pub thumbnail_src: Option, pub generation_cost_mud_points: u64, pub created_at: String, @@ -198,6 +202,10 @@ pub struct EditorShowcaseAssetRecord { pub provider: Option, pub task_id: Option, pub source_resource_id: Option, + pub source_image_src: Option, + pub source_object_key: Option, + pub source_asset_object_id: Option, + pub source_label: Option, pub asset_kind: Option, pub generation_inputs: Option, pub thumbnail_src: Option, @@ -1496,6 +1504,10 @@ fn map_admin_editor_asset_snapshot( asset_kind: snapshot.asset_kind, generation_inputs, source_resource_id: snapshot.source_resource_id, + source_image_src: snapshot.source_image_src, + source_object_key: snapshot.source_object_key, + source_asset_object_id: snapshot.source_asset_object_id, + source_label: snapshot.source_label, thumbnail_src: snapshot.thumbnail_src, generation_cost_mud_points: snapshot.generation_cost_mud_points, created_at: format_timestamp_micros(snapshot.created_at_micros), @@ -1533,6 +1545,10 @@ fn map_editor_showcase_asset_snapshot( provider: snapshot.provider, task_id: snapshot.task_id, source_resource_id: snapshot.source_resource_id, + source_image_src: snapshot.source_image_src, + source_object_key: snapshot.source_object_key, + source_asset_object_id: snapshot.source_asset_object_id, + source_label: snapshot.source_label, asset_kind: snapshot.asset_kind, generation_inputs, thumbnail_src: snapshot.thumbnail_src, diff --git a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs index 7e9d39668..008b012f5 100644 --- a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs @@ -165,6 +165,10 @@ pub struct AdminEditorAssetRecord { pub asset_kind: Option, pub generation_inputs: Option, pub source_resource_id: Option, + pub source_image_src: Option, + pub source_object_key: Option, + pub source_asset_object_id: Option, + pub source_label: Option, pub thumbnail_src: Option, pub generation_cost_mud_points: u64, pub created_at: String, @@ -197,6 +201,10 @@ pub struct EditorShowcaseAssetRecord { pub provider: Option, pub task_id: Option, pub source_resource_id: Option, + pub source_image_src: Option, + pub source_object_key: Option, + pub source_asset_object_id: Option, + pub source_label: Option, pub asset_kind: Option, pub generation_inputs: Option, pub thumbnail_src: Option, @@ -1380,6 +1388,10 @@ fn map_editor_project_resource_snapshot( provider: snapshot.provider, task_id: snapshot.task_id, source_resource_id: snapshot.source_resource_id, + source_image_src: snapshot.source_image_src, + source_object_key: snapshot.source_object_key, + source_asset_object_id: snapshot.source_asset_object_id, + source_label: snapshot.source_label, asset_kind: snapshot.asset_kind, generation_inputs, public_showcase_enabled: snapshot.public_showcase_enabled, @@ -1532,6 +1544,10 @@ fn map_editor_showcase_asset_snapshot( provider: snapshot.provider, task_id: snapshot.task_id, source_resource_id: snapshot.source_resource_id, + source_image_src: snapshot.source_image_src, + source_object_key: snapshot.source_object_key, + source_asset_object_id: snapshot.source_asset_object_id, + source_label: snapshot.source_label, asset_kind: snapshot.asset_kind, generation_inputs, thumbnail_src: snapshot.thumbnail_src, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_editor_asset_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_editor_asset_snapshot_type.rs index be01aa17c..3da4f3b11 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/admin_editor_asset_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_editor_asset_snapshot_type.rs @@ -25,6 +25,10 @@ pub struct AdminEditorAssetSnapshot { pub asset_kind: Option, pub generation_inputs_json: Option, pub source_resource_id: Option, + pub source_image_src: Option, + pub source_object_key: Option, + pub source_asset_object_id: Option, + pub source_label: Option, pub thumbnail_src: Option, pub generation_cost_mud_points: u64, pub created_at_micros: i64, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_showcase_asset_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_showcase_asset_snapshot_type.rs index 9e7121394..ba0ebc35b 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_showcase_asset_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_showcase_asset_snapshot_type.rs @@ -23,6 +23,10 @@ pub struct EditorShowcaseAssetSnapshot { pub provider: Option, pub task_id: Option, pub source_resource_id: Option, + pub source_image_src: Option, + pub source_object_key: Option, + pub source_asset_object_id: Option, + pub source_label: Option, pub asset_kind: Option, pub generation_inputs_json: Option, pub thumbnail_src: Option, diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 86eaae1f6..f56ede76e 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -879,6 +879,10 @@ pub struct AdminEditorAssetSnapshot { pub asset_kind: Option, pub generation_inputs_json: Option, pub source_resource_id: Option, + pub source_image_src: Option, + pub source_object_key: Option, + pub source_asset_object_id: Option, + pub source_label: Option, pub thumbnail_src: Option, pub generation_cost_mud_points: u64, pub created_at_micros: i64, @@ -912,6 +916,10 @@ pub struct EditorShowcaseAssetSnapshot { pub provider: Option, pub task_id: Option, pub source_resource_id: Option, + pub source_image_src: Option, + pub source_object_key: Option, + pub source_asset_object_id: Option, + pub source_label: Option, pub asset_kind: Option, pub generation_inputs_json: Option, pub thumbnail_src: Option, @@ -3217,9 +3225,13 @@ fn admin_list_editor_assets( Ok(groups .into_iter() .flat_map(|(_, group_task_id, assets)| { - assets - .into_iter() - .map(move |row| admin_asset_snapshot_from_row(row, group_task_id.clone())) + assets.into_iter().map(move |row| { + let source = row + .source_resource_id + .as_ref() + .and_then(|id| ctx.db.editor_project_resource().resource_id().find(id)); + admin_asset_snapshot_from_row(row, group_task_id.clone(), source.as_ref()) + }) }) .collect()) } @@ -6405,7 +6417,7 @@ fn admin_list_editor_showcase_assets( cursor.as_ref(), ) }) - .map(showcase_snapshot_from_row) + .map(|asset| showcase_snapshot_from_row_with_ctx(Some(ctx), asset)) .collect::>(); assets.sort_by(|left, right| { @@ -6436,7 +6448,7 @@ fn admin_review_editor_showcase_asset( if asset.review_status == EDITOR_SHOWCASE_STATUS_APPROVED && review_status == EDITOR_SHOWCASE_STATUS_APPROVED { - return Ok(showcase_snapshot_from_row(asset)); + return Ok(showcase_snapshot_from_row_with_ctx(Some(ctx), asset)); } return Err("只有待审核素材可以审核".to_string()); } @@ -7835,6 +7847,16 @@ fn asset_folder_snapshot_from_row(row: EditorAssetFolder) -> EditorAssetFolderSn } fn showcase_snapshot_from_row(row: EditorShowcaseAsset) -> EditorShowcaseAssetSnapshot { + showcase_snapshot_from_row_with_ctx(None, row) +} + +fn showcase_snapshot_from_row_with_ctx( + ctx: Option<&ReducerContext>, + row: EditorShowcaseAsset, +) -> EditorShowcaseAssetSnapshot { + let source = row.source_resource_id.as_ref().and_then(|id| { + ctx.and_then(|value| value.db.editor_project_resource().resource_id().find(id)) + }); EditorShowcaseAssetSnapshot { showcase_id: row.showcase_id, asset_id: row.asset_id, @@ -7852,6 +7874,12 @@ fn showcase_snapshot_from_row(row: EditorShowcaseAsset) -> EditorShowcaseAssetSn provider: row.provider, task_id: row.task_id, source_resource_id: row.source_resource_id, + source_image_src: source.as_ref().map(|value| value.image_src.clone()), + source_object_key: source.as_ref().and_then(|value| value.object_key.clone()), + source_asset_object_id: source + .as_ref() + .and_then(|value| value.asset_object_id.clone()), + source_label: source.as_ref().and_then(|value| value.prompt.clone()), asset_kind: row.asset_kind, generation_inputs_json: row.generation_inputs_json, thumbnail_src: row.thumbnail_src, @@ -8362,6 +8390,7 @@ fn asset_snapshot_from_row(ctx: &ReducerContext, row: EditorAsset) -> EditorAsse fn admin_asset_snapshot_from_row( row: EditorAsset, group_task_id: Option, + source: Option<&EditorProjectResource>, ) -> AdminEditorAssetSnapshot { AdminEditorAssetSnapshot { asset_id: row.asset_id, @@ -8382,6 +8411,10 @@ fn admin_asset_snapshot_from_row( asset_kind: row.asset_kind, generation_inputs_json: row.generation_inputs_json, source_resource_id: row.source_resource_id, + source_image_src: source.map(|value| value.image_src.clone()), + source_object_key: source.and_then(|value| value.object_key.clone()), + source_asset_object_id: source.and_then(|value| value.asset_object_id.clone()), + source_label: source.and_then(|value| value.prompt.clone()), thumbnail_src: row.thumbnail_src, generation_cost_mud_points: row.generation_cost_mud_points, created_at_micros: row.created_at.to_micros_since_unix_epoch(), @@ -20685,7 +20718,7 @@ mod tests { admin_editor_asset_group_task_id_with(&slice, &mut task_id_aliases, |_, _| { panic!("persisted group must not depend on project resources") }); - let snapshot = admin_asset_snapshot_from_row(slice, group_task_id); + let snapshot = admin_asset_snapshot_from_row(slice, group_task_id, None); assert_eq!(snapshot.task_id.as_deref(), Some(split_task_id)); assert_eq!(snapshot.group_task_id.as_deref(), Some(source_task_id)); From 5460c13216da3b378bf3c9783c59fe60ed2e14c8 Mon Sep 17 00:00:00 2001 From: Git Hooks Test Date: Fri, 28 Aug 2026 21:44:20 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=E6=8F=90=E5=8D=87=20AGC=20=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E8=87=B3=200.1.12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步 npm、Tauri、Cargo 与锁文件版本 更新 workspace 和 release 版本校验 --- apps/ai-game-creator-shell/package.json | 2 +- apps/ai-game-creator-shell/scripts/check-config.mjs | 8 ++++---- apps/ai-game-creator-shell/src-tauri/Cargo.lock | 2 +- apps/ai-game-creator-shell/src-tauri/Cargo.toml | 2 +- apps/ai-game-creator-shell/src-tauri/tauri.conf.json | 2 +- package-lock.json | 2 +- scripts/check-npm-workspaces.mjs | 2 +- scripts/check-npm-workspaces.test.mjs | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index e546d79a0..c7aa09d56 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -1,7 +1,7 @@ { "name": "@genarrative/ai-game-creator-shell", "private": true, - "version": "0.1.11", + "version": "0.1.12", "type": "module", "scripts": { "dev": "node scripts/start-tauri-dev.mjs", diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index b2d2d0958..6817d69bb 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1533,12 +1533,12 @@ if ( } if ( - tauriConfig.version !== '0.1.11' || - packageConfig.version !== '0.1.11' || - cargoPackageVersion !== '0.1.11' + tauriConfig.version !== '0.1.12' || + packageConfig.version !== '0.1.12' || + cargoPackageVersion !== '0.1.12' ) { throw new Error( - 'AI game creator standard release must remain version 0.1.11', + 'AI game creator standard release must remain version 0.1.12', ); } diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index c3e3eb600..34eae3142 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1695,7 +1695,7 @@ dependencies = [ [[package]] name = "genarrative-ai-game-creator-shell" -version = "0.1.11" +version = "0.1.12" dependencies = [ "agent-runtime-core", "axum", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index d1ac57049..318951ed9 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "genarrative-ai-game-creator-shell" -version = "0.1.11" +version = "0.1.12" edition = "2021" publish = false diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json index ff42f5b08..4ea2a7e05 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Genarrative AI Game Creator", - "version": "0.1.11", + "version": "0.1.12", "identifier": "world.genarrative.ai-game-creator", "build": { "beforeDevCommand": "npm --prefix ../.. run agc:serve", diff --git a/package-lock.json b/package-lock.json index ee02cb87e..adfb0e426 100644 --- a/package-lock.json +++ b/package-lock.json @@ -93,7 +93,7 @@ }, "apps/ai-game-creator-shell": { "name": "@genarrative/ai-game-creator-shell", - "version": "0.1.11", + "version": "0.1.12", "dependencies": { "@cubone/react-file-manager": "^1.35.0", "@genarrative/image-canvas-core": "0.1.0", diff --git a/scripts/check-npm-workspaces.mjs b/scripts/check-npm-workspaces.mjs index bff674b90..0c55b3b53 100644 --- a/scripts/check-npm-workspaces.mjs +++ b/scripts/check-npm-workspaces.mjs @@ -174,7 +174,7 @@ export function collectNpmWorkspaceErrors(rootDir) { ); } const expectedWorkspaceVersion = - workspacePath === 'apps/ai-game-creator-shell' ? '0.1.11' : '0.1.0'; + workspacePath === 'apps/ai-game-creator-shell' ? '0.1.12' : '0.1.0'; if (manifest.version !== expectedWorkspaceVersion) { errors.push( `${manifestPath}: workspace version must be ${expectedWorkspaceVersion}`, diff --git a/scripts/check-npm-workspaces.test.mjs b/scripts/check-npm-workspaces.test.mjs index e2e43c18b..9be1c91cf 100644 --- a/scripts/check-npm-workspaces.test.mjs +++ b/scripts/check-npm-workspaces.test.mjs @@ -79,7 +79,7 @@ function createValidFixture() { name: workspaceNames[workspacePath], private: true, version: - workspacePath === 'apps/ai-game-creator-shell' ? '0.1.11' : '0.1.0', + workspacePath === 'apps/ai-game-creator-shell' ? '0.1.12' : '0.1.0', dependencies: localDependencies[workspacePath], }; writeJson(rootDir, `${workspacePath}/package.json`, manifest); From 63abea0b3e7ef442e876685988d4b3d30d446b31 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 29 Aug 2026 00:49:53 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E9=98=BB=E6=AD=A2=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E6=B1=A1=E6=9F=93=20AGC=20=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=86=99=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 明确 agc_write_file 只接受原始 UTF-8 文件正文 拒绝 Exit code、Wall time、Output 包装并补充回归测试 --- .../src-tauri/src/agent/direct_runtime.rs | 4 ++- .../src-tauri/src/agent/direct_tool_bridge.rs | 30 +++++++++++++++++++ .../src-tauri/src/agent/direct_tools_mcp.rs | 12 ++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 55c1cf8e0..e515babc1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -13,7 +13,7 @@ const MAX_DIRECT_HOME_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96; const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6; const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160; const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。"; -const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同(仅说明项目边界,不是流程门槛):当前 Codex cwd 是用户选择的项目目录(工作区根),源码、素材、音效和其它资源按项目现有结构放置;先按需读取当前 cwd 下适用的 `AGENTS.md`、README 或项目说明,把它们当作项目规范参考。原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill,以及经审核的 `agc_tools` MCP。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图或发布宣传图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;`agc_read_skill_resource` 用于按需读取审核 Skill。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; +const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同(仅说明项目边界,不是流程门槛):当前 Codex cwd 是用户选择的项目目录(工作区根),源码、素材、音效和其它资源按项目现有结构放置;先按需读取当前 cwd 下适用的 `AGENTS.md`、README 或项目说明,把它们当作项目规范参考。原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文;不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill,以及经审核的 `agc_tools` MCP。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图或发布宣传图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;`agc_read_skill_resource` 用于按需读取审核 Skill。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png"; const DIRECT_CODEX_BACKGROUND_ASSET_PATH: &str = "assets/direct-game-background.png"; const DIRECT_CODEX_SPRITESHEET_ASSET_PATH: &str = "assets/art-spritesheet.png"; @@ -4407,6 +4407,8 @@ mod tests { assert!(prompt.contains("AGC 工程合同(仅说明项目边界,不是流程门槛)")); assert!(prompt.contains("先按需读取当前 cwd 下适用的 `AGENTS.md`")); assert!(prompt.contains("agc_write_file")); + assert!(prompt.contains("content 必须是目标文件的完整原始 UTF-8 正文")); + assert!(prompt.contains("不得把 command.exec 的 Exit code、Wall time、Output 包装")); assert!(prompt.contains("切图、资源依赖、规范图和试玩都只是可选工具提示")); assert!(prompt.contains("不要求调用、固定顺序或特定产物")); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 6fa8f196b..7330dd43b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -29,6 +29,23 @@ const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_CALLS_PER_TURN: usize = 4; const DIRECT_TOOL_BRIDGE_MAX_ACCOUNT_ASSET_ID_CHARS: usize = 512; const DIRECT_TOOL_BRIDGE_MAX_LOCAL_ASSET_PATH_CHARS: usize = 512; +pub(crate) fn reject_command_output_wrapper(content: &str) -> Result<(), String> { + let mut lines = content.trim_start_matches('\u{feff}').lines(); + let exit_line = lines.next().map(str::trim).unwrap_or_default(); + let wall_time_line = lines.next().map(str::trim).unwrap_or_default(); + let output_line = lines.next().map(str::trim).unwrap_or_default(); + if exit_line.starts_with("Exit code:") + && wall_time_line.starts_with("Wall time:") + && output_line.eq_ignore_ascii_case("Output:") + { + return Err( + "工具参数 content 不能包含 command.exec 的 Exit code/Wall time/Output 包装;请只传原始 UTF-8 文件正文" + .to_string(), + ); + } + Ok(()) +} + struct DirectToolBridgeState { root: PathBuf, turn_authorization: StdMutex, @@ -1420,6 +1437,7 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value { if content.chars().any(|character| character == '\0') { return Err("工具参数 content 不能包含 NUL".to_string()); } + reject_command_output_wrapper(content)?; let _lock = acquire_project_write_lock(root, "direct-codex.file.write")?; let written = write_local_project_file_at(root, &path, content)?; let revision = advance_agent_runtime_project_revision_locked(root)?; @@ -2475,6 +2493,18 @@ mod tests { fs::read_to_string(temporary.path().join("game/index.html")).expect("read written"), "" ); + let wrapped = bridge_write_file( + temporary.path(), + &json!({ + "path": "game/index.html", + "content": "Exit code: 0\nWall time: 0.1 seconds\nOutput:\n" + }), + ); + assert_eq!(wrapped.get("isError").and_then(Value::as_bool), Some(true)); + assert_eq!( + fs::read_to_string(temporary.path().join("game/index.html")).expect("read unchanged"), + "" + ); assert!( bridge_write_file( temporary.path(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 9844ea945..7c896bd3a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -77,6 +77,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { }, "content": { "type": "string", + "description": "目标文件的完整原始 UTF-8 正文;不要包含 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字", "maxLength": DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES } }, @@ -435,6 +436,7 @@ fn validate_write_file_arguments(arguments: &Value) -> Result<(), String> { if content.chars().any(|character| character == '\0') { return Err("工具参数 content 不能包含 NUL".to_string()); } + reject_command_output_wrapper(content)?; // Keep path normalization in the client bridge as the final authority; // this early check only gives Codex a quick, deterministic argument error. normalize_relative_path(&path).map(|_| ()) @@ -1279,6 +1281,16 @@ mod tests { "content": "" })) .is_ok()); + assert!(validate_write_file_arguments(&json!({ + "path": "game/index.html", + "content": "Exit code: 0\nWall time: 0.1 seconds\nOutput:\n" + })) + .is_err()); + assert!(validate_write_file_arguments(&json!({ + "path": "notes.txt", + "content": "说明:Exit code 只是一段普通文本" + })) + .is_ok()); assert!(validate_write_file_arguments(&json!({ "path": ".agent/manifest.json", "content": "{}"