This commit is contained in:
2026-08-13 14:13:47 +08:00
104 changed files with 6274 additions and 1608 deletions
+2 -17
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
npm run check:pre-push-master -- "$@"
@@ -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;
}
@@ -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'",
@@ -46,6 +46,8 @@ export function buildProcessSessionFixtureSource({
' if (!echoed && line === challenge) {',
' echoed = true;',
" console.log(echoPrefix + ' ' + challenge);",
" } else if (line === challenge + ':stop') {",
' stop();',
' }',
' }',
'});',
@@ -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',
@@ -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,
];
}
@@ -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、answersSha256Runtime 会自动派生稳定 continuation identity,不得把多个 delivery 的问题或答案混入同一 continuation
@@ -1,5 +1,5 @@
互不重叠的临时并行检查通过 agent.spawn_isolated 分派;当同一目标同时需要边界清晰的专业委派和互不重叠的临时检查时,必须把两类协作放进同一个 native planning 批次一次性提交,不能拆成先后轮次。提交首个协作批次前,先分别完整枚举当前目标中已经生效的长期专业交付和临时隔离检查;两类都非空时,遗漏任一类的批次都不得提交。仓库合同明确把临时检查分为先行和后续独立阶段时,首批只提交当前已经生效的检查;先行组 ready 后优先创建刚生效的后续组,所有必要组创建前不得调用 agent.run_status 认领先行组,全部 ready 后用一次 agent.run_status 收齐。已有委派未收束时不要重复委派。
需要等待专业 Agent 时不得调用 respond_to_userRuntime 会通过 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_userRuntime 会通过 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、answersSha256Runtime 自动派生稳定 continuation identity,禁止跨 delivery 混用指纹。客观或语义不满足时可以发起一次新 agent.delegate,并把 repairOfDelegationId 指向已认领原 delivery;不得对返工再返工或为同一原 delivery 创建第二个返工。专业结果冲突且无法依据用户目标裁决时,合并问题后用一次 user.input_request 询问用户。只有实现路径、产品取舍或缺失事实会实质改变结果时才调用 user.input_request;项目内可读取事实、权限确认和工具失败不得伪装成用户问题。
只在所有必要回执已认领、manifest 正式任务图已经完成、所有必要返工也已认领、项目副作用已验证且没有待确认动作或待回答请求时给用户最终回复。不要向用户暴露内部 task/event、工具计划、动态 child ID 或调试状态。
@@ -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::*;
@@ -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<serde_json::Value, String>;
@@ -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<u16> {
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<CodexAppServerInner>,
detail: impl Into<String>,
@@ -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, platform_llm::LlmError> {
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::<Vec<_>>();
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();
@@ -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<PathBuf> {
let mut candidates = Vec::new();
#[cfg(windows)]
{
fn append_native_npm_candidates(candidates: &mut Vec<PathBuf>, 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::<Vec<_>>();
targets.sort();
candidates.extend(targets);
}
}
fn append_desktop_codex_candidates(candidates: &mut Vec<PathBuf>, 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::<Vec<_>>();
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<PathBuf> {
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<String, String> {
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<PathBuf, String> {
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<String, String> {
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<String, String> {
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<platform_llm::LlmRunResponse, platform_llm::LlmError> {
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");
@@ -2265,7 +2265,19 @@ struct TrustedPlatformArtTransactionDirectory {
impl TrustedPlatformArtTransactionDirectory {
fn open_anchored(root: &Path, path: &Path) -> Result<Self, String> {
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"),
@@ -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),
@@ -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"
@@ -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));
}
}
@@ -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::<std::collections::BTreeSet<_>>();
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]
@@ -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 =
@@ -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,
@@ -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::<String>();
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::<usize>().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) => {
@@ -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";
@@ -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,
@@ -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<bool, String> {
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,
@@ -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()],
@@ -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::<String>()
})
.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="));
}
}
}

Some files were not shown because too many files have changed in this diff Show More