diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml
index 2de097ee1..0b20e28e2 100644
--- a/.gitea/workflows/project-ci.yml
+++ b/.gitea/workflows/project-ci.yml
@@ -69,23 +69,8 @@ jobs:
- name: Install npm dependencies
run: bash scripts/ci-npm-ci-with-retry.sh
- - name: Run repository lint gates
- run: npm run lint
-
- - name: Build web applications
- run: npm run build
-
- - name: Validate content data
- run: npm run check:content
-
- - name: Check committed whitespace
- shell: bash
- run: |
- set -euo pipefail
- base_ref="${SPACETIME_SCHEMA_BASE_REF:-}"
- test -n "${base_ref}"
- git cat-file -e "${base_ref}^{commit}"
- git diff --check "${base_ref}"...HEAD
+ - name: Run repository checks
+ run: npm run check:repository-ci
frontend-tests:
name: Frontend tests
diff --git a/.husky/pre-push b/.husky/pre-push
new file mode 100755
index 000000000..fdb72ecc2
--- /dev/null
+++ b/.husky/pre-push
@@ -0,0 +1 @@
+npm run check:pre-push-master -- "$@"
diff --git a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs
index d10cfdcac..756e17422 100644
--- a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs
+++ b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs
@@ -253,12 +253,24 @@ export function defaultRuntimeConfigDirCandidates({
),
);
} else {
+ const configuredRoot = environment.XDG_CONFIG_HOME;
+ const posixAbsoluteConfiguredRoot =
+ configuredRoot && path.posix.isAbsolute(configuredRoot);
+ const hostAbsoluteConfiguredRoot =
+ configuredRoot &&
+ !posixAbsoluteConfiguredRoot &&
+ path.isAbsolute(configuredRoot);
const configRoot =
- environment.XDG_CONFIG_HOME &&
- path.posix.isAbsolute(environment.XDG_CONFIG_HOME)
- ? environment.XDG_CONFIG_HOME
+ configuredRoot &&
+ (posixAbsoluteConfiguredRoot || hostAbsoluteConfiguredRoot)
+ ? configuredRoot
: path.posix.join(homeDirectory, '.config');
- pushUnique(candidates, path.posix.join(configRoot, appIdentifier));
+ pushUnique(
+ candidates,
+ hostAbsoluteConfiguredRoot
+ ? path.join(configRoot, appIdentifier)
+ : path.posix.join(configRoot, appIdentifier),
+ );
}
return candidates;
}
diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs
index 0d913a01b..a541abce3 100644
--- a/apps/ai-game-creator-shell/scripts/check-config.mjs
+++ b/apps/ai-game-creator-shell/scripts/check-config.mjs
@@ -1594,7 +1594,8 @@ for (const snippet of [
'const GAME_CREATOR_LOCAL_CONFIG_FILE_NAME: &str = "game-creator.config.local.json"',
'const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json")',
'fn configure_game_creator_runtime_config_dir(',
- 'app.path().app_config_dir()?',
+ 'game_creator_runtime_config_dir()',
+ '.unwrap_or_else(|| app.path().app_config_dir())?',
'fn load_game_creator_app_config()',
'fn read_game_creator_app_config()',
'fn write_game_creator_app_config(',
@@ -1688,8 +1689,8 @@ for (const snippet of [
"'write_game_creator_app_config'",
'aria-label="运行时配置"',
'LLM API Key',
- 'showDeveloperEditorApi',
- '开发者 External Editor API Key',
+ 'External Editor Base URL',
+ 'External Editor API Key',
'runtime_config.save',
"'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'",
"'activate_local_game_preview'",
diff --git a/apps/ai-game-creator-shell/scripts/process-session-real-e2e-fixture.mjs b/apps/ai-game-creator-shell/scripts/process-session-real-e2e-fixture.mjs
index 2caf00aea..7eedae4cb 100644
--- a/apps/ai-game-creator-shell/scripts/process-session-real-e2e-fixture.mjs
+++ b/apps/ai-game-creator-shell/scripts/process-session-real-e2e-fixture.mjs
@@ -46,6 +46,8 @@ export function buildProcessSessionFixtureSource({
' if (!echoed && line === challenge) {',
' echoed = true;',
" console.log(echoPrefix + ' ' + challenge);",
+ " } else if (line === challenge + ':stop') {",
+ ' stop();',
' }',
' }',
'});',
diff --git a/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs b/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs
index 6b03ae39c..2f7026531 100644
--- a/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs
+++ b/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs
@@ -4,8 +4,9 @@ import fs from 'node:fs/promises';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
+import { fileURLToPath } from 'node:url';
-const appRoot = path.resolve(new URL('..', import.meta.url).pathname);
+const appRoot = fileURLToPath(new URL('..', import.meta.url));
const localConfigPath = path.join(appRoot, 'game-creator.config.local.json');
const projectRoot = path.join(
os.tmpdir(),
@@ -884,7 +885,28 @@ function readBrowserDom(url) {
}
function resolveChromeBin() {
+ const windowsRoot = path.parse(os.homedir()).root;
for (const candidate of [
+ path.join(
+ windowsRoot,
+ 'Program Files/Google/Chrome/Application/chrome.exe',
+ ),
+ path.join(
+ windowsRoot,
+ 'Program Files (x86)/Google/Chrome/Application/chrome.exe',
+ ),
+ path.join(
+ os.homedir(),
+ 'AppData/Local/Google/Chrome/Application/chrome.exe',
+ ),
+ path.join(
+ windowsRoot,
+ 'Program Files/Microsoft/Edge/Application/msedge.exe',
+ ),
+ path.join(
+ windowsRoot,
+ 'Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
+ ),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs
index c0bf687f5..87452d7b5 100644
--- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs
+++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs
@@ -45,12 +45,16 @@ function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) {
if (separatorIndex < 0) {
return ['dev', ...args, '--config', configOverride];
}
+ const separatedArguments = args.slice(separatorIndex);
+ if (separatedArguments[1] !== '--') {
+ separatedArguments.unshift('--');
+ }
return [
'dev',
...args.slice(0, separatorIndex),
'--config',
configOverride,
- ...args.slice(separatorIndex),
+ ...separatedArguments,
];
}
diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/common.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/common.md
index f2a7af165..3403f655f 100644
--- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/common.md
+++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/common.md
@@ -16,4 +16,4 @@ git.inspect 会返回 commitSnapshotFingerprint;只有当前非零 revision
用户输入请求协议:user.input_request 使用 {"questions":[{"id":"唯一 snake_case","header":"最多 12 字符","question":"单句问题","options":[{"label":"短选项","description":"一条影响说明"},{"label":"另一选项","description":"一条影响说明"}]}]},一次 1-3 题、每题 2-3 个选项且始终允许自由输入。它必须是本轮唯一函数调用,不得同批调用 update_agent_plan、其他动作函数或 respond_to_user。只有 Project Supervisor 或没有父委派身份的静态 Agent 开发试聊可直接调用;委派专业 Agent 和动态隔离 child 必须把澄清需要回传父 Agent。
-静态委派协议:新 agent.delegate 必须提交 1-8 条 acceptanceCriteria、0-16 个精确项目内非私有 expectedArtifacts,以及 nullable repairOfDelegationId/runId。专业 Agent 收到的 task 会携带完整合同。Supervisor 认领回执后必须区分 evidence-ready 与 needs-repair;前者仍需语义验收,后者不能作为成功。
+静态委派协议:新 agent.delegate 必须提交 1-8 条 acceptanceCriteria、0-16 个精确项目内非私有 expectedArtifacts,以及 nullable repairOfDelegationId/runId/continuationOfDelegationId/questionsSha256/answersSha256,普通委派后三项传 null。专业 Agent 收到的 task 会携带完整合同。Supervisor 认领回执后必须区分 evidence-ready、needs-user-input 与 needs-repair;前者仍需语义验收,needs-repair 不能作为成功。专业 Agent 若缺少会实质改变结果的用户事实,不能调用 user.input_request,必须以最终回复首行 `AGC_NEEDS_USER_INPUT_V1`,下一行短 JSON `{"questions":[...]}` 返回 1-3 个结构化问题;Runtime 会把它作为内部回执交给 Supervisor。Supervisor 对每个原 delivery 逐一用现有 user.input_request 提问,收齐对应答案后最多创建一次 continuation 委派,并同时提交 continuationOfDelegationId、questionsSha256、answersSha256;Runtime 会自动派生稳定 continuation identity,不得把多个 delivery 的问题或答案混入同一 continuation。
diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/supervisor/playbook.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/supervisor/playbook.md
index 204a3d97b..534b488d6 100644
--- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/supervisor/playbook.md
+++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/supervisor/playbook.md
@@ -1,5 +1,5 @@
互不重叠的临时并行检查通过 agent.spawn_isolated 分派;当同一目标同时需要边界清晰的专业委派和互不重叠的临时检查时,必须把两类协作放进同一个 native planning 批次一次性提交,不能拆成先后轮次。提交首个协作批次前,先分别完整枚举当前目标中已经生效的长期专业交付和临时隔离检查;两类都非空时,遗漏任一类的批次都不得提交。仓库合同明确把临时检查分为先行和后续独立阶段时,首批只提交当前已经生效的检查;先行组 ready 后优先创建刚生效的后续组,所有必要组创建前不得调用 agent.run_status 认领先行组,全部 ready 后用一次 agent.run_status 收齐。已有委派未收束时不要重复委派。
-需要等待专业 Agent 时不得调用 respond_to_user;Runtime 会通过 delegate/all-join 完成屏障保持同一父 run,取得 readyDelegateReceipts 或 readyIsolatedJoins 后直接整合结果。readyDelegateReceipts 中 contractStatus=evidence-ready 只说明终态、产物和验证等客观证据齐全,你仍须按 acceptanceCriteria 判断语义是否满足;needs-repair 不得当作成功。客观或语义不满足时可以发起一次新 agent.delegate,并把 repairOfDelegationId 指向已认领原 delivery;不得对返工再返工或为同一原 delivery 创建第二个返工。专业结果冲突且无法依据用户目标裁决时,合并问题后用一次 user.input_request 询问用户。只有实现路径、产品取舍或缺失事实会实质改变结果时才调用 user.input_request;项目内可读取事实、权限确认和工具失败不得伪装成用户问题。
+需要等待专业 Agent 时不得调用 respond_to_user;Runtime 会通过 delegate/all-join 完成屏障保持同一父 run,取得 readyDelegateReceipts 或 readyIsolatedJoins 后直接整合结果。readyDelegateReceipts 中 contractStatus=evidence-ready 只说明终态、产物和验证等客观证据齐全,你仍须按 acceptanceCriteria 判断语义是否满足;needs-repair 不得当作成功。contractStatus=needs-user-input 时,Runtime 会按原 delivery 逐一发起 user.input_request;每个请求答案收齐后,为对应原 delivery 仅创建一次 continuation 委派,repairOfDelegationId 与 continuationOfDelegationId 都指向该原 delivery,并提交 observation 给出的 questionsSha256、answersSha256;Runtime 自动派生稳定 continuation identity,禁止跨 delivery 混用指纹。客观或语义不满足时可以发起一次新 agent.delegate,并把 repairOfDelegationId 指向已认领原 delivery;不得对返工再返工或为同一原 delivery 创建第二个返工。专业结果冲突且无法依据用户目标裁决时,合并问题后用一次 user.input_request 询问用户。只有实现路径、产品取舍或缺失事实会实质改变结果时才调用 user.input_request;项目内可读取事实、权限确认和工具失败不得伪装成用户问题。
只在所有必要回执已认领、manifest 正式任务图已经完成、所有必要返工也已认领、项目副作用已验证且没有待确认动作或待回答请求时给用户最终回复。不要向用户暴露内部 task/event、工具计划、动态 child ID 或调试状态。
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs
index 7354d0283..8bda8dd61 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs
@@ -22,6 +22,9 @@ mod runtime_state;
mod runtime_tools;
use codex_app_server::*;
use codex_cli::*;
+pub(crate) use codex_cli::{
+ game_creator_codex_cli_executable_path, game_creator_codex_cli_version_identity,
+};
pub(crate) use generation::*;
pub(crate) use interaction::*;
pub(crate) use prompt::*;
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs
index eeedc2dfd..ede836cf6 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs
@@ -7,7 +7,6 @@ use std::sync::{Arc, OnceLock, Weak};
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::sync::{mpsc, oneshot, Mutex};
-const GAME_CREATOR_CODEX_APP_SERVER_EXECUTABLE: &str = "codex";
const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc";
const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY";
const GAME_CREATOR_CODEX_APP_SERVER_PROTOCOL: &str = "genarrative-codex-app-server.v2";
@@ -20,6 +19,8 @@ const GAME_CREATOR_CODEX_APP_SERVER_THREAD_MAX: usize = 128;
const GAME_CREATOR_CODEX_APP_SERVER_RPC_TIMEOUT_MS: u64 = 30_000;
pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_TERMINAL_UNKNOWN_PREFIX: &str =
"codex-app-server-terminal-unknown:";
+pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX: &str =
+ "codex-app-server-error:";
type RpcResult = Result;
@@ -177,6 +178,92 @@ fn game_creator_codex_app_server_terminal_unknown(
))
}
+fn game_creator_codex_app_server_error_kind(kind: &str) -> platform_llm::LlmError {
+ platform_llm::LlmError::InvalidRequest(format!(
+ "{GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX}{kind}"
+ ))
+}
+
+fn game_creator_codex_app_server_error_http_status(
+ info: &serde_json::Value,
+ field: &str,
+) -> Option {
+ info.get(field)?
+ .get("httpStatusCode")?
+ .as_u64()
+ .and_then(|status| u16::try_from(status).ok())
+ .filter(|status| (100..=599).contains(status))
+}
+
+fn game_creator_codex_app_server_connection_error(
+ info: &serde_json::Value,
+ field: &str,
+) -> platform_llm::LlmError {
+ match game_creator_codex_app_server_error_http_status(info, field) {
+ Some(401 | 403) => game_creator_codex_app_server_error_kind("unauthorized"),
+ Some(status_code) => platform_llm::LlmError::Upstream {
+ status_code,
+ message: "Codex app-server 连接上游失败".to_string(),
+ },
+ None => platform_llm::LlmError::Connectivity {
+ attempts: 1,
+ message: "Codex app-server 连接失败".to_string(),
+ },
+ }
+}
+
+fn game_creator_codex_app_server_failed_turn_error(
+ turn: &serde_json::Value,
+) -> platform_llm::LlmError {
+ let Some(info) = turn
+ .get("error")
+ .and_then(|error| error.get("codexErrorInfo"))
+ .filter(|info| !info.is_null())
+ else {
+ return game_creator_codex_app_server_error_kind("other");
+ };
+ if let Some(kind) = info.as_str() {
+ return match kind {
+ "contextWindowExceeded" => {
+ game_creator_codex_app_server_error_kind("context-window-exceeded")
+ }
+ "sessionBudgetExceeded" => {
+ game_creator_codex_app_server_error_kind("session-budget-exceeded")
+ }
+ "usageLimitExceeded" => {
+ game_creator_codex_app_server_error_kind("usage-limit-exceeded")
+ }
+ "serverOverloaded" | "internalServerError" => platform_llm::LlmError::Upstream {
+ status_code: 503,
+ message: "Codex app-server 上游服务暂时不可用".to_string(),
+ },
+ "cyberPolicy" => game_creator_codex_app_server_error_kind("cyber-policy"),
+ "unauthorized" => game_creator_codex_app_server_error_kind("unauthorized"),
+ "badRequest" => game_creator_codex_app_server_error_kind("bad-request"),
+ "threadRollbackFailed" => {
+ game_creator_codex_app_server_error_kind("thread-rollback-failed")
+ }
+ "sandboxError" => game_creator_codex_app_server_error_kind("sandbox-error"),
+ "other" => game_creator_codex_app_server_error_kind("other"),
+ _ => game_creator_codex_app_server_error_kind("other"),
+ };
+ }
+ for field in [
+ "httpConnectionFailed",
+ "responseStreamConnectionFailed",
+ "responseStreamDisconnected",
+ "responseTooManyFailedAttempts",
+ ] {
+ if info.get(field).is_some() {
+ return game_creator_codex_app_server_connection_error(info, field);
+ }
+ }
+ if info.get("activeTurnNotSteerable").is_some() {
+ return game_creator_codex_app_server_error_kind("active-turn-not-steerable");
+ }
+ game_creator_codex_app_server_error_kind("other")
+}
+
async fn isolate_game_creator_codex_app_server_terminal_unknown(
inner: &Arc,
detail: impl Into,
@@ -394,10 +481,8 @@ fn configure_game_creator_codex_app_server_command(
"plugins",
"remote_plugin",
"shell_tool",
- "skill_search",
"tool_suggest",
"unified_exec",
- "view_image",
"workspace_dependencies",
] {
command.arg("--disable").arg(feature);
@@ -520,12 +605,9 @@ impl CodexAppServerConnection {
llm: &GameCreatorLlmConfig,
credential: &CodexAppServerCredential,
) -> Result {
- Self::spawn_with_executable_and_credential(
- llm,
- credential,
- std::ffi::OsStr::new(GAME_CREATOR_CODEX_APP_SERVER_EXECUTABLE),
- )
- .await
+ let executable = game_creator_codex_cli_executable_path()
+ .map_err(platform_llm::LlmError::InvalidConfig)?;
+ Self::spawn_with_executable_and_credential(llm, credential, executable.as_os_str()).await
}
async fn spawn_with_executable(
@@ -1012,9 +1094,7 @@ impl CodexAppServerConnection {
))
}
"failed" => {
- return Err(platform_llm::LlmError::InvalidRequest(
- "Codex app-server turn 执行失败".to_string(),
- ))
+ return Err(game_creator_codex_app_server_failed_turn_error(turn))
}
status => {
return Err(platform_llm::LlmError::Deserialize(format!(
@@ -1577,6 +1657,81 @@ mod tests {
assert!(response.text.is_empty());
}
+ #[test]
+ fn codex_app_server_failed_turn_uses_structured_error_info_without_raw_details() {
+ let secret = ["sk", "turn-secret"].join("-");
+ let failed_turn = serde_json::json!({
+ "status": "failed",
+ "error": {
+ "message": format!("private message {secret}"),
+ "additionalDetails": "https://provider.example/private C:\\Users\\victim\\project",
+ "codexErrorInfo": "contextWindowExceeded"
+ }
+ });
+ let error = game_creator_codex_app_server_failed_turn_error(&failed_turn);
+ assert_eq!(
+ error,
+ platform_llm::LlmError::InvalidRequest(
+ "codex-app-server-error:context-window-exceeded".to_string()
+ )
+ );
+ let visible = error.to_string();
+ assert!(!visible.contains(&secret));
+ assert!(!visible.contains("provider.example"));
+ assert!(!visible.contains("victim"));
+ }
+
+ #[test]
+ fn codex_app_server_failed_turn_maps_stable_categories_and_http_status() {
+ for (info, expected) in [
+ (
+ serde_json::json!("usageLimitExceeded"),
+ platform_llm::LlmError::InvalidRequest(
+ "codex-app-server-error:usage-limit-exceeded".to_string(),
+ ),
+ ),
+ (
+ serde_json::json!("unauthorized"),
+ platform_llm::LlmError::InvalidRequest(
+ "codex-app-server-error:unauthorized".to_string(),
+ ),
+ ),
+ (
+ serde_json::json!({"httpConnectionFailed":{"httpStatusCode":429}}),
+ platform_llm::LlmError::Upstream {
+ status_code: 429,
+ message: "Codex app-server 连接上游失败".to_string(),
+ },
+ ),
+ (
+ serde_json::json!({"responseStreamDisconnected":{"httpStatusCode":null}}),
+ platform_llm::LlmError::Connectivity {
+ attempts: 1,
+ message: "Codex app-server 连接失败".to_string(),
+ },
+ ),
+ ] {
+ let turn = serde_json::json!({
+ "status": "failed",
+ "error": {
+ "message": "private upstream body",
+ "additionalDetails": "private diagnostics",
+ "codexErrorInfo": info
+ }
+ });
+ assert_eq!(
+ game_creator_codex_app_server_failed_turn_error(&turn),
+ expected
+ );
+ }
+ assert_eq!(
+ game_creator_codex_app_server_failed_turn_error(
+ &serde_json::json!({"status":"failed","error":null})
+ ),
+ platform_llm::LlmError::InvalidRequest("codex-app-server-error:other".to_string())
+ );
+ }
+
#[test]
fn codex_app_server_rejects_non_responses_key_mapping() {
let mut llm = test_llm();
@@ -1587,6 +1742,41 @@ mod tests {
assert!(game_creator_codex_app_server_validate_llm_config(&llm).is_err());
}
+ #[test]
+ fn codex_app_server_command_uses_only_current_cli_feature_flags() {
+ let mut command = tokio::process::Command::new("codex");
+ configure_game_creator_codex_app_server_command(&mut command, &test_llm())
+ .expect("configure app-server command");
+ let arguments = command
+ .as_std()
+ .get_args()
+ .map(|argument| argument.to_string_lossy().into_owned())
+ .collect::>();
+ assert!(arguments
+ .windows(2)
+ .any(|pair| pair == ["--disable", "shell_tool"]));
+ assert!(!arguments.iter().any(|argument| argument == "skill_search"));
+ assert!(!arguments.iter().any(|argument| argument == "view_image"));
+ }
+
+ #[cfg(windows)]
+ #[test]
+ fn codex_app_server_current_cli_accepts_configured_arguments() {
+ let executable = game_creator_codex_cli_executable_path().expect("Codex CLI executable");
+ let mut command = std::process::Command::new(executable);
+ let mut configured = tokio::process::Command::new("codex");
+ configure_game_creator_codex_app_server_command(&mut configured, &test_llm())
+ .expect("configure app-server command");
+ command.args(configured.as_std().get_args());
+ command.arg("--help").stdin(Stdio::null());
+ let output = command.output().expect("run Codex app-server help");
+ assert!(
+ output.status.success(),
+ "configured app-server arguments must be accepted: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ }
+
#[test]
fn codex_app_server_pool_key_isolated_by_credentials_and_route() {
let mut base = test_llm();
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs
index d26107510..d0c2877d2 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs
@@ -1,4 +1,5 @@
use super::*;
+use std::path::{Path, PathBuf};
use std::process::Stdio;
use sha2::{Digest, Sha256};
@@ -9,29 +10,128 @@ const GAME_CREATOR_CODEX_CLI_PROMPT_MAX_BYTES: usize = 4 * 1024 * 1024;
const GAME_CREATOR_CODEX_CLI_STDOUT_MAX_BYTES: usize = 4 * 1024 * 1024;
const GAME_CREATOR_CODEX_CLI_STDERR_MAX_BYTES: usize = 256 * 1024;
+fn game_creator_codex_cli_executable_candidates_for(
+ app_data: Option<&Path>,
+ local_app_data: Option<&Path>,
+ runtime_config_dir: Option<&Path>,
+ path: Option<&std::ffi::OsStr>,
+) -> Vec {
+ let mut candidates = Vec::new();
+ #[cfg(windows)]
+ {
+ fn append_native_npm_candidates(candidates: &mut Vec, npm_root: &Path) {
+ let vendor_root = npm_root
+ .join("node_modules")
+ .join("@openai")
+ .join("codex")
+ .join("node_modules")
+ .join("@openai")
+ .join("codex-win32-x64")
+ .join("vendor");
+ if let Ok(entries) = std::fs::read_dir(vendor_root) {
+ let mut targets = entries
+ .filter_map(Result::ok)
+ .map(|entry| entry.path().join("bin").join("codex.exe"))
+ .collect::>();
+ targets.sort();
+ candidates.extend(targets);
+ }
+ }
+
+ fn append_desktop_codex_candidates(candidates: &mut Vec, local_app_data: &Path) {
+ let bin_root = local_app_data.join("OpenAI").join("Codex").join("bin");
+ if let Ok(entries) = std::fs::read_dir(bin_root) {
+ let mut targets = entries
+ .filter_map(Result::ok)
+ .map(|entry| entry.path().join("codex.exe"))
+ .collect::>();
+ targets.sort();
+ targets.reverse();
+ candidates.extend(targets);
+ }
+ }
+
+ if let Some(app_data) = app_data {
+ append_native_npm_candidates(&mut candidates, &app_data.join("npm"));
+ }
+ if let Some(local_app_data) = local_app_data {
+ append_desktop_codex_candidates(&mut candidates, local_app_data);
+ }
+ if let Some(app_data) = runtime_config_dir.and_then(Path::parent) {
+ append_native_npm_candidates(&mut candidates, &app_data.join("npm"));
+ if let Some(user_profile) = app_data.parent() {
+ append_desktop_codex_candidates(&mut candidates, &user_profile.join("Local"));
+ }
+ }
+ if let Some(path) = path {
+ for entry in std::env::split_paths(&path) {
+ append_native_npm_candidates(&mut candidates, &entry);
+ candidates.push(entry.join("codex.exe"));
+ }
+ }
+ }
+ candidates.push(PathBuf::from(GAME_CREATOR_CODEX_CLI_EXECUTABLE));
+ candidates
+}
+
+fn game_creator_codex_cli_executable_candidates() -> Vec {
+ game_creator_codex_cli_executable_candidates_for(
+ std::env::var_os("APPDATA").as_deref().map(Path::new),
+ std::env::var_os("LOCALAPPDATA").as_deref().map(Path::new),
+ game_creator_runtime_config_dir().as_deref(),
+ std::env::var_os("PATH").as_deref(),
+ )
+}
+
+fn game_creator_codex_cli_version_at(executable: &Path) -> Result {
+ let output = std::process::Command::new(executable)
+ .arg("--version")
+ .stdin(Stdio::null())
+ .stderr(Stdio::null())
+ .output()
+ .map_err(|error| error.to_string())?;
+ if !output.status.success() {
+ return Err(format!("版本检查退出状态为 {}", output.status));
+ }
+ let version = std::str::from_utf8(&output.stdout)
+ .map_err(|_| "版本信息不是 UTF-8".to_string())?
+ .trim();
+ if !version.starts_with("codex-cli ") || version.len() > 120 {
+ return Err("返回了无法识别的版本信息".to_string());
+ }
+ Ok(version.to_string())
+}
+
+pub(crate) fn game_creator_codex_cli_executable_path() -> Result {
+ let mut last_error = None;
+ let mut seen = std::collections::HashSet::new();
+ for candidate in game_creator_codex_cli_executable_candidates() {
+ let identity = candidate.to_string_lossy().to_ascii_lowercase();
+ if !seen.insert(identity) {
+ continue;
+ }
+ match game_creator_codex_cli_version_at(&candidate) {
+ Ok(_) => return Ok(candidate),
+ Err(error) => last_error = Some(error),
+ }
+ }
+ Err(format!(
+ "Codex CLI 未安装或当前 Agent Runner 无法启动;已检查 PATH 和 npm 全局安装目录{}",
+ last_error
+ .map(|error| format!("(最后错误:{error})"))
+ .unwrap_or_default()
+ ))
+}
+
struct CodexCliStderrSummary {
byte_len: usize,
sha256: String,
classification: &'static str,
}
-pub(in crate::agent) fn game_creator_codex_cli_version_identity() -> Result {
- let output = std::process::Command::new(GAME_CREATOR_CODEX_CLI_EXECUTABLE)
- .arg("--version")
- .stdin(Stdio::null())
- .stderr(Stdio::null())
- .output()
- .map_err(|_| "Codex CLI 未安装或不在当前 Agent Runner PATH 中".to_string())?;
- if !output.status.success() {
- return Err("Codex CLI 版本检查失败".to_string());
- }
- let version = std::str::from_utf8(&output.stdout)
- .map_err(|_| "Codex CLI 版本信息不是 UTF-8".to_string())?
- .trim();
- if !version.starts_with("codex-cli ") || version.len() > 120 {
- return Err("Codex CLI 返回了无法识别的版本信息".to_string());
- }
- Ok(version.to_string())
+pub(crate) fn game_creator_codex_cli_version_identity() -> Result {
+ let executable = game_creator_codex_cli_executable_path()?;
+ game_creator_codex_cli_version_at(&executable)
}
pub(in crate::agent) fn game_creator_codex_cli_reasoning_effort(
@@ -546,11 +646,9 @@ async fn request_game_creator_agent_codex_cli_with_executable(
pub(in crate::agent) async fn request_game_creator_agent_codex_cli(
request: LlmRunRequest,
) -> Result {
- request_game_creator_agent_codex_cli_with_executable(
- std::ffi::OsStr::new(GAME_CREATOR_CODEX_CLI_EXECUTABLE),
- request,
- )
- .await
+ let executable =
+ game_creator_codex_cli_executable_path().map_err(platform_llm::LlmError::InvalidConfig)?;
+ request_game_creator_agent_codex_cli_with_executable(executable.as_os_str(), request).await
}
#[cfg(test)]
@@ -567,6 +665,103 @@ mod tests {
])
}
+ #[cfg(windows)]
+ #[test]
+ fn codex_cli_candidates_prefer_sorted_native_npm_targets_before_path() {
+ let temp = tempfile::tempdir().expect("temp dir");
+ let app_data = temp.path().join("app-data");
+ let vendor = app_data
+ .join("npm/node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor");
+ std::fs::create_dir_all(vendor.join("z-target/bin")).expect("z target");
+ std::fs::create_dir_all(vendor.join("a-target/bin")).expect("a target");
+ let path_dir = temp.path().join("path");
+ std::fs::create_dir_all(&path_dir).expect("path dir");
+
+ let candidates = game_creator_codex_cli_executable_candidates_for(
+ Some(&app_data),
+ None,
+ None,
+ Some(path_dir.as_os_str()),
+ );
+ assert_eq!(
+ candidates[0],
+ vendor.join("a-target/bin/codex.exe"),
+ "native npm targets must be deterministic and precede PATH"
+ );
+ assert_eq!(candidates[1], vendor.join("z-target/bin/codex.exe"));
+ assert_eq!(candidates[2], path_dir.join("codex.exe"));
+ assert_eq!(candidates.last(), Some(&PathBuf::from("codex")));
+ }
+
+ #[cfg(windows)]
+ #[test]
+ fn codex_cli_candidates_discover_native_npm_target_from_path_without_appdata() {
+ let temp = tempfile::tempdir().expect("temp dir");
+ let npm_root = temp.path().join("npm");
+ let native = npm_root
+ .join("node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor")
+ .join("x86_64-pc-windows-msvc/bin/codex.exe");
+ std::fs::create_dir_all(native.parent().expect("native parent"))
+ .expect("native target directory");
+
+ let candidates = game_creator_codex_cli_executable_candidates_for(
+ None,
+ None,
+ None,
+ Some(npm_root.as_os_str()),
+ );
+ assert_eq!(candidates[0], native);
+ assert_eq!(candidates[1], npm_root.join("codex.exe"));
+ }
+
+ #[cfg(windows)]
+ #[test]
+ fn codex_cli_candidates_discover_native_npm_target_from_runtime_config_dir() {
+ let temp = tempfile::tempdir().expect("temp dir");
+ let app_data = temp.path().join("roaming");
+ let config_dir = app_data.join("world.genarrative.ai-game-creator");
+ let native = app_data
+ .join("npm/node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor")
+ .join("x86_64-pc-windows-msvc/bin/codex.exe");
+ std::fs::create_dir_all(native.parent().expect("native parent"))
+ .expect("native target directory");
+
+ let candidates =
+ game_creator_codex_cli_executable_candidates_for(None, None, Some(&config_dir), None);
+ assert_eq!(candidates[0], native);
+ }
+
+ #[cfg(windows)]
+ #[test]
+ fn codex_cli_candidates_discover_desktop_native_target() {
+ let temp = tempfile::tempdir().expect("temp dir");
+ let local_app_data = temp.path().join("local");
+ let older = local_app_data.join("OpenAI/Codex/bin/111/codex.exe");
+ let newer = local_app_data.join("OpenAI/Codex/bin/222/codex.exe");
+ std::fs::create_dir_all(older.parent().expect("older parent")).expect("older dir");
+ std::fs::create_dir_all(newer.parent().expect("newer parent")).expect("newer dir");
+
+ let candidates = game_creator_codex_cli_executable_candidates_for(
+ None,
+ Some(&local_app_data),
+ None,
+ None,
+ );
+ assert_eq!(candidates[0], newer);
+ assert_eq!(candidates[1], older);
+ }
+
+ #[cfg(windows)]
+ #[test]
+ fn codex_cli_resolver_finds_current_native_install() {
+ let executable = game_creator_codex_cli_executable_path().expect("Codex CLI executable");
+ assert!(executable.is_absolute());
+ assert_eq!(
+ game_creator_codex_cli_version_identity().expect("Codex CLI version"),
+ game_creator_codex_cli_version_at(&executable).expect("same executable version")
+ );
+ }
+
#[test]
fn codex_cli_mode_renders_runtime_messages_and_structured_tool_contract() {
let prompt = render_game_creator_codex_cli_prompt(&tool_request()).expect("render prompt");
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs
index 8c1375ad2..0fe8bab76 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs
@@ -2265,7 +2265,19 @@ struct TrustedPlatformArtTransactionDirectory {
impl TrustedPlatformArtTransactionDirectory {
fn open_anchored(root: &Path, path: &Path) -> Result {
- let parent = TrustedPlatformArtRecoveryParent::open(root, path, false)?;
+ // The transaction leaf is a directory. Anchor and validate its parent using a
+ // missing sibling path so the regular-file recovery preflight does not reject the
+ // directory itself before the dedicated directory validation below.
+ let anchor_target = path.with_file_name(".art-spritesheet-contract-transaction.anchor");
+ let parent = TrustedPlatformArtRecoveryParent::open(root, &anchor_target, false)?;
+ #[cfg(unix)]
+ let parent = TrustedPlatformArtRecoveryParent {
+ leaf: path
+ .file_name()
+ .ok_or_else(|| "平台图集事务目录缺少叶子文件名".to_string())?
+ .to_os_string(),
+ ..parent
+ };
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
@@ -3658,11 +3670,14 @@ fn sync_strict_platform_art_contract_state_at(
path.display()
));
}
- Ok(_) => fs::File::open(&path)
- .and_then(|file| file.sync_all())
- .map_err(|error| {
- format!("同步平台图集合同文件失败:{}: {error}", path.display())
- })?,
+ Ok(_) => {
+ #[cfg(unix)]
+ fs::File::open(&path)
+ .and_then(|file| file.sync_all())
+ .map_err(|error| {
+ format!("同步平台图集合同文件失败:{}: {error}", path.display())
+ })?;
+ }
Err(error) if error.kind() == std::io::ErrorKind::NotFound && !require_complete => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Err(format!("平台图集提交缺少完整合同文件:{}", path.display()));
@@ -4698,6 +4713,7 @@ impl PlatformArtSliceContractRollback {
&journal,
"平台图集事务 journal",
)?;
+ #[cfg(unix)]
trusted_transaction_directory
.handle
.sync_all()
@@ -4794,6 +4810,7 @@ impl PlatformArtSliceContractRollback {
));
}
}
+ #[cfg(unix)]
trusted_transaction_directory.handle.sync_all().map_err(|error| {
format!(
"{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 平台图集合同已提交,但同步 prepared marker 清理失败:{error}"
@@ -7852,6 +7869,7 @@ mod canvas_generation_tests {
drop(project_lock);
}
+ #[cfg(unix)]
#[test]
fn durable_strict_contract_transaction_rejects_same_length_snapshot_rewrite_during_read() {
let temporary = tempfile::tempdir().expect("create concurrent snapshot project");
@@ -8322,6 +8340,7 @@ mod canvas_generation_tests {
assert!(!root.join(STRICT_PLATFORM_ART_TRANSACTION_PATH).exists());
}
+ #[cfg(unix)]
#[test]
fn durable_strict_contract_transaction_rejects_replaced_transaction_directory() {
let temporary = tempfile::tempdir().expect("create replaced transaction directory fixture");
@@ -8689,6 +8708,7 @@ mod canvas_generation_tests {
.expect("open sparse main sheet");
main.set_len(STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES + 1)
.expect("create oversized sparse main sheet");
+ drop(main);
let error = match PlatformArtSliceContractRollback::capture(root, "oversized-snapshot") {
Ok(_) => panic!("oversized sparse snapshot must fail before an unbounded read"),
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs
index dd84942a3..43a6aa0ed 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs
@@ -547,7 +547,13 @@ pub(crate) fn game_creator_agent_llm_error_public_summary(
(format!("upstream-{status_code}"), Some(*status_code))
}
platform_llm::LlmError::InvalidConfig(_) => ("invalid-config".to_string(), None),
- platform_llm::LlmError::InvalidRequest(_) => ("invalid-request".to_string(), None),
+ platform_llm::LlmError::InvalidRequest(message) => (
+ message
+ .strip_prefix(GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX)
+ .map(|kind| format!("codex-app-server-{kind}"))
+ .unwrap_or_else(|| "invalid-request".to_string()),
+ None,
+ ),
platform_llm::LlmError::StreamUnavailable => ("stream-unavailable".to_string(), None),
platform_llm::LlmError::EmptyResponse => ("empty-response".to_string(), None),
platform_llm::LlmError::Deserialize(_) => ("deserialize".to_string(), None),
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/run_lifecycle.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/run_lifecycle.rs
index d70ede095..697fd2c81 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/run_lifecycle.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/run_lifecycle.rs
@@ -93,9 +93,10 @@ pub(crate) async fn control_agent_run_at(
let prompt = resumed_agent_run_prompt(&previous_trace.goal, action, detail);
let generated = generate_local_game_draft_at(root, &prompt, progress).await?;
let trace = read_latest_agent_run_trace(root)?;
+ let game_index_path = generated.game_index_path.replace('\\', "/");
let message = format!(
"{},已重新运行为 {}:{}",
- control_result.message, trace.run_id, generated.game_index_path
+ control_result.message, trace.run_id, game_index_path
);
let event = if action == "retry" {
"agent.retry.run"
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 cc6d337e5..731d40ef0 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
@@ -214,7 +214,9 @@ pub(in crate::agent) fn validate_root_goal_contract_control_plan_at(
|| !plan.plan.is_empty()
|| !plan.response.trim().is_empty()
{
- return Err("根 Project Supervisor 必须先把自己对当前用户最终意图的理解作为本轮唯一动作提交 agent.goal_contract;固定规则只提供上下文,不能先调度、委派、修改项目或回复完成".to_string());
+ return Err(format!(
+ "{AGENT_RUNTIME_ROOT_GOAL_CONTRACT_REQUIRED_ERROR_PREFIX};固定规则只提供上下文,不能先调度、委派、修改项目或回复完成"
+ ));
}
return Ok(());
}
@@ -236,6 +238,29 @@ pub(in crate::agent) fn validate_root_goal_contract_control_plan_at(
Ok(())
}
+pub(super) const AGENT_RUNTIME_ROOT_GOAL_CONTRACT_REQUIRED_ERROR_PREFIX: &str =
+ "根 Project Supervisor 必须先把自己对当前用户最终意图的理解作为本轮唯一动作提交 agent.goal_contract";
+
+pub(in crate::agent) fn restrict_agent_runtime_root_goal_contract_tools(
+ request: &mut LlmRunRequest,
+) -> Result<(), String> {
+ let goal_contract_function = native_runtime_function_name("agent.goal_contract")
+ .ok_or_else(|| "无法生成根 Goal Contract 工具函数名".to_string())?;
+ request
+ .function_tools
+ .retain(|tool| tool.name == goal_contract_function);
+ if request.function_tools.len() != 1 {
+ return Err("根 Goal Contract 工具目录缺少 agent.goal_contract".to_string());
+ }
+ request.max_output_tokens = Some(
+ request
+ .max_output_tokens
+ .unwrap_or(AGENT_RUNTIME_AUTONOMOUS_FORCED_ACTION_MAX_OUTPUT_TOKENS)
+ .min(AGENT_RUNTIME_AUTONOMOUS_FORCED_ACTION_MAX_OUTPUT_TOKENS),
+ );
+ Ok(())
+}
+
pub(super) const AGENT_RUNTIME_AUTONOMOUS_SUPERVISOR_DELIVERY_CONVERGENCE_LIVENESS_ERROR_PREFIX:
&str = "自主构建 Project Supervisor 必须先收束已有专业 Agent 委派";
pub(super) const AGENT_RUNTIME_AUTONOMOUS_PREVIEW_AFTER_STATIC_LIVENESS_ERROR_PREFIX: &str =
@@ -702,6 +727,30 @@ pub(crate) fn validate_agent_runtime_autonomous_plan_liveness(
.actions
.iter()
.any(|action| action.tool.trim() == "agent.route_manifest");
+ let latest_playtest_index = observations
+ .iter()
+ .rposition(|observation| observation.tool == "preview.validate");
+ let latest_playtest_is_failed = latest_playtest_index.is_some_and(|index| {
+ observations[index].status == "failed"
+ && observations[index].summary == "浏览器验证未通过,请根据诊断修复后重试"
+ });
+ // A Supervisor without a mutation must still hit the same first-mutation
+ // liveness gate as every other autonomous Agent. A concrete failed
+ // playtest is more specific and must reach its repair gate below.
+ if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
+ && loop_index > AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT
+ && verification_gate.mutation_revision.is_none()
+ && verification_gate.failed_playtest_revision.is_none()
+ && !has_mutation
+ && !has_code_asset_route
+ && plan.response.trim().is_empty()
+ && !has_specialist_delegation
+ && !latest_playtest_is_failed
+ {
+ return Err(format!(
+ "{AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX};Supervisor 尚未提交首次项目 mutation 或有效协作动作,禁止继续只规划、读取、验证或空转"
+ ));
+ }
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
if let Some(failed_playtest_revision) = verification_gate.failed_playtest_revision {
if project_revision < failed_playtest_revision {
@@ -743,10 +792,7 @@ pub(crate) fn validate_agent_runtime_autonomous_plan_liveness(
"{AGENT_RUNTIME_AUTONOMOUS_PREVIEW_AFTER_STATIC_LIVENESS_ERROR_PREFIX};当前 revision {project_revision} 已通过 game.static_smoke,下一步必须只调用 preview.validate 取得当前 revision 的桌面与移动真实试玩凭证;不得继续委派、更新计划、读取、搜索、查询状态、修改项目或返回最终回复"
));
}
- let Some(latest_playtest_index) = observations
- .iter()
- .rposition(|observation| observation.tool == "preview.validate")
- else {
+ let Some(latest_playtest_index) = latest_playtest_index else {
return Ok(());
};
let latest_playtest = &observations[latest_playtest_index];
@@ -1609,6 +1655,35 @@ pub(in crate::agent) fn agent_runtime_protocol_error_requires_supervisor_collabo
mod tests {
use super::*;
+ #[test]
+ fn root_goal_contract_repair_catalog_contains_only_goal_contract() {
+ let catalog = GameCreatorMcpCatalog {
+ fingerprint: String::new(),
+ servers: Vec::new(),
+ tools: Vec::new(),
+ };
+ let mut request = LlmRunRequest::new(Vec::new())
+ .with_function_tools(
+ build_agent_runtime_native_function_tools(&catalog)
+ .expect("build native function tools"),
+ )
+ .with_tool_choice(platform_llm::LlmToolChoice::Required);
+
+ restrict_agent_runtime_root_goal_contract_tools(&mut request)
+ .expect("restrict root Goal Contract tools");
+
+ assert_eq!(request.function_tools.len(), 1);
+ assert_eq!(
+ request.function_tools[0].name,
+ native_runtime_function_name("agent.goal_contract")
+ .expect("goal contract function name")
+ );
+ assert_eq!(
+ request.tool_choice,
+ Some(platform_llm::LlmToolChoice::Required)
+ );
+ }
+
fn autonomous_initial_delegate(
agent_id: &str,
expected_artifacts: &[&str],
@@ -1911,4 +1986,88 @@ mod tests {
.is_err());
}
}
+
+ #[test]
+ fn autonomous_supervisor_without_playtest_still_hits_pre_mutation_liveness_gate() {
+ let verification_gate = AgentRuntimeVerificationGate {
+ schema_version: "test".to_string(),
+ project_id: "test".to_string(),
+ agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(),
+ run_id: "supervisor-pre-mutation".to_string(),
+ requires_verification: false,
+ mutation_revision: None,
+ verified_revision: None,
+ last_mutation_tool: None,
+ last_verification_tool: None,
+ last_verification_status: None,
+ static_smoke_verified_revision: None,
+ failed_playtest_revision: None,
+ updated_at: 0,
+ };
+ let plan = AgentRuntimeToolPlan {
+ thinking_summary: "继续只读规划".to_string(),
+ ..AgentRuntimeToolPlan::default()
+ };
+ let error = validate_agent_runtime_autonomous_plan_liveness(
+ GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
+ AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1,
+ 0,
+ &verification_gate,
+ &[],
+ &plan,
+ false,
+ false,
+ )
+ .expect_err("Supervisor without a playtest must not bypass pre-mutation liveness");
+ assert!(error.starts_with(AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX));
+ }
+
+ #[test]
+ fn autonomous_supervisor_latest_successful_playtest_does_not_bypass_pre_mutation_gate() {
+ let verification_gate = AgentRuntimeVerificationGate {
+ schema_version: "test".to_string(),
+ project_id: "test".to_string(),
+ agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(),
+ run_id: "supervisor-latest-playtest".to_string(),
+ requires_verification: false,
+ mutation_revision: None,
+ verified_revision: None,
+ last_mutation_tool: None,
+ last_verification_tool: None,
+ last_verification_status: None,
+ static_smoke_verified_revision: None,
+ failed_playtest_revision: None,
+ updated_at: 0,
+ };
+ let observations = [
+ AgentRuntimeToolObservation {
+ tool: "preview.validate".to_string(),
+ status: "failed".to_string(),
+ summary: "浏览器验证未通过,请根据诊断修复后重试".to_string(),
+ detail: None,
+ },
+ AgentRuntimeToolObservation {
+ tool: "preview.validate".to_string(),
+ status: "ok".to_string(),
+ summary: "浏览器验证通过".to_string(),
+ detail: None,
+ },
+ ];
+ let plan = AgentRuntimeToolPlan {
+ thinking_summary: "继续只读规划".to_string(),
+ ..AgentRuntimeToolPlan::default()
+ };
+ let error = validate_agent_runtime_autonomous_plan_liveness(
+ GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
+ AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1,
+ 0,
+ &verification_gate,
+ &observations,
+ &plan,
+ false,
+ false,
+ )
+ .expect_err("latest successful playtest must not retain an earlier failure exemption");
+ assert!(error.starts_with(AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX));
+ }
}
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 3f2a0951d..0ed74be4c 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
@@ -105,6 +105,8 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
let root_goal_contract_context =
render_game_creator_agent_runtime_goal_contract_for_prompt_at(root, agent_id, run_id)?
.unwrap_or_else(|| "null".to_string());
+ let root_goal_contract_required =
+ root_control_authority && root_goal_contract_context == "null";
let acceptance_graph_context =
render_game_creator_agent_runtime_acceptance_graph_for_prompt_at(root, agent_id, run_id)?
.unwrap_or_else(|| "null".to_string());
@@ -316,6 +318,55 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
tool.name != goal_contract_function && tool.name != acceptance_update_function
});
}
+ // A rejected structured-plan update is a request-scoped liveness signal.
+ // The next Provider turn must perform the concrete mutation (or deliver a
+ // read-only result) instead of entering another planning loop.
+ let latest_plan_rejection = observations.iter().rposition(|observation| {
+ observation.tool == "runtime.plan_update" && observation.status == "rejected"
+ });
+ let plan_rejection_needs_repair = latest_plan_rejection.is_some_and(|index| {
+ !observations[index + 1..]
+ .iter()
+ .any(is_agent_runtime_project_mutation_observation)
+ });
+ if autonomous_game_build && plan_rejection_needs_repair {
+ let supervisor_orchestrator_repair = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
+ {
+ let policy =
+ resolve_supervisor_collaboration_policy_for_run_at(root, agent_id, run_id)?.policy;
+ let state = read_supervisor_collaboration_state_at(root, agent_id, run_id)?;
+ policy.orchestrator_only_after_delegation && state.has_collaboration()
+ } else {
+ false
+ };
+ let repair_tools: &[&str] = if supervisor_orchestrator_repair {
+ &["agent.delegate", "agent.run_status"]
+ } else {
+ &[
+ "file.write",
+ "file.patch",
+ "file.delete",
+ "project.patchset",
+ "project.restore",
+ "canvas.asset_generate",
+ ]
+ };
+ let mut allowed_function_names =
+ BTreeSet::from([AGENT_RUNTIME_RESPOND_FUNCTION_NAME.to_string()]);
+ for tool in repair_tools {
+ if let Some(name) = native_runtime_function_name(tool) {
+ allowed_function_names.insert(name);
+ }
+ }
+ request
+ .function_tools
+ .retain(|tool| allowed_function_names.contains(&tool.name));
+ request.messages.push(LlmMessage::user(if supervisor_orchestrator_repair {
+ "上一轮 runtime.plan_update 被拒绝。本轮 Supervisor 已进入协作编排模式,只能调用 agent.run_status 或 agent.delegate 继续收束,或在证据足够时 respond_to_user;禁止再次规划、读取、搜索、验证或直接修改项目。"
+ } else {
+ "上一轮 runtime.plan_update 被拒绝。本轮必须立即提交当前 in_progress 步骤对应的实际项目 mutation,或在只读合同已满足时 respond_to_user;禁止再次规划、读取、搜索、验证、委派或普通文本解释。"
+ }));
+ }
if autonomous_game_build && !editor_api_key_is_configured() {
let canvas_function = native_runtime_function_name("canvas.asset_generate")
.ok_or_else(|| "无法生成画布素材工具函数名".to_string())?;
@@ -330,6 +381,12 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
.function_tools
.retain(|tool| tool.name != project_verify_function);
}
+ if root_goal_contract_required {
+ restrict_agent_runtime_root_goal_contract_tools(&mut request)?;
+ request.messages.push(LlmMessage::user(
+ "当前根 Run 尚未冻结 Goal Contract。本轮唯一可用工具是 agent.goal_contract;必须且只能调用一次,用 outcome 具体概括当前用户最终意图,acceptanceNodes 至少提交一项可核对标准。每个 requiredEvidence 必须选择在该标准所有合法结果下都能成功产生回执的工具;环境探测可能以 rejected/failed 表示正常否定结果时,不得把该探测工具写成必需成功回执(例如非 Git 项目不得要求 git.inspect 成功,应使用 project.index 的成功回执证明 isRepository=false)。nonNegotiables、preferences、forbiddenAssumptions、openQuestions 没有内容时传空数组。不得调用 update_agent_plan、respond_to_user 或任何其他动作,不得输出普通文本。",
+ ));
+ }
request = apply_game_creator_llm_web_search(
apply_game_creator_llm_reasoning_effort(request, &llm)?,
&llm,
@@ -530,12 +587,14 @@ mod tests {
game_creator_project_supervisor_chat_system_prompt, init_local_game_project_at,
provider_command_exec_contract, provider_command_start_contract,
required_runtime_prompt_section, resolve_agent_conversation_session_id_at,
- start_game_creator_agent_runtime_task_at, AgentRuntimeTaskLink, AgentRuntimeToolPlan,
- GameCreatorMcpCatalog, GameCreatorMcpCatalogTool,
- AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL,
+ start_game_creator_agent_runtime_task_at, AgentRuntimeGoalContractAcceptanceNodeDraft,
+ AgentRuntimeGoalContractDraft, AgentRuntimeTaskLink, AgentRuntimeToolObservation,
+ AgentRuntimeToolPlan, GameCreatorMcpCatalog, GameCreatorMcpCatalogTool,
+ AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, AGENT_RUNTIME_RESPOND_FUNCTION_NAME,
AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE,
- GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION,
+ AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
+ RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION,
};
fn native_input_required_fields(
@@ -556,6 +615,92 @@ mod tests {
.collect()
}
+ #[test]
+ fn rejected_plan_update_forces_request_scoped_mutation_catalog() {
+ 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", "修复现有游戏")
+ .expect("project init");
+ let binding = bind_game_creator_agent_runtime_run_profile_at(
+ &root,
+ GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
+ "plan-rejection-repair-root",
+ AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
+ Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
+ None,
+ )
+ .expect("bind root");
+ let state = start_game_creator_agent_runtime_task_at(
+ &root,
+ &binding.agent_id,
+ "修复现有游戏",
+ &binding.run_id,
+ &binding.source,
+ "执行当前计划中的项目修改",
+ vec!["立即修改 game/index.html".to_string()],
+ )
+ .expect("start task");
+ crate::agent::create_game_creator_agent_runtime_goal_contract_at(
+ &root,
+ &binding.agent_id,
+ &binding.run_id,
+ &state.current_task,
+ &AgentRuntimeGoalContractDraft {
+ outcome: "修复现有游戏".to_string(),
+ non_negotiables: Vec::new(),
+ preferences: Vec::new(),
+ forbidden_assumptions: Vec::new(),
+ open_questions: Vec::new(),
+ acceptance_nodes: vec![AgentRuntimeGoalContractAcceptanceNodeDraft {
+ criterion_id: "repair-game".to_string(),
+ criterion: "完成项目修改".to_string(),
+ required: true,
+ required_evidence: vec!["file.patch".to_string()],
+ dependencies: Vec::new(),
+ }],
+ },
+ )
+ .expect("create goal contract");
+ let catalog = GameCreatorMcpCatalog {
+ fingerprint: String::new(),
+ servers: Vec::new(),
+ tools: Vec::new(),
+ };
+ let rejected = AgentRuntimeToolObservation {
+ tool: "runtime.plan_update".to_string(),
+ status: "rejected".to_string(),
+ summary: "结构化计划更新被 Runtime 拒绝".to_string(),
+ detail: Some("计划状态回退".to_string()),
+ };
+ let (_, _, request, _) = build_game_creator_agent_background_tool_plan_request(
+ &root,
+ &state.agent_id,
+ &state.session_id,
+ &state.run_id,
+ &state.current_task,
+ &[rejected],
+ 1,
+ &catalog,
+ )
+ .expect("build request");
+ let names = request
+ .function_tools
+ .iter()
+ .map(|tool| tool.name.as_str())
+ .collect::>();
+ assert!(names.contains(
+ crate::agent_native_tools::native_runtime_function_name("file.patch")
+ .expect("file.patch function")
+ .as_str()
+ ));
+ assert!(names.contains(AGENT_RUNTIME_RESPOND_FUNCTION_NAME));
+ assert!(!names.contains(AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME));
+ assert!(request
+ .messages
+ .iter()
+ .any(|message| message.content.contains("runtime.plan_update 被拒绝")));
+ }
+
fn build_request_system_prompt_for_root_source(
agent_id: &str,
root_source: &str,
@@ -667,7 +812,7 @@ mod tests {
}
#[test]
- fn trusted_root_supervisor_receives_dynamic_goal_control_tools() {
+ fn trusted_root_supervisor_first_turn_only_receives_goal_contract_tool() {
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", "完成可验证游戏")
@@ -711,6 +856,7 @@ mod tests {
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"),
[
@@ -722,10 +868,9 @@ mod tests {
"acceptanceNodes"
]
);
- assert_eq!(
- native_input_required_fields(&request, "agent.acceptance_update"),
- ["contractFingerprint", "evaluations"]
- );
+ assert!(request.messages.iter().any(|message| message
+ .content
+ .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 50719d29b..3754f1f80 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
@@ -856,11 +856,14 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
&& protocol_error
.starts_with(AGENT_RUNTIME_AUTONOMOUS_TRUNCATED_SCAFFOLD_ERROR_PREFIX)
&& !request.function_tools.is_empty();
+ let force_root_goal_contract = protocol_error
+ .starts_with(AGENT_RUNTIME_ROOT_GOAL_CONTRACT_REQUIRED_ERROR_PREFIX);
let force_supervisor_initial_collaboration =
agent_runtime_protocol_error_requires_supervisor_collaboration_repair(
&protocol_error,
) && !request.function_tools.is_empty();
- if force_supervisor_initial_collaboration
+ if force_root_goal_contract
+ || force_supervisor_initial_collaboration
|| force_autonomous_specialist_mutation_only
|| force_autonomous_specialist_verification_only
|| force_autonomous_response_plan_completion
@@ -887,7 +890,12 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
.retain(|tool| tool.name != project_verify_function);
}
}
- if force_supervisor_initial_collaboration {
+ if force_root_goal_contract {
+ restrict_agent_runtime_root_goal_contract_tools(&mut request)?;
+ request.messages.push(LlmMessage::user(format!(
+ "上一条输出不符合工具计划协议:{protocol_error}\n当前根 Run 尚未冻结 Goal Contract。本次修复的原生工具目录只保留 agent.goal_contract;必须且只能调用一次,用 outcome 具体概括当前用户最终意图,acceptanceNodes 至少提交一项可核对标准。每个 requiredEvidence 必须选择在该标准所有合法结果下都能成功产生回执的工具;环境探测可能以 rejected/failed 表示正常否定结果时,不得把该探测工具写成必需成功回执(例如非 Git 项目不得要求 git.inspect 成功,应使用 project.index 的成功回执证明 isRepository=false)。nonNegotiables、preferences、forbiddenAssumptions、openQuestions 没有内容时传空数组。不得调用 update_agent_plan、respond_to_user 或任何其他动作,不得输出普通文本、解释、markdown 或代码围栏。"
+ )));
+ } else if force_supervisor_initial_collaboration {
supervisor_collaboration_repair_active = true;
if let Some(actions) = supervisor_collaboration_candidate_actions.take() {
supervisor_collaboration_repair_actions =
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs
index 9604b7f27..b979bc23c 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs
@@ -301,7 +301,7 @@ pub(crate) use provider_recovery::{
#[cfg(test)]
pub(crate) use provider_recovery::{
drive_waiting_autonomous_manifest_parent_wake_budget_for_test,
- ensure_waiting_provider_retry_records_for_test,
+ ensure_static_delegate_user_input_wait_at, ensure_waiting_provider_retry_records_for_test,
mark_autonomous_manifest_parent_wake_needs_reconciliation_for_test,
prepare_waiting_autonomous_manifest_parent_for_test,
probe_static_delegate_parent_wake_singleflight_coalescing,
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 4d02b7aba..fa6ecd3fc 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
@@ -60,6 +60,25 @@ pub(super) fn game_creator_agent_background_final_reply_fallback(
}
}
+pub(super) fn game_creator_agent_final_reply_error_allows_fallback(error: &str) -> bool {
+ const KIND_PREFIX: &str = "kind=";
+ let Some(start) = error.rfind(KIND_PREFIX) else {
+ return false;
+ };
+ if error[..start]
+ .chars()
+ .next_back()
+ .is_some_and(|boundary| !boundary.is_whitespace() && boundary != ':' && boundary != ':')
+ {
+ return false;
+ }
+ let kind = error[start + KIND_PREFIX.len()..]
+ .chars()
+ .take_while(|character| character.is_ascii_lowercase() || *character == '-')
+ .collect::();
+ matches!(kind.as_str(), "empty-response" | "deserialize")
+}
+
fn requested_game_chat_fast_path_plan_at(
root: &Path,
plan: AgentRuntimeToolPlan,
@@ -1879,8 +1898,46 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
.detail
.as_deref()
.is_some_and(static_delegate_barrier_requires_repair);
+ let user_input_required = blocker.detail.as_deref().is_some_and(|detail| {
+ detail
+ .split_whitespace()
+ .find_map(|part| part.strip_prefix("userInputRequired="))
+ .and_then(|value| value.parse::().ok())
+ .is_some_and(|count| count > 0)
+ });
runtime.status = "running".to_string();
- if repair_required {
+ if user_input_required {
+ let deliveries = match claimed_static_delegate_deliveries_at(
+ &root,
+ &runtime.agent_id,
+ &runtime.run_id,
+ ) {
+ Ok(deliveries) => deliveries,
+ Err(error) => {
+ return fail_game_creator_agent_background_context_at(
+ &root,
+ &agent_id,
+ &session_id,
+ runtime,
+ &format!("读取 needs-user-input 回执失败:{error}"),
+ );
+ }
+ };
+ if let Err(error) = ensure_static_delegate_user_input_wait_at(
+ &root,
+ &mut runtime,
+ &deliveries,
+ ) {
+ return fail_game_creator_agent_background_context_at(
+ &root,
+ &agent_id,
+ &session_id,
+ runtime,
+ &format!("Supervisor 用户澄清请求无法安全进入等待态:{error}"),
+ );
+ }
+ return AgentBackgroundTaskOutcome::WaitingForUserInput;
+ } else if repair_required {
runtime.phase = "planning".to_string();
runtime.current_action = "等待 Project Supervisor 发起唯一返工".to_string();
runtime.waiting_on =
@@ -3761,7 +3818,10 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
{
return AgentBackgroundTaskOutcome::NeedsReconciliation;
}
- Err(_) if final_reply_fallback.is_some() => {
+ Err(error)
+ if final_reply_fallback.is_some()
+ && game_creator_agent_final_reply_error_allows_fallback(&error) =>
+ {
final_reply_fallback.expect("checked final reply fallback")
}
Err(error) => {
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs
index df9c4e0a5..e02eacbd6 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs
@@ -387,12 +387,13 @@ fn game_chat_main_without_asset_audit_fixture(root: &Path) -> String {
async fn game_chat_main_agent_delegates_only_real_missing_art_and_limits_children_to_assets() {
let temporary = tempfile::tempdir().expect("create game-chat art child root");
let root = temporary.path().join("project");
- let (_main, mut child, _delegation_id, _child_lane) = game_chat_main_art_child_fixture_with_lane(
- &root,
- "art-asset-plan",
- &["core-spritesheet"],
- true,
- );
+ let (_main, mut child, _delegation_id, _child_lane) =
+ game_chat_main_art_child_fixture_with_lane(
+ &root,
+ "art-asset-plan",
+ &["core-spritesheet"],
+ true,
+ );
assert_eq!(child.agent_id, "art-asset-plan");
assert_eq!(child.source, "agent-delegate");
assert_eq!(child.parent_agent_id.as_deref(), Some("code-prototype"));
@@ -3294,6 +3295,36 @@ fn plan_response_precedes_autonomous_supervisor_deterministic_fallback() {
);
}
+#[test]
+fn autonomous_final_reply_fallback_only_accepts_safe_response_shape_failures() {
+ let fingerprint = "a".repeat(64);
+ for allowed in ["empty-response", "deserialize"] {
+ assert!(game_creator_agent_final_reply_error_allows_fallback(
+ &format!("后台 Agent 最终回复调用 LLM 失败:kind={allowed} fingerprint={fingerprint} chars=12")
+ ));
+ }
+ for rejected in [
+ "codex-app-server-unauthorized",
+ "codex-app-server-usage-limit-exceeded",
+ "codex-app-server-context-window-exceeded",
+ "codex-app-server-cyber-policy",
+ "codex-app-server-sandbox-error",
+ "invalid-config",
+ "transport",
+ "upstream-503",
+ ] {
+ assert!(
+ !game_creator_agent_final_reply_error_allows_fallback(&format!(
+ "后台 Agent 最终回复调用 LLM 失败:kind={rejected} fingerprint={fingerprint} chars=12"
+ )),
+ "final reply fallback must reject {rejected}"
+ );
+ }
+ assert!(!game_creator_agent_final_reply_error_allows_fallback(
+ "上游自由文本kind=deserialize fingerprint=private"
+ ));
+}
+
#[tokio::test]
async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallback_once() {
const RUN_ID: &str = "autonomous-final-reply-fallback-run";
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs
index 805214caa..7e130e4a5 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs
@@ -787,7 +787,9 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at(
can_repair_terminal_receipt = true;
}
if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT {
- if runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD {
+ if runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
+ && !static_delegate_clarification_pending_matches_delivery_at(root, &pending)?
+ {
let _ = cancel_game_creator_agent_user_input_request_for_pending_at(root, &pending);
mark_game_creator_agent_runtime_needs_reconciliation_at(
root,
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 1c2f55a5e..3b6af0f9d 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
@@ -321,6 +321,92 @@ pub(crate) fn schedule_waiting_static_delegate_parent_wake_after_lane_release(
});
}
+/// Convert a claimed `needs-user-input` delivery into the Supervisor's own
+/// durable user-input action. The child never owns this action: it is tied to
+/// the parent run and therefore passes the normal user-input owner gate.
+pub(crate) fn ensure_static_delegate_user_input_wait_at(
+ root: &Path,
+ runtime: &mut AgentRuntimeState,
+ deliveries: &[StaticDelegateDeliveryRecord],
+) -> Result {
+ let mut pending_deliveries = deliveries.iter().filter(|delivery| {
+ delivery.structured_result.as_ref().is_some_and(|result| {
+ result.contract_status == StaticDelegateContractStatus::NeedsUserInput
+ }) && delivery.clarification_answers_sha256.is_none()
+ });
+ let Some(delivery) = pending_deliveries.next() else {
+ return Ok(false);
+ };
+ // Each durable request belongs to exactly one original delivery. Other
+ // deliveries remain behind the completion barrier and are asked next.
+ let result = delivery
+ .structured_result
+ .as_ref()
+ .ok_or_else(|| "needs-user-input delivery 缺少 structured result".to_string())?;
+ let questions = result.user_input_questions.clone();
+ let action = AgentRuntimeToolAction {
+ tool: GAME_CREATOR_USER_INPUT_REQUEST_TOOL.to_string(),
+ reason: Some("代 Supervisor 汇总子 Agent 的澄清问题".to_string()),
+ input: serde_json::json!({"questions": questions}),
+ };
+ let question_binding = game_creator_agent_user_input_action_input_summary(&action.input)
+ .unwrap_or_else(|| "questionsSha256=unavailable".to_string());
+ let task = format!(
+ "子 Agent 需要用户澄清后才能继续。delegationId={};{question_binding}。请回答以下问题;回答完成后只创建一次 agent.delegate continuation,并将 repairOfDelegationId 与 continuationOfDelegationId 指向该原 delegation,同时提交 questionsSha256/answersSha256。",
+ delivery.delegation_id
+ );
+ // Re-entry after a wake or restart may only reuse the exact request that
+ // belongs to this delivery; an unrelated Supervisor question must not mask it.
+ if let Ok(existing) = read_game_creator_agent_runtime_pending_tool_action(
+ root,
+ &runtime.agent_id,
+ &runtime.run_id,
+ ) {
+ if existing.action.tool == GAME_CREATOR_USER_INPUT_REQUEST_TOOL
+ && matches!(
+ existing.status.as_str(),
+ AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT
+ | AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED
+ )
+ {
+ if existing.action.input != action.input || existing.task != task {
+ return Err(
+ "当前 Supervisor 用户输入 pending 与 needs-user-input delivery 身份冲突"
+ .to_string(),
+ );
+ }
+ return Ok(
+ existing.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT
+ );
+ }
+ }
+ let plan = AgentRuntimeToolPlan {
+ thinking_summary: "汇总子 Agent 澄清问题并等待用户回答".to_string(),
+ plan: vec!["等待用户回答后创建唯一 continuation 委派".to_string()],
+ actions: Vec::new(),
+ response: String::new(),
+ plan_update: None,
+ };
+ let repository_context_fingerprint = build_repository_startup_context_at(root)?.fingerprint;
+ let project_revision = read_game_creator_agent_runtime_project_revision(root)?;
+ let mut pending = build_game_creator_agent_runtime_pending_tool_action(
+ root,
+ runtime,
+ &task,
+ &plan,
+ &[],
+ &project_revision,
+ &repository_context_fingerprint,
+ &action,
+ 0,
+ AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
+ AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT,
+ None,
+ )?;
+ persist_game_creator_agent_user_input_wait_at(root, runtime, &mut pending)?;
+ Ok(true)
+}
+
pub(in crate::agent) async fn drive_waiting_static_delegate_parent_wake_pass(
root: &Path,
agent_id: &str,
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/acceptance_graph.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/acceptance_graph.rs
index 4abdc0db3..e59e42a88 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/acceptance_graph.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/acceptance_graph.rs
@@ -1350,6 +1350,50 @@ mod tests {
&contract_plan,
)
.expect("sole goal contract action is allowed");
+ let contract_with_extra_action = AgentRuntimeToolPlan {
+ actions: vec![action("agent.goal_contract"), action("file.list")],
+ ..AgentRuntimeToolPlan::default()
+ };
+ assert!(validate_root_goal_contract_control_plan_at(
+ &root,
+ &binding.agent_id,
+ &binding.run_id,
+ &contract_with_extra_action,
+ )
+ .expect_err("extra action before Goal Contract must fail")
+ .contains("必须先"));
+ let contract_with_plan_update = AgentRuntimeToolPlan {
+ actions: vec![action("agent.goal_contract")],
+ plan_update: Some(AgentRuntimePlanUpdate {
+ explanation: "不应与合同同轮".to_string(),
+ steps: vec![AgentRuntimePlanUpdateStep {
+ step: "不应先规划".to_string(),
+ status: "in_progress".to_string(),
+ }],
+ }),
+ ..AgentRuntimeToolPlan::default()
+ };
+ assert!(validate_root_goal_contract_control_plan_at(
+ &root,
+ &binding.agent_id,
+ &binding.run_id,
+ &contract_with_plan_update,
+ )
+ .expect_err("plan update before Goal Contract must fail")
+ .contains("必须先"));
+ let contract_with_response = AgentRuntimeToolPlan {
+ actions: vec![action("agent.goal_contract")],
+ response: "不应先回复".to_string(),
+ ..AgentRuntimeToolPlan::default()
+ };
+ assert!(validate_root_goal_contract_control_plan_at(
+ &root,
+ &binding.agent_id,
+ &binding.run_id,
+ &contract_with_response,
+ )
+ .expect_err("response before Goal Contract must fail")
+ .contains("必须先"));
let contract_with_legacy_plan = AgentRuntimeToolPlan {
actions: vec![action("agent.goal_contract")],
plan: vec!["先执行旧式计划".to_string()],
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs
index 0a15a6860..ef3faa7bc 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs
@@ -204,6 +204,40 @@ pub(in crate::agent) fn game_creator_agent_runtime_failure_conversation_message(
} else {
"专业 Agent"
};
+ let public_kind_prefix = "kind=codex-app-server-";
+ let codex_error_kind = error
+ .rfind(public_kind_prefix)
+ .map(|start| &error[start + public_kind_prefix.len()..])
+ .or_else(|| {
+ error
+ .rfind(GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX)
+ .map(|start| {
+ &error[start + GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX.len()..]
+ })
+ })
+ .map(|kind| {
+ kind.chars()
+ .take_while(|character| character.is_ascii_lowercase() || *character == '-')
+ .collect::()
+ })
+ .filter(|kind| !kind.is_empty());
+ if let Some(kind) = codex_error_kind {
+ let detail = match kind
+ .trim_matches(|character: char| !character.is_ascii_lowercase() && character != '-')
+ {
+ "context-window-exceeded" => "模型上下文已超限,请缩小任务范围后重试",
+ "session-budget-exceeded" => "本次会话预算已耗尽,请缩小任务范围或新建任务",
+ "usage-limit-exceeded" => "Codex 用量已达上限,请检查账户额度后重试",
+ "unauthorized" => "Codex 鉴权失败,请重新登录或检查 API Key",
+ "bad-request" => "Codex 请求无效,请检查模型与运行时配置",
+ "cyber-policy" => "Codex 安全策略拒绝了本次请求,请调整任务内容",
+ "sandbox-error" => "Codex 隔离环境启动失败,请重试或检查本机环境",
+ "thread-rollback-failed" => "Codex 会话恢复失败,请新建任务后重试",
+ "active-turn-not-steerable" => "当前 Codex 任务无法追加指令,请等待结束后重试",
+ _ => "Codex 执行失败,请查看运行详情后重试",
+ };
+ return format!("{subject} {detail}");
+ }
if let Some((http_status, retry_attempt, max_retries)) =
game_creator_agent_runtime_exhausted_upstream_retry_fields(error)
{
@@ -1956,4 +1990,43 @@ mod tests {
assert!(!unrelated_visible.contains("absolute-path"));
assert!(!unrelated_visible.contains("redacted-secret"));
}
+
+ #[test]
+ fn codex_app_server_failure_kind_has_actionable_safe_public_summary() {
+ let private_error = format!(
+ "agentLlm.code-prototype 调用 LLM 失败:kind=codex-app-server-context-window-exceeded fingerprint={} chars=999",
+ "a".repeat(64)
+ );
+ assert_eq!(
+ game_creator_agent_runtime_failure_conversation_message(
+ "code-prototype",
+ &private_error,
+ ),
+ "专业 Agent 模型上下文已超限,请缩小任务范围后重试"
+ );
+ let unauthorized = format!(
+ "kind=codex-app-server-unauthorized fingerprint={} chars=32",
+ "b".repeat(64)
+ );
+ assert_eq!(
+ game_creator_agent_runtime_failure_conversation_message(
+ GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
+ &unauthorized,
+ ),
+ "项目总控 Agent Codex 鉴权失败,请重新登录或检查 API Key"
+ );
+ for visible in [
+ game_creator_agent_runtime_failure_conversation_message(
+ "code-prototype",
+ &private_error,
+ ),
+ game_creator_agent_runtime_failure_conversation_message(
+ GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
+ &unauthorized,
+ ),
+ ] {
+ assert!(!visible.contains("fingerprint"));
+ assert!(!visible.contains("chars="));
+ }
+ }
}
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 820ea4827..52aa4a66d 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
@@ -2336,12 +2336,18 @@ pub(super) fn try_open_game_creator_agent_runtime_task_lock_file(
if let Some(component) = component {
current.push(component);
if !current.exists() {
- fs::create_dir(¤t).map_err(|error| {
- format!(
- "创建 Agent Runtime 锁目录失败:{}: {error}",
- current.display()
- )
- })?;
+ if let Err(error) = fs::create_dir(¤t) {
+ // 另一并发锁请求可能在 exists 与 create_dir 之间创建同一目录;
+ // 下方元数据检查仍是权威校验,并会拒绝普通文件或 reparse point。
+ if error.kind() != std::io::ErrorKind::AlreadyExists
+ && error.raw_os_error() != Some(183)
+ {
+ return Err(format!(
+ "创建 Agent Runtime 锁目录失败:{}: {error}",
+ current.display()
+ ));
+ }
+ }
}
}
let metadata = fs::symlink_metadata(¤t).map_err(|error| {
@@ -2599,6 +2605,21 @@ pub(super) fn append_game_creator_agent_runtime_event_with_action(
)
})?;
}
+ let failure_detail = matches!(
+ event_type,
+ "error" | "turn.failed" | "turn.budget_exhausted"
+ )
+ .then(|| {
+ detail.map(|value| game_creator_agent_runtime_public_failure_detail(&state.agent_id, value))
+ })
+ .flatten();
+ let public_text = if matches!(event_type, "turn.failed" | "turn.budget_exhausted") {
+ failure_detail
+ .clone()
+ .or_else(|| game_creator_agent_runtime_public_event_text(root, event_type, summary))
+ } else {
+ game_creator_agent_runtime_public_event_text(root, event_type, summary)
+ };
let event = AgentRuntimeEvent {
schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(),
agent_id: state.agent_id.clone(),
@@ -2612,7 +2633,7 @@ pub(super) fn append_game_creator_agent_runtime_event_with_action(
status: status.to_string(),
phase: phase.to_string(),
summary: summary.to_string(),
- public_text: game_creator_agent_runtime_public_event_text(root, event_type, summary),
+ public_text,
detail: detail
.filter(|_| {
!(event_type == "observation"
@@ -2624,10 +2645,9 @@ pub(super) fn append_game_creator_agent_runtime_event_with_action(
event_type,
"error" | "turn.failed" | "turn.budget_exhausted"
) {
- return game_creator_agent_runtime_public_failure_detail(
- &state.agent_id,
- value,
- );
+ return failure_detail.clone().unwrap_or_else(|| {
+ game_creator_agent_runtime_public_failure_detail(&state.agent_id, value)
+ });
}
let max_chars = if event_type == "observation"
&& summary.starts_with("agent.action_history:")
@@ -4115,6 +4135,10 @@ pub(super) fn redact_agent_runtime_project_paths_raw(root: &Path, value: &str) -
let root_display = root.to_string_lossy();
if !root_display.is_empty() {
redacted = redacted.replace(root_display.as_ref(), "$PROJECT_ROOT");
+ #[cfg(windows)]
+ if let Some(non_verbatim_root) = root_display.strip_prefix(r"\\?\") {
+ redacted = redacted.replace(non_verbatim_root, "$PROJECT_ROOT");
+ }
}
if let Ok(canonical_root) = root.canonicalize() {
let canonical_display = canonical_root.to_string_lossy();
@@ -4134,6 +4158,10 @@ pub(super) fn redact_agent_runtime_project_paths_preserving_tail(
let root_display = root.to_string_lossy();
if !root_display.is_empty() {
redacted = redacted.replace(root_display.as_ref(), "$PROJECT_ROOT");
+ #[cfg(windows)]
+ if let Some(non_verbatim_root) = root_display.strip_prefix(r"\\?\") {
+ redacted = redacted.replace(non_verbatim_root, "$PROJECT_ROOT");
+ }
}
if let Ok(canonical_root) = root.canonicalize() {
let canonical_display = canonical_root.to_string_lossy();
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 ed846969b..c8327c490 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
@@ -34,6 +34,8 @@ pub(in crate::agent) use project_ops::*;
pub(in crate::agent) use run_status::*;
pub(in crate::agent) use task_ops::*;
+#[cfg(test)]
+pub(crate) use delivery::build_static_delegate_result_for_child_at;
#[cfg(test)]
pub(crate) use media::validate_agent_runtime_canvas_replacement_authorization_at;
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs
index 9b4f7256f..165830cec 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs
@@ -545,6 +545,24 @@ pub(crate) fn observe_agent_runtime_agent_delegate(
detail: None,
};
}
+ let clarification_continuation_identity =
+ match validate_static_delegate_clarification_continuation_at(
+ root,
+ agent_id,
+ parent_run_id,
+ input,
+ repair_of_delegation_id.as_deref(),
+ ) {
+ Ok(identity) => identity,
+ Err(error) => {
+ return AgentRuntimeToolObservation {
+ tool: "agent.delegate".to_string(),
+ status: "failed".to_string(),
+ summary: sanitize_agent_runtime_text(&error, 240),
+ detail: None,
+ }
+ }
+ };
if let Err(error) =
validate_publish_delegate_run_profile_at(root, agent_id, parent_run_id, &target_agent_id)
{
@@ -582,8 +600,15 @@ pub(crate) fn observe_agent_runtime_agent_delegate(
detail: None,
};
}
- let delegation_id =
- agent_runtime_delegation_id(agent_id, parent_run_id, &target_agent_id, &action_identity);
+ let delegation_action_identity = clarification_continuation_identity
+ .as_deref()
+ .unwrap_or(action_identity.as_str());
+ let delegation_id = agent_runtime_delegation_id(
+ agent_id,
+ parent_run_id,
+ &target_agent_id,
+ delegation_action_identity,
+ );
let delegated_task = match render_static_delegate_task_contract(
&task,
agent_id,
@@ -736,7 +761,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate(
agent_id,
parent_session_id,
parent_run_id,
- &action_identity,
+ delegation_action_identity,
&delegation_id,
&target_agent_id,
&existing.target_session_id,
@@ -826,7 +851,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate(
agent_id,
parent_session_id,
parent_run_id,
- &action_identity,
+ delegation_action_identity,
&delegation_id,
&target_agent_id,
&existing.session_id,
@@ -992,7 +1017,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate(
agent_id,
parent_session_id,
parent_run_id,
- &action_identity,
+ delegation_action_identity,
&delegation_id,
&target_agent_id,
&target_session_id,
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 8f6a80e06..a4bbf91db 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
@@ -94,7 +94,7 @@ pub(in crate::agent) fn validate_static_delegate_delivery_for_child_result(
Ok(())
}
-pub(in crate::agent) fn build_static_delegate_result_for_child_at(
+pub(crate) fn build_static_delegate_result_for_child_at(
root: &Path,
delivery: &StaticDelegateDeliveryRecord,
child_task: &AgentRuntimeTaskRecord,
@@ -110,11 +110,11 @@ pub(in crate::agent) fn build_static_delegate_result_for_child_at(
let verified_revision = (verification_status == Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED))
.then_some(gate.verified_revision)
.flatten();
- let error = child_task
+ let result_detail = child_task
.error
.as_deref()
- .or((terminal_status != "completed").then_some(result_detail));
- let error = error.map(|value| redact_agent_runtime_error(root, value, 500));
+ .or((!result_detail.trim().is_empty()).then_some(result_detail));
+ let result_detail = result_detail.map(|value| redact_agent_runtime_error(root, value, 500));
let mut result = build_static_delegate_structured_result_at(
root,
terminal_status,
@@ -123,7 +123,7 @@ pub(in crate::agent) fn build_static_delegate_result_for_child_at(
verification_status,
gate.last_verification_tool.as_deref(),
verified_revision,
- error.as_deref(),
+ result_detail.as_deref(),
)?;
if verification_status == Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED) {
if let Some(evidence) = result.evidence.first_mut() {
@@ -327,6 +327,16 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at(
{
return Err("静态委派 parent-wake 的父 run 状态身份不一致".to_string());
}
+ if barrier.user_input_required_count > 0 {
+ let deliveries = claimed_static_delegate_deliveries_at(
+ root,
+ ¤t_task.agent_id,
+ ¤t_task.run_id,
+ )?;
+ let mut state = state;
+ ensure_static_delegate_user_input_wait_at(root, &mut state, &deliveries)?;
+ return Ok(true);
+ }
let state = advance_game_creator_agent_runtime_turn_at(
root,
state,
@@ -885,7 +895,7 @@ pub(crate) fn publish_game_creator_agent_delegate_result(
&existing_delivery,
child_task,
terminal_status,
- &safe_result_summary,
+ &result_detail,
) {
Ok(result) => result,
Err(error) => {
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 d31c814da..b58d27a2b 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
@@ -237,6 +237,14 @@ pub(crate) fn observe_agent_runtime_run_status(
.structured_result
.as_ref()
.map(|result| result.contract_status),
+ "needsUserInput": delivery
+ .structured_result
+ .as_ref()
+ .is_some_and(|result| result.contract_status == StaticDelegateContractStatus::NeedsUserInput),
+ "userInputQuestionCount": delivery
+ .structured_result
+ .as_ref()
+ .map(|result| result.user_input_questions.len()),
"acceptanceCriteriaCount": delivery.acceptance_criteria.len(),
"expectedArtifactsCount": delivery.expected_artifacts.len(),
})
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs
index 0c9adc269..6a9e44e4e 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs
@@ -482,6 +482,17 @@ fn validate_native_agent_delegate_input(
"repairOfDelegationId",
"runId",
];
+ const ALLOWED_FIELDS: [&str; 9] = [
+ "agentId",
+ "task",
+ "acceptanceCriteria",
+ "expectedArtifacts",
+ "repairOfDelegationId",
+ "runId",
+ "continuationOfDelegationId",
+ "questionsSha256",
+ "answersSha256",
+ ];
let object = input.as_object().ok_or_else(|| {
protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
@@ -498,7 +509,7 @@ fn validate_native_agent_delegate_input(
}
if object
.keys()
- .any(|field| !REQUIRED_FIELDS.contains(&field.as_str()))
+ .any(|field| !ALLOWED_FIELDS.contains(&field.as_str()))
{
return Err(protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
@@ -528,6 +539,25 @@ fn validate_native_agent_delegate_input(
true,
)?;
validate_native_delegate_string(object.get("runId"), "runId", 160, true)?;
+ if object.contains_key("continuationOfDelegationId") {
+ validate_native_delegate_string(
+ object.get("continuationOfDelegationId"),
+ "continuationOfDelegationId",
+ 160,
+ true,
+ )?;
+ }
+ if object.contains_key("questionsSha256") {
+ validate_native_delegate_string(
+ object.get("questionsSha256"),
+ "questionsSha256",
+ 64,
+ true,
+ )?;
+ }
+ if object.contains_key("answersSha256") {
+ validate_native_delegate_string(object.get("answersSha256"), "answersSha256", 64, true)?;
+ }
if object
.get("repairOfDelegationId")
.is_some_and(Value::is_string)
@@ -538,6 +568,47 @@ fn validate_native_agent_delegate_input(
"Agent 原生工具协议错误:agent.delegate 返工委派时 runId 必须为 JSON null",
));
}
+ let continuation_fields = [
+ "continuationOfDelegationId",
+ "questionsSha256",
+ "answersSha256",
+ ]
+ .iter()
+ .filter(|field| object.get(**field).is_some_and(Value::is_string))
+ .count();
+ let continuation_present = [
+ "continuationOfDelegationId",
+ "questionsSha256",
+ "answersSha256",
+ ]
+ .iter()
+ .filter(|field| object.contains_key(**field))
+ .count();
+ if (continuation_present != 0 && continuation_present != 3)
+ || (continuation_fields != 0 && continuation_fields != 3)
+ {
+ return Err(protocol_error(
+ AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
+ "Agent 原生工具协议错误:agent.delegate 澄清 continuation 字段必须同时提供",
+ ));
+ }
+ for field in ["questionsSha256", "answersSha256"] {
+ if object.get(field).is_some_and(Value::is_string)
+ && object
+ .get(field)
+ .and_then(Value::as_str)
+ .is_none_or(|value| {
+ value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit())
+ })
+ {
+ return Err(protocol_error(
+ AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
+ format!(
+ "Agent 原生工具协议错误:agent.delegate {field} 必须是 64 位十六进制 SHA-256"
+ ),
+ ));
+ }
+ }
Ok(())
}
@@ -1216,14 +1287,17 @@ fn runtime_tool_input_schema(tool: &str) -> Value {
"blackboard.write" => two_string_input_schema("title", "content"),
"agent.message" => two_string_input_schema("agentId", "content"),
"agent.delegate" => json!({
- "type": "object", "required": ["agentId", "task", "acceptanceCriteria", "expectedArtifacts", "repairOfDelegationId", "runId"], "additionalProperties": false,
+ "type": "object", "required": ["agentId", "task", "acceptanceCriteria", "expectedArtifacts", "repairOfDelegationId", "runId", "continuationOfDelegationId", "questionsSha256", "answersSha256"], "additionalProperties": false,
"properties": {
"agentId": { "type": "string", "minLength": 1 },
"task": { "type": "string", "minLength": 1, "maxLength": 2400 },
"acceptanceCriteria": { "type": "array", "minItems": 1, "maxItems": 8, "items": { "type": "string", "minLength": 1, "maxLength": 240 } },
"expectedArtifacts": { "type": "array", "maxItems": 16, "items": { "type": "string", "minLength": 1, "maxLength": 240 } },
"repairOfDelegationId": { "type": ["string", "null"] },
- "runId": { "type": ["string", "null"] }
+ "runId": { "type": ["string", "null"] },
+ "continuationOfDelegationId": { "type": ["string", "null"] },
+ "questionsSha256": { "type": ["string", "null"] },
+ "answersSha256": { "type": ["string", "null"] }
}
}),
"agent.spawn_isolated" => json!({
@@ -1505,9 +1579,68 @@ mod tests {
"expectedArtifacts": [],
"repairOfDelegationId": repair_of_delegation_id,
"runId": run_id,
+ "continuationOfDelegationId": null,
+ "questionsSha256": null,
+ "answersSha256": null,
})
}
+ #[test]
+ fn native_agent_delegate_accepts_complete_clarification_continuation_binding() {
+ let mut input = valid_delegate_input(json!("delegation-id"), Value::Null);
+ let object = input.as_object_mut().expect("delegate input object");
+ object.insert(
+ "continuationOfDelegationId".to_string(),
+ json!("delegation-id"),
+ );
+ object.insert("questionsSha256".to_string(), json!("a".repeat(64)));
+ object.insert("answersSha256".to_string(), json!("b".repeat(64)));
+
+ validate_native_agent_delegate_input(&input)
+ .expect("complete clarification continuation binding");
+ }
+
+ #[test]
+ fn native_agent_delegate_accepts_legacy_input_without_clarification_fields() {
+ let mut input = valid_delegate_input(Value::Null, Value::Null);
+ let object = input.as_object_mut().expect("delegate input object");
+ object.remove("continuationOfDelegationId");
+ object.remove("questionsSha256");
+ object.remove("answersSha256");
+
+ validate_native_agent_delegate_input(&input)
+ .expect("legacy delegate input without clarification fields");
+ }
+
+ #[test]
+ fn native_agent_delegate_rejects_partial_or_invalid_clarification_binding() {
+ let mut partial = valid_delegate_input(json!("delegation-id"), Value::Null);
+ partial
+ .as_object_mut()
+ .expect("delegate input object")
+ .insert(
+ "continuationOfDelegationId".to_string(),
+ json!("delegation-id"),
+ );
+ assert!(validate_native_agent_delegate_input(&partial)
+ .expect_err("partial continuation binding must fail")
+ .to_string()
+ .contains("必须同时提供"));
+
+ let mut invalid_sha = valid_delegate_input(json!("delegation-id"), Value::Null);
+ let object = invalid_sha.as_object_mut().expect("delegate input object");
+ object.insert(
+ "continuationOfDelegationId".to_string(),
+ json!("delegation-id"),
+ );
+ object.insert("questionsSha256".to_string(), json!("z".repeat(64)));
+ object.insert("answersSha256".to_string(), json!("b".repeat(64)));
+ assert!(validate_native_agent_delegate_input(&invalid_sha)
+ .expect_err("invalid continuation sha must fail")
+ .to_string()
+ .contains("SHA-256"));
+ }
+
#[test]
fn native_agent_delegate_repair_rejects_string_run_id() {
let repair_id = "delegation-value-must-not-leak";
diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs
index 309a9456b..086f410d2 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs
@@ -842,7 +842,7 @@ fn result_serializes_with_camel_case_evidence_paths() {
#[test]
fn persisted_report_uses_only_relative_evidence_paths() {
- let evidence_root = PathBuf::from("/tmp/browser-evidence");
+ let evidence_root = std::env::temp_dir().join("browser-evidence");
let result = BrowserValidationResult {
schema_version: RESULT_SCHEMA_VERSION.to_string(),
url: "http://127.0.0.1:34567/".to_string(),
diff --git a/apps/ai-game-creator-shell/src-tauri/src/cli.rs b/apps/ai-game-creator-shell/src-tauri/src/cli.rs
index eec0c244f..076a4f0aa 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs
@@ -807,6 +807,26 @@ fn strip_agent_runtime_cli_private_paths(value: &mut serde_json::Value) {
}
}
+pub(crate) fn start_cli_agent_task_at(
+ project_path: &Path,
+ agent_id: &str,
+ task: &str,
+ run_id: &str,
+) -> Result {
+ if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
+ start_game_creator_supervisor_background_task_for_session_at(
+ project_path,
+ None,
+ task,
+ run_id,
+ AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
+ AGENT_RUNTIME_RUN_PROFILE_STANDARD,
+ )
+ } else {
+ start_game_creator_agent_background_task_at(project_path, agent_id, task, run_id)
+ }
+}
+
fn serialize_agent_runtime_cli_payload(payload: &T) -> Result {
let mut value = serde_json::to_value(payload)
.map_err(|error| format!("序列化 Agent Runtime 状态失败:{error}"))?;
@@ -906,12 +926,7 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> {
.map_err(|error| format!("创建 CLI runtime 失败:{error}"))?;
let run_id = format!("cli-{agent_id}-{}", unix_millis());
let terminal = runtime.block_on(async {
- let started = start_game_creator_agent_background_task_at(
- &project_path,
- &agent_id,
- &task,
- &run_id,
- )?;
+ let started = start_cli_agent_task_at(&project_path, &agent_id, &task, &run_id)?;
let canonical_run_id = started.state.run_id.clone();
let deadline = std::time::Instant::now() + Duration::from_secs(600);
loop {
diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs
index adeec51c4..873662bca 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs
@@ -631,6 +631,14 @@ fn resolve_project_command_executable_from_path(
}
let executable =
executable.ok_or_else(|| format!("command.exec 找不到受信任的 {program} 可执行文件"))?;
+ #[cfg(windows)]
+ let safe_directories = safe_directories
+ .into_iter()
+ .map(|directory| {
+ let directory = directory.to_string_lossy();
+ PathBuf::from(directory.strip_prefix(r"\\?\").unwrap_or(&directory))
+ })
+ .collect::>();
let safe_path = std::env::join_paths(safe_directories)
.map_err(|error| format!("构造 command.exec 安全 PATH 失败:{error}"))?;
Ok((executable, safe_path))
@@ -1149,6 +1157,10 @@ pub(crate) fn prepare_project_command_launch_spec(
),
(OsString::from("CARGO_NET_OFFLINE"), OsString::from("true")),
(OsString::from("CARGO_TERM_COLOR"), OsString::from("never")),
+ // 受控命令不得继承用户级 Cargo rustc-wrapper(例如 sccache);
+ // 隔离 HOME/CARGO_HOME 下这类包装器既不可复现,也可能无法启动。
+ (OsString::from("RUSTC_WRAPPER"), OsString::new()),
+ (OsString::from("RUSTC_WORKSPACE_WRAPPER"), OsString::new()),
(OsString::from("npm_config_audit"), OsString::from("false")),
(OsString::from("npm_config_fund"), OsString::from("false")),
(
@@ -1213,10 +1225,18 @@ pub(crate) fn prepare_project_command_launch_spec(
environment.push((
OsString::from("ComSpec"),
PathBuf::from(system_root)
- .join("System32/cmd.exe")
+ .join("System32")
+ .join("cmd.exe")
.into_os_string(),
));
}
+ #[cfg(windows)]
+ for (_, value) in &mut environment {
+ let rendered = value.to_string_lossy();
+ if let Some(without_prefix) = rendered.strip_prefix(r"\\?\") {
+ *value = OsString::from(without_prefix);
+ }
+ }
let arguments = project_command_actual_arguments(spec);
#[cfg(target_os = "linux")]
@@ -1247,10 +1267,71 @@ pub(crate) fn prepare_project_command_launch_spec(
}
#[cfg(not(target_os = "linux"))]
{
+ #[cfg(windows)]
+ let (executable, arguments, cwd) = {
+ fn without_windows_verbatim_prefix(path: PathBuf) -> PathBuf {
+ let value = path.to_string_lossy();
+ PathBuf::from(value.strip_prefix(r"\\?\").unwrap_or(&value))
+ }
+ let is_npm_batch = spec
+ .executable
+ .file_name()
+ .and_then(|name| name.to_str())
+ .is_some_and(|name| name.eq_ignore_ascii_case("npm.cmd"));
+ if is_npm_batch {
+ let npm_directory = spec.executable.parent().ok_or_else(|| {
+ ProjectCommandError::new(
+ ProjectCommandErrorStage::Preflight,
+ "command.exec 无法定位 Windows npm 安装目录",
+ )
+ })?;
+ let node_executable = npm_directory.join("node.exe");
+ let npm_cli = npm_directory.join("node_modules/npm/bin/npm-cli.js");
+ if !node_executable.is_file() || !npm_cli.is_file() {
+ return Err(ProjectCommandError::new(
+ ProjectCommandErrorStage::Preflight,
+ "command.exec Windows npm 安装缺少 node.exe 或 npm-cli.js",
+ ));
+ }
+ let node_executable = fs::canonicalize(node_executable).map_err(|error| {
+ ProjectCommandError::new(
+ ProjectCommandErrorStage::Preflight,
+ format!("command.exec 定位 Windows node.exe 失败:{error}"),
+ )
+ })?;
+ let npm_cli = fs::canonicalize(npm_cli).map_err(|error| {
+ ProjectCommandError::new(
+ ProjectCommandErrorStage::Preflight,
+ format!("command.exec 定位 Windows npm-cli.js 失败:{error}"),
+ )
+ })?;
+ let node_executable = without_windows_verbatim_prefix(node_executable);
+ let npm_cli = without_windows_verbatim_prefix(npm_cli);
+ let mut node_arguments = vec![npm_cli.into_os_string()];
+ node_arguments.extend(arguments.into_iter().map(OsString::from));
+ (
+ node_executable,
+ node_arguments,
+ without_windows_verbatim_prefix(spec.cwd.clone()),
+ )
+ } else {
+ (
+ without_windows_verbatim_prefix(spec.executable.clone()),
+ arguments.into_iter().map(OsString::from).collect(),
+ without_windows_verbatim_prefix(spec.cwd.clone()),
+ )
+ }
+ };
+ #[cfg(not(windows))]
+ let (executable, arguments, cwd) = (
+ spec.executable.clone(),
+ arguments.into_iter().map(OsString::from).collect(),
+ spec.cwd.clone(),
+ );
Ok(ProjectCommandLaunchSpec {
- executable: spec.executable.clone(),
- arguments: arguments.into_iter().map(OsString::from).collect(),
- cwd: spec.cwd.clone(),
+ executable,
+ arguments,
+ cwd,
environment,
sandbox_backend: "legacy-host-restricted".to_string(),
sandbox_mode: "fixed-command".to_string(),
@@ -1308,7 +1389,10 @@ pub(crate) fn stage_project_command_launch_spec(
}
}
-fn configure_project_command_process_group(command: &mut tokio::process::Command) {
+fn configure_project_command_process_group(
+ command: &mut tokio::process::Command,
+ launch: &ProjectCommandLaunchSpec,
+) {
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
@@ -1316,6 +1400,13 @@ fn configure_project_command_process_group(command: &mut tokio::process::Command
}
#[cfg(windows)]
{
+ let npm_cli_host = launch
+ .arguments
+ .first()
+ .and_then(|argument| Path::new(argument).file_name())
+ .and_then(|name| name.to_str())
+ .is_some_and(|name| name.eq_ignore_ascii_case("npm-cli.js"));
+ let _ = npm_cli_host;
crate::configure_windows_background_tokio_command(command, true);
}
}
@@ -1347,7 +1438,7 @@ where
for (name, value) in &staged.launch.environment {
command.env(name, value);
}
- configure_project_command_process_group(&mut command);
+ configure_project_command_process_group(&mut command, &staged.launch);
#[cfg(target_os = "linux")]
staged
.gate
@@ -1803,6 +1894,12 @@ where
Err(_) => {
let termination = match terminate_project_command_process_group(&mut child).await {
Ok(termination) => termination,
+ #[cfg(windows)]
+ Err(error) if child.try_wait().ok().flatten().is_some() => {
+ format!(
+ "请求终止受控进程组后主进程已回收(taskkill 未找到已退出进程:{error})"
+ )
+ }
Err(error) => {
stdout_task.abort();
stderr_task.abort();
@@ -2144,7 +2241,13 @@ mod tests {
"expected rejection for {program} {args:?}"
);
}
- let absolute = vec!["test".to_string(), "/tmp/outside.rs".to_string()];
+ let absolute = vec![
+ "test".to_string(),
+ std::env::temp_dir()
+ .join("outside.rs")
+ .to_string_lossy()
+ .into_owned(),
+ ];
assert!(resolve_project_command_spec_at(root, "cargo", &absolute, ".", 30).is_err());
let sensitive = vec!["status".to_string(), ".agent/agent.db".to_string()];
assert!(resolve_project_command_spec_at(root, "git", &sensitive, ".", 30).is_err());
@@ -2512,6 +2615,18 @@ raise SystemExit(code)'
);
}
+ #[cfg(windows)]
+ #[test]
+ fn project_command_safe_path_uses_win32_compatible_directories() {
+ let dir = command_project("windows-safe-path");
+ let raw_path = std::env::var_os("PATH").expect("PATH");
+ let (_, safe_path) =
+ resolve_project_command_executable_from_path(dir.path(), "node", &raw_path)
+ .expect("resolve node executable");
+ assert!(std::env::split_paths(&safe_path)
+ .all(|directory| { !directory.as_os_str().to_string_lossy().starts_with(r"\\?\") }));
+ }
+
#[cfg(not(target_os = "linux"))]
#[test]
fn project_command_injects_git_safety_options_before_pathspec_separator() {
diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs
index 27f8ad432..04ce73338 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/config.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs
@@ -368,23 +368,12 @@ pub(crate) fn game_creator_codex_app_server_llm_route_error(
}
pub(crate) fn check_game_creator_codex_cli_available() -> Result<(), String> {
- let output = std::process::Command::new("codex")
- .arg("--version")
- .stdin(std::process::Stdio::null())
- .output()
- .map_err(|_| "Codex CLI 未安装或不在当前客户端 PATH 中".to_string())?;
- if !output.status.success() {
- return Err("Codex CLI 版本检查失败".to_string());
- }
- let version = String::from_utf8_lossy(&output.stdout);
- if !version.trim().starts_with("codex-cli ") {
- return Err("Codex CLI 返回了无法识别的版本信息".to_string());
- }
- Ok(())
+ crate::agent::game_creator_codex_cli_version_identity().map(|_| ())
}
fn check_game_creator_codex_app_server_available() -> Result<(), String> {
- let output = std::process::Command::new("codex")
+ let executable = crate::agent::game_creator_codex_cli_executable_path()?;
+ let output = std::process::Command::new(executable)
.args(["app-server", "--help"])
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
@@ -860,6 +849,11 @@ pub(crate) fn windows_private_dacl_security_information(
} else {
0
}
+ | if initialize_owner && !owner_matches {
+ OWNER_SECURITY_INFORMATION
+ } else {
+ 0
+ }
}
#[cfg(windows)]
@@ -1251,7 +1245,10 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy(
pub(crate) fn configure_game_creator_runtime_config_dir(
app: &tauri::AppHandle,
) -> Result<(), Box> {
- let config_dir = prepare_game_creator_runtime_config_dir(&app.path().app_config_dir()?)
+ let requested_config_dir = game_creator_runtime_config_dir()
+ .map(Ok)
+ .unwrap_or_else(|| app.path().app_config_dir())?;
+ let config_dir = prepare_game_creator_runtime_config_dir(&requested_config_dir)
.map_err(std::io::Error::other)?;
let config_path = config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME);
if !config_path.exists() {
diff --git a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs
index 365b5ae72..d726b825b 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs
@@ -13,6 +13,8 @@ const STATIC_DELEGATE_ACCEPTANCE_CRITERION_MAX_CHARS: usize = 240;
const STATIC_DELEGATE_MAX_EXPECTED_ARTIFACTS: usize = 16;
const STATIC_DELEGATE_EXPECTED_ARTIFACT_MAX_CHARS: usize = 240;
const STATIC_DELEGATE_MAX_EVIDENCE: usize = 16;
+const STATIC_DELEGATE_USER_INPUT_PREFIX: &str = "AGC_NEEDS_USER_INPUT_V1\n";
+const STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS: usize = 500;
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
@@ -28,6 +30,7 @@ pub(crate) enum StaticDelegateDeliveryStatus {
pub(crate) enum StaticDelegateContractStatus {
EvidenceReady,
NeedsRepair,
+ NeedsUserInput,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -64,6 +67,26 @@ pub(crate) struct StaticDelegateStructuredResult {
pub(crate) evidence: Vec,
#[serde(default)]
pub(crate) error: Option,
+ #[serde(default)]
+ pub(crate) user_input_questions: Vec,
+ #[serde(default)]
+ pub(crate) user_input_questions_sha256: Option,
+}
+
+impl Default for StaticDelegateStructuredResult {
+ fn default() -> Self {
+ Self {
+ contract_status: StaticDelegateContractStatus::NeedsRepair,
+ artifacts: Vec::new(),
+ missing_expected_artifacts: Vec::new(),
+ verification_required: false,
+ verified_revision: None,
+ evidence: Vec::new(),
+ error: None,
+ user_input_questions: Vec::new(),
+ user_input_questions_sha256: None,
+ }
+ }
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -84,6 +107,10 @@ pub(crate) struct StaticDelegateDeliveryRecord {
pub(crate) expected_artifacts: Vec,
#[serde(default)]
pub(crate) repair_of_delegation_id: Option,
+ #[serde(default)]
+ pub(crate) clarification_request_id: Option,
+ #[serde(default)]
+ pub(crate) clarification_answers_sha256: Option,
pub(crate) status: StaticDelegateDeliveryStatus,
pub(crate) terminal_status: Option,
pub(crate) result_summary: Option,
@@ -136,6 +163,7 @@ pub(crate) struct StaticDelegateCompletionBarrier {
pub(crate) ready_unclaimed_count: usize,
pub(crate) unobserved_claim_count: usize,
pub(crate) repair_required_count: usize,
+ pub(crate) user_input_required_count: usize,
}
impl StaticDelegateCompletionBarrier {
@@ -144,6 +172,7 @@ impl StaticDelegateCompletionBarrier {
&& self.ready_unclaimed_count == 0
&& self.unobserved_claim_count == 0
&& self.repair_required_count == 0
+ && self.user_input_required_count == 0
}
pub(crate) fn has_waiting(self) -> bool {
@@ -152,11 +181,12 @@ impl StaticDelegateCompletionBarrier {
pub(crate) fn detail(self) -> String {
format!(
- "waitingDelegations={} · readyUnclaimedReceipts={} · unobservedReceiptClaims={} · repairRequired={} · 必须认领专业 Agent 回执,并对 needs-repair 原委派发起唯一返工后再继续",
+ "waitingDelegations={} · readyUnclaimedReceipts={} · unobservedReceiptClaims={} · repairRequired={} · userInputRequired={} · 必须认领专业 Agent 回执,并处理 needs-user-input 或对 needs-repair 原委派发起唯一返工后再继续",
self.waiting_count,
self.ready_unclaimed_count,
self.unobserved_claim_count,
- self.repair_required_count
+ self.repair_required_count,
+ self.user_input_required_count
)
}
}
@@ -213,6 +243,8 @@ pub(crate) fn new_static_delegate_delivery_with_contract(
acceptance_criteria: acceptance_criteria.to_vec(),
expected_artifacts: expected_artifacts.to_vec(),
repair_of_delegation_id: repair_of_delegation_id.map(str::to_string),
+ clarification_request_id: None,
+ clarification_answers_sha256: None,
status: StaticDelegateDeliveryStatus::Dispatched,
terminal_status: None,
result_summary: None,
@@ -286,6 +318,8 @@ pub(crate) fn mark_static_delegate_delivery_ready_at(
verified_revision: None,
evidence: Vec::new(),
error: (terminal_status != "completed").then(|| result_summary.to_string()),
+ user_input_questions: Vec::new(),
+ user_input_questions_sha256: None,
};
mark_static_delegate_delivery_ready_with_result_at(
root,
@@ -434,6 +468,8 @@ pub(crate) fn static_delegate_completion_barrier_at(
&& !legacy_empty_contract
&& delivery.structured_result.as_ref().is_some_and(|result| {
result.contract_status == StaticDelegateContractStatus::NeedsRepair
+ || (result.contract_status == StaticDelegateContractStatus::NeedsUserInput
+ && delivery.clarification_answers_sha256.is_some())
})
&& !deliveries.iter().any(|candidate| {
candidate.repair_of_delegation_id.as_deref()
@@ -447,6 +483,26 @@ pub(crate) fn static_delegate_completion_barrier_at(
})
})
.count();
+ barrier.user_input_required_count = deliveries
+ .iter()
+ .filter(|delivery| {
+ delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent
+ && delivery.structured_result.as_ref().is_some_and(|result| {
+ result.contract_status == StaticDelegateContractStatus::NeedsUserInput
+ })
+ && delivery.clarification_answers_sha256.is_none()
+ && !deliveries.iter().any(|candidate| {
+ candidate.repair_of_delegation_id.as_deref()
+ == Some(delivery.delegation_id.as_str())
+ && matches!(
+ candidate.status,
+ StaticDelegateDeliveryStatus::Dispatched
+ | StaticDelegateDeliveryStatus::Ready
+ | StaticDelegateDeliveryStatus::ClaimedByParent
+ )
+ })
+ })
+ .count();
Ok(barrier)
}
@@ -1076,6 +1132,207 @@ pub(crate) fn validate_static_delegate_repair_request_at(
Ok(())
}
+pub(crate) fn validate_static_delegate_clarification_continuation_at(
+ root: &Path,
+ parent_agent_id: &str,
+ parent_run_id: &str,
+ input: &serde_json::Value,
+ repair_of_delegation_id: Option<&str>,
+) -> Result
))}
>
diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx
index 7cc9d262d..220a31edf 100644
--- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx
+++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx
@@ -13,7 +13,10 @@ import type {
PendingUiConfirmation,
} from '../../app/types';
import {
+ projectProfessionalAgentLabel,
+ projectRuntimeVisibleError,
ProjectSupervisorRuntimePanel,
+ projectSupervisorVisibleConversationText,
projectWorkspaceStatusForDisplay,
} from '../agent-runtime';
import { formatAgentCardRuntimeStatus } from '../project-summary/agentPresentation';
@@ -77,7 +80,10 @@ export function ProjectSupervisorView({
key={message.messageId ?? `${message.role}-${index}`}
className={`message message--${message.role}`}
>
- {message.text}
+ {projectSupervisorVisibleConversationText(
+ message.text,
+ message.role,
+ )}
))}
{transientReply ? (
@@ -151,6 +157,15 @@ export function ProjectSupervisorView({
taskStatusLabels[agent.status]}
{agent.runtimeTask ? {agent.runtimeTask} : null}
+ {agent.runtimeError ? (
+
+ {projectRuntimeVisibleError(
+ agent.runtimeError,
+ projectProfessionalAgentLabel(agent.id),
+ true,
+ )}
+
+ ) : null}
))}
diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx
index 0057e4c6a..141a298e4 100644
--- a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx
+++ b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx
@@ -303,7 +303,23 @@ export function formatGameChatStageRecord(
) {
const terminalStatus =
runtime.status === 'failed' || runtime.phase === 'failed'
- ? progress.interruptionText || '本轮失败'
+ ? progress.interruptionText ||
+ (runtime.error
+ ? projectRuntimeVisibleError(
+ runtime.error,
+ '项目总控 Agent',
+ true,
+ )
+ : '本轮失败')
+ : runtime.status === 'needs-reconciliation' ||
+ runtime.phase === 'needs-reconciliation'
+ ? runtime.error
+ ? projectRuntimeVisibleError(
+ runtime.error,
+ '项目总控 Agent',
+ true,
+ )
+ : '项目总控 Agent 运行状态需要核对,请打开运行详情后重试'
: runtime.status === 'cancelled' || runtime.phase === 'cancelled'
? '本轮已取消'
: runtime.status === 'completed' || runtime.phase === 'completed'
@@ -971,7 +987,10 @@ export function SupervisorChatOnlyView({
const running = Boolean(
chatAgentBusy ||
synchronizingAcceptedRun ||
- (runtime && !isAgentRuntimeTerminalState(runtime)),
+ (runtime &&
+ runtime.status !== 'needs-reconciliation' &&
+ runtime.phase !== 'needs-reconciliation' &&
+ !isAgentRuntimeTerminalState(runtime)),
);
const gameChatInterruptionText =
gameChatMode && runtime
@@ -1099,6 +1118,12 @@ export function SupervisorChatOnlyView({
if (runtimeError) {
return '运行异常';
}
+ if (
+ runtime?.status === 'needs-reconciliation' ||
+ runtime?.phase === 'needs-reconciliation'
+ ) {
+ return '待人工核对';
+ }
if (synchronizingAcceptedRun) {
return '正在启动';
}
diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx
index ce52e5f90..df6e2469e 100644
--- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx
+++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx
@@ -1,5 +1,19 @@
-import { Plus, Trash2, Zap } from 'lucide-react';
-import { type FormEvent, Fragment, useEffect, useRef, useState } from 'react';
+import {
+ Bot,
+ Cable,
+ CheckCircle2,
+ CircleAlert,
+ LoaderCircle,
+ Plus,
+ RotateCcw,
+ Save,
+ Settings2,
+ SlidersHorizontal,
+ Trash2,
+ X,
+ Zap,
+} from 'lucide-react';
+import { type FormEvent, useEffect, useRef, useState } from 'react';
import { createGameCreationAppSeedTasks } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
@@ -82,6 +96,40 @@ const runtimeAgentModes = new Set([
'provider',
]);
+type RuntimeSettingsSection = 'general' | 'agents' | 'connections' | 'advanced';
+
+const runtimeSettingsSections = [
+ {
+ id: 'general',
+ label: '常用设置',
+ description: '运行方式与默认模型',
+ icon: Settings2,
+ },
+ {
+ id: 'agents',
+ label: 'Agent 模型',
+ description: '按角色覆盖默认模型',
+ icon: Bot,
+ },
+ {
+ id: 'connections',
+ label: '连接与工具',
+ description: 'MCP 与外部服务',
+ icon: Cable,
+ },
+ {
+ id: 'advanced',
+ label: '高级参数',
+ description: '上下文、超时与重试',
+ icon: SlidersHorizontal,
+ },
+] as const satisfies ReadonlyArray<{
+ id: RuntimeSettingsSection;
+ label: string;
+ description: string;
+ icon: typeof Settings2;
+}>;
+
const defaultRuntimeMcpServerConfig: GameCreatorMcpServerConfig = {
enabled: true,
required: false,
@@ -522,12 +570,10 @@ function normalizeRuntimeConfigDraft(
export function RuntimeConfigDialog({
projectPath,
- showDeveloperEditorApi = false,
onClose,
onLog,
}: {
projectPath?: string;
- showDeveloperEditorApi?: boolean;
onClose: () => void;
onLog?: (entry: string) => void;
}) {
@@ -536,6 +582,9 @@ export function RuntimeConfigDialog({
const [runtimeConfigDraft, setRuntimeConfigDraft] =
useState(defaultRuntimeConfigDraft);
const [runtimeConfigBusy, setRuntimeConfigBusy] = useState(false);
+ const [activeSection, setActiveSection] =
+ useState('general');
+ const [expandedAgentIds, setExpandedAgentIds] = useState([]);
const [newMcpServerId, setNewMcpServerId] = useState('');
const [mcpStructuredDrafts, setMcpStructuredDrafts] = useState<
Record
@@ -860,6 +909,24 @@ export function RuntimeConfigDialog({
}
}
+ const selectedSection =
+ runtimeSettingsSections.find((section) => section.id === activeSection) ??
+ runtimeSettingsSections[0];
+ const configuredAgentCount = Object.values(
+ runtimeConfigDraft.agentLlm ?? {},
+ ).filter((config) =>
+ Object.values(config).some((value) => value !== undefined && value !== ''),
+ ).length;
+ const runtimeConfigStatusTone = runtimeConfigBusy
+ ? 'busy'
+ : /^(已保存|已读取|已恢复默认|已添加|已移除|MCP 已连接)/.test(
+ runtimeConfigStatus,
+ )
+ ? 'success'
+ : runtimeConfigStatus === '未读取'
+ ? 'neutral'
+ : 'warning';
+
return (
closeDialogOnBackdropMouseDown(event, onClose)}
>