合并最新master到M0A-2开发分支
Project CI / Repository checks (pull_request) Successful in 1m21s
Project CI / Frontend tests (pull_request) Successful in 3m5s
Project CI / Backend tests (pull_request) Successful in 4m10s
Project CI / Native shell tests (pull_request) Failing after 10m35s

同步 origin/master 至 3c457feb4c

保留 M0 固定 owner 验证、本人 smoke、试玩执行者与动态 Canvas 谱系合同

通过 114 项完成合同回归、动态 Canvas 精确回归及格式编码检查
This commit is contained in:
2026-08-11 09:12:10 +00:00
103 changed files with 7551 additions and 1455 deletions
@@ -1,4 +1,5 @@
{
"agentMode": "codex_app_server",
"llm": {
"apiKey": "",
"baseUrl": "https://api.openai.com/v1",
+10
View File
@@ -11,6 +11,7 @@
"@lexical/react": "^0.47.0",
"@lexical/utils": "^0.47.0",
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
"@tauri-apps/plugin-http": "^2.5.9",
"@tauri-apps/plugin-opener": "~2",
"@vitejs/plugin-react": "^5.0.4",
"lexical": "^0.47.0",
@@ -2208,6 +2209,15 @@
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-http": {
"version": "2.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.9.tgz",
"integrity": "sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
+1
View File
@@ -37,6 +37,7 @@
"@lexical/react": "^0.47.0",
"@lexical/utils": "^0.47.0",
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
"@tauri-apps/plugin-http": "^2.5.9",
"@tauri-apps/plugin-opener": "~2",
"@vitejs/plugin-react": "^5.0.4",
"lexical": "^0.47.0",
@@ -651,10 +651,12 @@ async function runConfigWizardRegressionChecks() {
apiKind: 'openai_responses',
},
);
assert.equal(updatedPrimary.agentMode, 'provider');
await writeGameCreatorWizardConfig(overlayState, updatedPrimary);
const reloadedState =
await readGameCreatorWizardConfigState(overlayConfigDir);
assert.equal(reloadedState.effectiveConfig.llm.apiKey, 'fixture-new-key');
assert.equal(reloadedState.effectiveConfig.agentMode, 'provider');
assert.equal(reloadedState.effectiveConfig.llm.model, 'new-model');
assert.equal(
reloadedState.effectiveConfig.llm.baseUrl,
@@ -1160,6 +1162,12 @@ if (defaultAppConfig.llm?.apiKey !== '') {
throw new Error('AI game creator shell default llm.apiKey must stay empty');
}
if (defaultAppConfig.agentMode !== 'codex_app_server') {
throw new Error(
'AI game creator shell default agentMode must be codex_app_server',
);
}
const allowedLlmReasoningEfforts = new Set([
'default',
'low',
@@ -1530,7 +1538,6 @@ for (const snippet of [
'$verified = $targetItem.GetAccessControl()',
'$rules.Count -ne 1',
'[System.Security.AccessControl.FileSystemRights]::FullControl',
"runChildCapture('powershell.exe'",
"'-NoProfile'",
"'-Command'",
'windowsPrivateAclScript',
@@ -1543,6 +1550,11 @@ for (const snippet of [
);
}
}
if (!/runChildCapture\(\s*['"]powershell\.exe['"]/u.test(configWizardSource)) {
throw new Error(
'AI game creator config wizard guardrail drifted: runChildCapture(powershell.exe)',
);
}
if (/\bGet-Acl\b/u.test(configWizardSource)) {
throw new Error(
'AI game creator config wizard must not rely on Get-Acl module auto-loading',
@@ -211,6 +211,7 @@ export function buildGameCreatorWizardConfig(existingConfig, llmInput) {
}
return {
...source,
agentMode: 'provider',
llm: {
...previousLlm,
apiKey,
@@ -290,21 +291,25 @@ export async function secureWindowsGameCreatorPathForCurrentUser(
targetPath,
{ isDirectory },
) {
const result = await runChildCapture('powershell.exe', [
'-NoLogo',
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-Command',
windowsPrivateAclScript,
], {
env: {
...process.env,
GENARRATIVE_AGC_PRIVATE_PATH: targetPath,
GENARRATIVE_AGC_PRIVATE_IS_DIRECTORY: String(isDirectory),
const result = await runChildCapture(
'powershell.exe',
[
'-NoLogo',
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-Command',
windowsPrivateAclScript,
],
{
env: {
...process.env,
GENARRATIVE_AGC_PRIVATE_PATH: targetPath,
GENARRATIVE_AGC_PRIVATE_IS_DIRECTORY: String(isDirectory),
},
},
});
);
if (result.code !== 0 || result.signal) {
const detail = result.stderr.trim() || result.stdout.trim();
throw new Error(
+163 -4
View File
@@ -702,10 +702,39 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie",
"document-features",
"idna",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -729,7 +758,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.13.0",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types 0.5.0",
"libc",
@@ -742,7 +771,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.13.0",
"core-foundation",
"core-foundation 0.10.1",
"libc",
]
@@ -889,6 +918,12 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "data-url"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "dbus"
version = "0.9.11"
@@ -1018,6 +1053,15 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "dom_query"
version = "0.27.0"
@@ -1131,6 +1175,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.1"
@@ -1616,6 +1669,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-clipboard-manager",
"tauri-plugin-dialog",
"tauri-plugin-http",
"tauri-plugin-opener",
"tempfile",
"tokio",
@@ -1835,6 +1889,25 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "h2"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.14.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "half"
version = "2.7.1"
@@ -1955,6 +2028,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@@ -1979,6 +2053,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
@@ -2015,9 +2090,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -2488,6 +2565,12 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -3734,6 +3817,22 @@ dependencies = [
"windows 0.62.2",
]
[[package]]
name = "psl-types"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "publicsuffix"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
dependencies = [
"idna",
"psl-types",
]
[[package]]
name = "pxfm"
version = "0.1.30"
@@ -3987,8 +4086,12 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"cookie",
"cookie_store",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
@@ -3998,6 +4101,7 @@ dependencies = [
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
@@ -4021,6 +4125,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
"webpki-roots",
]
[[package]]
@@ -4291,7 +4396,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.0",
"core-foundation",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -4847,6 +4952,27 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.13.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -4868,7 +4994,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [
"bitflags 2.13.0",
"block2",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics",
"crossbeam-channel",
"dbus",
@@ -5103,6 +5229,30 @@ dependencies = [
"url",
]
[[package]]
name = "tauri-plugin-http"
version = "2.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5bd512048e1985b7ec78f96d99083e2ddaf7e0d906b2b63c44ce5bb8b894067"
dependencies = [
"bytes",
"cookie_store",
"data-url",
"http",
"regex",
"reqwest 0.12.28",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"tokio",
"url",
"urlpattern",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.4"
@@ -6176,6 +6326,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -41,6 +41,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "nat
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
tauri = { version = "2.11.2", features = [] }
tauri-plugin-dialog = "2.7.1"
tauri-plugin-http = "2.5.9"
tauri-plugin-opener = "2"
tempfile = "3"
tokio = { version = "1", features = ["io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time"] }
@@ -9,6 +9,10 @@
"core:image:allow-rgba",
"core:image:allow-size",
"core:resources:allow-close",
{
"identifier": "http:default",
"allow": [{ "url": "https://dev.genarrative.world/api/*" }]
},
"opener:default"
]
}
@@ -9,6 +9,8 @@ use std::collections::BTreeSet;
use std::io::{Seek, SeekFrom};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
mod codex_app_server;
mod codex_cli;
mod generation;
mod interaction;
mod prompt;
@@ -18,6 +20,8 @@ mod runtime_driver;
mod runtime_protocol;
mod runtime_state;
mod runtime_tools;
use codex_app_server::*;
use codex_cli::*;
pub(crate) use generation::*;
pub(crate) use interaction::*;
pub(crate) use prompt::*;
@@ -27,3 +31,7 @@ pub(crate) use runtime_driver::*;
pub(crate) use runtime_protocol::*;
pub(crate) use runtime_state::*;
pub(crate) use runtime_tools::*;
pub(crate) fn shutdown_game_creator_codex_app_servers() -> Result<(), String> {
shutdown_game_creator_codex_app_servers_impl()
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -6,6 +6,7 @@ const AGENT_INTERACTION_RESUME_TOOL: &str = "runtime_resume";
const AGENT_INTERACTION_PROJECT_LOCATION_TOOL: &str = "project_location";
const AGENT_INTERACTION_MAX_OUTPUT_TOKENS: u32 = 1_200;
const AGENT_INTERACTION_PROVIDER_INSTANCE_ID: &str = "agc-interaction";
pub(crate) const AGENT_RUNTIME_STEER_DECISION_TOOL: &str = "runtime_steer_decision";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum AgentInteractionToolKind {
@@ -80,6 +81,171 @@ enum AgentInteractionTextEnvelope {
ProjectLocation,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct AgentRuntimeSteerDecisionArguments {
reply: String,
interrupt_current_provider: bool,
reason: String,
}
fn agent_runtime_steer_decision_function_tool() -> platform_llm::LlmFunctionTool {
platform_llm::LlmFunctionTool::new(
AGENT_RUNTIME_STEER_DECISION_TOOL,
"回复用户,并判断是否必须中断当前正在进行的 LLM Provider 请求。",
serde_json::json!({
"type": "object",
"required": ["reply", "interruptCurrentProvider", "reason"],
"additionalProperties": false,
"properties": {
"reply": {
"type": "string",
"minLength": 1,
"maxLength": 2000
},
"interruptCurrentProvider": { "type": "boolean" },
"reason": {
"type": "string",
"minLength": 1,
"maxLength": 500
}
}
}),
)
.with_strict(true)
}
fn build_agent_runtime_steer_decision_request(
root: &Path,
state: &AgentRuntimeState,
instruction: &str,
) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), String> {
let (llm, config_path, context) = build_game_creator_role_agent_context_for_session(
root,
&state.agent_id,
Some(&state.session_id),
)?;
let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?;
let runtime_summary = serde_json::json!({
"status": state.status,
"phase": state.phase,
"currentAction": state.current_action,
"waitingOn": state.waiting_on,
"nextStep": state.next_step,
"plan": state.plan,
"appliedSteerCursor": state.applied_steer_cursor,
});
let system = format!(
"{}\n\n你现在是项目总控的非终态对话判定层。一个制作 Run 正在执行,你必须先自然回答用户,再判断是否需要中断当前正在进行的 LLM Provider 请求。不要因为收到新消息就默认中断:状态询问、解释、鼓励、确认以及不冲突的补充应保持 interruptCurrentProvider=false;只有用户明确停止/改向,或新要求会让当前 LLM 继续生成明显过期方案时才设为 true。宿主工具和已经开始的外部动作不会被此判定强杀,它们会在安全边界完成。你的回复不能宣称整个 Run 已完成,也不能把这次回复当作终态。必须调用且只调用 runtime_steer_decision。",
game_creator_project_supervisor_chat_system_prompt()
);
let user = format!(
"项目上下文如下,只作为背景,不要逐字复述:\n{context}\n\n当前 Runtime 摘要:\n{}\n\n用户在制作过程中发来的消息:\n{}",
serde_json::to_string_pretty(&runtime_summary)
.map_err(|error| format!("序列化 steer Runtime 摘要失败:{error}"))?,
instruction.trim(),
);
let request = LlmRunRequest::single_turn(system, user)
.with_api_kind(api_kind)
.with_max_output_tokens(AGENT_INTERACTION_MAX_OUTPUT_TOKENS)
.with_function_tools(vec![agent_runtime_steer_decision_function_tool()])
.with_tool_choice(platform_llm::LlmToolChoice::Required);
let request = apply_game_creator_llm_reasoning_effort(request, &llm)?;
Ok((llm, config_path, request))
}
fn parse_agent_runtime_steer_decision_response(
response: &platform_llm::LlmRunResponse,
) -> Result<AgentRuntimeSteerDecision, String> {
if response.tool_calls.len() != 1 {
return Err("Supervisor steer decision 必须返回一个 runtime_steer_decision".to_string());
}
let call = &response.tool_calls[0];
if call.name != AGENT_RUNTIME_STEER_DECISION_TOOL {
return Err(format!(
"Supervisor steer decision 返回未知工具:{}",
call.name
));
}
let arguments = serde_json::from_str::<AgentRuntimeSteerDecisionArguments>(&call.arguments)
.map_err(|error| format!("runtime_steer_decision 参数无效:{error}"))?;
let reply = arguments.reply.trim();
let reason = arguments.reason.trim();
if reply.is_empty() || reason.is_empty() {
return Err("runtime_steer_decision reply/reason 不能为空".to_string());
}
Ok(AgentRuntimeSteerDecision {
reply: reply.to_string(),
interrupt_current_provider: arguments.interrupt_current_provider,
reason: reason.to_string(),
})
}
pub(crate) async fn decide_game_creator_agent_runtime_steer_at(
root: &Path,
state: &AgentRuntimeState,
steer_id: &str,
sequence: u64,
instruction: &str,
) -> Result<AgentRuntimeSteerDecision, String> {
if let Some(decision) = read_game_creator_agent_runtime_steer_decision_at(
root,
&state.agent_id,
&state.run_id,
steer_id,
)? {
return Ok(decision);
}
if state.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|| state.parent_agent_id.is_some()
|| state.parent_run_id.is_some()
{
return Err("只有根 Project Supervisor 支持运行中非终态回复".to_string());
}
let (llm, config_path, request) =
build_agent_runtime_steer_decision_request(root, state, instruction)?;
let decision_snapshot = {
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"runtime.steer_decision.snapshot",
)?;
let mut snapshot = capture_game_creator_agent_runtime_provider_request_snapshot_at_locked(
root,
&state.agent_id,
&state.session_id,
&state.run_id,
"steer-decision",
&format!("steer-{sequence}"),
state.applied_steer_cursor,
)?;
// The main Runtime Provider request must keep running while this
// independent LLM turn decides whether it is stale. A distinct node
// identity gives Codex app-server a separate process/thread gate.
snapshot.agent_id = format!("{}-steer-decision", state.agent_id);
snapshot.task_id = snapshot.agent_id.clone();
snapshot
};
let agent_mode =
normalize_game_creator_agent_mode(&load_game_creator_app_config()?.agent_mode)?;
let response = match agent_mode.as_str() {
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER => {
request_game_creator_agent_codex_app_server(&decision_snapshot, &llm, request).await
}
GAME_CREATOR_AGENT_MODE_CODEX_CLI => request_game_creator_agent_codex_cli(request).await,
GAME_CREATOR_AGENT_MODE_PROVIDER => {
build_game_creator_agent_runtime_llm_client(&llm, &config_path)?
.run(request)
.await
}
_ => unreachable!("agent mode is normalized"),
}
.map_err(|error| format!("Supervisor steer decision 调用 LLM 失败:{error}"))?;
let decision = parse_agent_runtime_steer_decision_response(&response)?;
persist_game_creator_agent_runtime_steer_decision_and_reply_at(
root, state, steer_id, sequence, decision,
)
}
pub(crate) fn game_creator_agent_uses_interaction_kernel(agent_id: &str) -> bool {
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
return true;
@@ -407,6 +573,40 @@ mod tests {
assert!(error.contains("最多只能选择一个"));
}
#[test]
fn steer_decision_replies_without_interrupting_status_questions() {
let decision = parse_agent_runtime_steer_decision_response(&response(
"",
vec![tool_call(
AGENT_RUNTIME_STEER_DECISION_TOOL,
r#"{"reply":"我正在完成玩法实现,当前任务会继续。","interruptCurrentProvider":false,"reason":"这是状态询问,不会使当前方案过期。"}"#,
)],
))
.expect("parse non-interrupting steer decision");
assert_eq!(
decision,
AgentRuntimeSteerDecision {
reply: "我正在完成玩法实现,当前任务会继续。".to_string(),
interrupt_current_provider: false,
reason: "这是状态询问,不会使当前方案过期。".to_string(),
}
);
}
#[test]
fn steer_decision_can_request_safe_interrupt_for_conflicting_change() {
let decision = parse_agent_runtime_steer_decision_response(&response(
"",
vec![tool_call(
AGENT_RUNTIME_STEER_DECISION_TOOL,
r#"{"reply":"明白,我会停止旧方向并改成回合制。","interruptCurrentProvider":true,"reason":"用户明确改向,旧 Provider 仍在生成过期方案。"}"#,
)],
))
.expect("parse interrupting steer decision");
assert!(decision.interrupt_current_provider);
assert!(decision.reply.contains("改成回合制"));
}
#[test]
fn interaction_registry_derives_unique_strict_function_tools() {
let registry = agent_interaction_tool_registry().expect("interaction registry");
@@ -135,6 +135,11 @@ pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_
let stream_response = llm.stream;
let request_stream_snapshot = stream_snapshot.clone();
let response_observations = observations;
let agent_mode =
normalize_game_creator_agent_mode(&load_game_creator_app_config()?.agent_mode)?;
let llm_for_request = llm.clone();
let snapshot_for_request = provider_snapshot.clone();
let config_path_for_request = config_path.clone();
let response_result =
request_game_creator_agent_runtime_llm_with_persisted_transient_retry_using(
root,
@@ -143,9 +148,71 @@ pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_
&config_path,
"后台 Agent 最终回复",
&request,
move |client, attempt_request| {
agent_mode.clone(),
move |attempt_request| {
let stream_snapshot = request_stream_snapshot.clone();
async move {
if agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER {
if stream_response {
let mut publisher = AgentRuntimeResponseStreamPublisher::start(
root,
&stream_snapshot,
response_revision,
);
return match stream_game_creator_agent_codex_app_server(
&snapshot_for_request,
&llm_for_request,
attempt_request,
|delta| {
if !suppress_private_process_output {
publisher.push(delta);
}
},
)
.await
{
Ok(response) => {
match normalize_game_creator_agent_background_final_reply_response(
response,
response_observations,
) {
Ok(response) => {
publisher.handoff();
Ok(response)
}
Err(error) => {
publisher.failed();
Err(error)
}
}
}
Err(error) => {
publisher.failed();
Err(error)
}
};
}
return normalize_game_creator_agent_background_final_reply_response(
request_game_creator_agent_codex_app_server(
&snapshot_for_request,
&llm_for_request,
attempt_request,
)
.await?,
response_observations,
);
}
if agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_CLI {
return normalize_game_creator_agent_background_final_reply_response(
request_game_creator_agent_codex_cli(attempt_request).await?,
response_observations,
);
}
let client = build_game_creator_agent_runtime_llm_client(
&llm_for_request,
&config_path_for_request,
)
.map_err(platform_llm::LlmError::InvalidConfig)?;
if stream_response {
let mut publisher = AgentRuntimeResponseStreamPublisher::start(
root,
@@ -1,5 +1,18 @@
use super::*;
fn provider_retry_identity_for_response_stream_test(
snapshot: &AgentRuntimeProviderRequestSnapshot,
llm: &GameCreatorLlmConfig,
request: &LlmRunRequest,
) -> Result<AgentRuntimeProviderRetryIdentity, String> {
game_creator_agent_runtime_provider_retry_identity_for_mode(
snapshot,
llm,
request,
GAME_CREATOR_AGENT_MODE_PROVIDER,
)
}
fn response_stream_fixture(
run_id: &str,
) -> (
@@ -75,10 +88,10 @@ fn persist_tool_plan_handoff_repair_chain(
LlmMessage::user("修复格式"),
]);
let base_identity =
game_creator_agent_runtime_provider_retry_identity(&base_snapshot, llm, &base_request)
provider_retry_identity_for_response_stream_test(&base_snapshot, llm, &base_request)
.expect("build cleanup base identity");
let repair_identity =
game_creator_agent_runtime_provider_retry_identity(&repair_snapshot, llm, &repair_request)
provider_retry_identity_for_response_stream_test(&repair_snapshot, llm, &repair_request)
.expect("build cleanup repair identity");
let base_request_id = game_creator_agent_runtime_provider_request_id(&base_snapshot);
let repair_request_id = game_creator_agent_runtime_provider_request_id(&repair_snapshot);
@@ -972,7 +985,7 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon
let mut new_llm = old_llm.clone();
new_llm.model = "new-provider-model".to_string();
let old_identity =
game_creator_agent_runtime_provider_retry_identity(&snapshot, &old_llm, &request)
provider_retry_identity_for_response_stream_test(&snapshot, &old_llm, &request)
.expect("build old handoff identity");
let provider_request_id = game_creator_agent_runtime_provider_request_id(&snapshot);
append_game_creator_agent_runtime_provider_request_lifecycle(
@@ -1008,7 +1021,8 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon
"agentLlm.design-director",
"测试 Provider handoff 身份漂移",
&request,
|_client, _request| async {
GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
|_request| async {
Err(platform_llm::LlmError::Transport(
"identity drift must not call Provider".to_string(),
))
@@ -1074,9 +1088,9 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo
let mut new_llm = old_llm.clone();
new_llm.model = "new-tool-plan-model".to_string();
let base_identity =
game_creator_agent_runtime_provider_retry_identity(&base_snapshot, &old_llm, &base_request)
provider_retry_identity_for_response_stream_test(&base_snapshot, &old_llm, &base_request)
.expect("build old base identity");
let repair_identity = game_creator_agent_runtime_provider_retry_identity(
let repair_identity = provider_retry_identity_for_response_stream_test(
&repair_snapshot,
&old_llm,
&repair_request,
@@ -1135,7 +1149,8 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo
"agentLlm.project-supervisor",
"测试 tool-plan handoff 身份漂移",
&base_request,
|_client, _request| async {
GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
|_request| async {
Err(platform_llm::LlmError::Transport(
"identity drift must not call Provider".to_string(),
))
@@ -1191,7 +1206,7 @@ async fn generic_retry_identity_drift_closes_tool_plan_repair_chain_before_remov
persist_tool_plan_handoff_repair_chain(root, &snapshot, &old_llm);
let request = LlmRunRequest::new(vec![LlmMessage::user("验证通用 retry 身份漂移")]);
let old_identity =
game_creator_agent_runtime_provider_retry_identity(&snapshot, &old_llm, &request)
provider_retry_identity_for_response_stream_test(&snapshot, &old_llm, &request)
.expect("build old generic retry identity");
provider_retry::write_next_at(
root,
@@ -1214,7 +1229,8 @@ async fn generic_retry_identity_drift_closes_tool_plan_repair_chain_before_remov
"agentLlm.project-supervisor",
"测试通用 retry 身份漂移",
&request,
|_client, _request| async {
GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
|_request| async {
Err(platform_llm::LlmError::Transport(
"generic retry drift must not call Provider".to_string(),
))
@@ -1280,7 +1296,7 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() {
"capacity request {loop_iteration}"
))]);
let identity =
game_creator_agent_runtime_provider_retry_identity(&entry_snapshot, &llm, &request)
provider_retry_identity_for_response_stream_test(&entry_snapshot, &llm, &request)
.expect("build capacity entry identity");
let request_id = game_creator_agent_runtime_provider_request_id(&entry_snapshot);
let response = platform_llm::LlmRunResponse {
@@ -1326,7 +1342,8 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() {
"agentLlm.project-supervisor",
"测试 tool-plan 请求前容量门禁",
&request,
move |_client, _request| async move {
GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
move |_request| async move {
provider_called_for_request.store(true, Ordering::Release);
Err(platform_llm::LlmError::Transport(
"capacity gate must run before Provider".to_string(),
@@ -1392,10 +1409,10 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem
retry_backoff_ms: 1,
};
let base_identity =
game_creator_agent_runtime_provider_retry_identity(&base_snapshot, &llm, &base_request)
provider_retry_identity_for_response_stream_test(&base_snapshot, &llm, &base_request)
.expect("build durable control base identity");
let repair_identity =
game_creator_agent_runtime_provider_retry_identity(&repair_snapshot, &llm, &repair_request)
provider_retry_identity_for_response_stream_test(&repair_snapshot, &llm, &repair_request)
.expect("build durable control repair identity");
let base_request_id = game_creator_agent_runtime_provider_request_id(&base_snapshot);
let repair_request_id = game_creator_agent_runtime_provider_request_id(&repair_snapshot);
@@ -1458,7 +1475,8 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem
"agentLlm.project-supervisor",
"测试 durable control 清理 tool-plan handoff",
&base_request,
|_client, _request| async {
GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
|_request| async {
Err(platform_llm::LlmError::Transport(
"durable control must not call Provider".to_string(),
))
@@ -1513,7 +1531,7 @@ fn provider_recovery_cleanup_closes_tool_plan_lifecycle_before_removing_handoff(
max_retries: 0,
retry_backoff_ms: 1,
};
let identity = game_creator_agent_runtime_provider_retry_identity(&snapshot, &llm, &request)
let identity = provider_retry_identity_for_response_stream_test(&snapshot, &llm, &request)
.expect("build cleanup handoff identity");
let request_id = game_creator_agent_runtime_provider_request_id(&snapshot);
append_game_creator_agent_runtime_provider_request_lifecycle(
@@ -1583,7 +1601,7 @@ fn runtime_resume_scans_and_cleans_terminal_tool_plan_handoff() {
max_retries: 0,
retry_backoff_ms: 1,
};
let identity = game_creator_agent_runtime_provider_retry_identity(&snapshot, &llm, &request)
let identity = provider_retry_identity_for_response_stream_test(&snapshot, &llm, &request)
.expect("build terminal handoff identity");
let request_id = game_creator_agent_runtime_provider_request_id(&snapshot);
append_game_creator_agent_runtime_provider_request_lifecycle(
@@ -1677,7 +1695,7 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat
max_retries: 1,
retry_backoff_ms: 1,
};
let identity = game_creator_agent_runtime_provider_retry_identity(&snapshot, &llm, &request)
let identity = provider_retry_identity_for_response_stream_test(&snapshot, &llm, &request)
.expect("build Provider recovery identity");
let provider_request_id = game_creator_agent_runtime_provider_request_id(&snapshot);
append_game_creator_agent_runtime_provider_request_lifecycle(
@@ -1723,7 +1741,8 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat
"agentLlm.design-director",
"测试 Provider recovery 冲突",
&request,
|_client, _request| async {
GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
|_request| async {
Err(platform_llm::LlmError::Transport(
"reconciliation must not call Provider".to_string(),
))
@@ -83,14 +83,20 @@ pub(crate) use run_configuration::{
};
pub(crate) use steering::{
acquire_game_creator_agent_runtime_steer_project_write_lock_with_wait,
append_game_creator_agent_runtime_steer_decision_failure_reply_at,
consume_game_creator_agent_runtime_steers, game_creator_agent_runtime_accepts_steer,
game_creator_agent_runtime_provider_request_count_for_roots,
game_creator_agent_runtime_steer_ledger_path,
interrupt_game_creator_agent_runtime_provider_for_decided_steer_at,
interrupt_game_creator_agent_runtime_provider_request_at,
interrupt_game_creator_agent_runtime_provider_requests_for_roots,
persist_game_creator_agent_runtime_steer_decision_and_reply_at,
read_game_creator_agent_runtime_steer_decision_at,
register_game_creator_agent_runtime_provider_request,
render_game_creator_agent_runtime_steers_for_prompt, steer_game_creator_agent_runtime_task_at,
steer_game_creator_agent_runtime_task_for_profile_at,
validate_game_creator_agent_runtime_steer_notification_at,
unregister_game_creator_agent_runtime_provider_request,
validate_game_creator_agent_runtime_steer_notification_at, AgentRuntimeSteerDecision,
};
pub(crate) use verification::{
game_creator_agent_runtime_verification_gate_path,
@@ -480,9 +480,10 @@ pub(in crate::agent) struct AgentRuntimeSteerLedgerSnapshot {
}
#[derive(Default)]
pub(in crate::agent) struct AgentRuntimeProviderInterrupt {
pub(in crate::agent) interrupted: AtomicBool,
pub(in crate::agent) notify: tokio::sync::Notify,
pub(crate) struct AgentRuntimeProviderInterrupt {
pub(crate) applied_steer_cursor: u64,
pub(crate) interrupted: AtomicBool,
pub(crate) notify: tokio::sync::Notify,
}
pub(in crate::agent) static GAME_CREATOR_AGENT_PROVIDER_INTERRUPTS: OnceLock<
@@ -71,6 +71,15 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_error_with_transient
operation: &str,
retry_autonomous_upstream_400: bool,
) -> String {
if matches!(
error,
platform_llm::LlmError::Transport(message)
if message.starts_with(GAME_CREATOR_CODEX_APP_SERVER_TERMINAL_UNKNOWN_PREFIX)
) {
return format!(
"{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: Codex app-server turn 终态未知"
);
}
let public_error = format!(
"{config_path} {operation}调用 LLM 失败:{}",
game_creator_agent_llm_error_public_summary(error)
@@ -284,7 +293,76 @@ pub(in crate::agent) fn game_creator_agent_runtime_llm_request_fingerprint(
pub(in crate::agent) fn game_creator_agent_runtime_provider_config_fingerprint(
llm: &GameCreatorLlmConfig,
) -> Result<String, String> {
let app_config = load_game_creator_app_config()?;
let agent_mode = normalize_game_creator_agent_mode(&app_config.agent_mode)?;
let codex_cli_version = if matches!(
agent_mode.as_str(),
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER | GAME_CREATOR_AGENT_MODE_CODEX_CLI
) {
Some(game_creator_codex_cli_version_identity()?)
} else {
None
};
game_creator_agent_runtime_provider_config_fingerprint_for_mode(
&agent_mode,
codex_cli_version.as_deref(),
llm,
)
}
fn game_creator_agent_runtime_provider_config_fingerprint_for_mode(
agent_mode: &str,
codex_cli_version: Option<&str>,
llm: &GameCreatorLlmConfig,
) -> Result<String, String> {
let agent_mode = normalize_game_creator_agent_mode(agent_mode)?;
let codex_cli = if agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_CLI {
Some(serde_json::json!({
"executable": "codex",
"version": codex_cli_version.ok_or_else(|| {
"Codex CLI 模式缺少 CLI 版本身份".to_string()
})?,
"protocol": "genarrative-codex-cli-agent.v1",
"sandbox": "read-only",
"shellTool": false,
"ephemeral": true,
"ignoreUserConfig": true,
"ignoreRules": true,
"approvalPolicy": "never",
}))
} else {
None
};
let codex_app_server = if agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER {
Some(serde_json::json!({
"executable": "codex",
"version": codex_cli_version.ok_or_else(|| {
"Codex app-server 模式缺少 CLI 版本身份".to_string()
})?,
"protocol": game_creator_codex_app_server_protocol_identity(),
"providerRoute": if llm.api_key.trim().is_empty() {
"codex-login"
} else {
"agc-openai-responses"
},
"sandbox": "read-only",
"networkAccess": false,
"shellTool": false,
"webSearch": false,
"multiAgent": false,
"isolatedOsHome": true,
"mcpServers": false,
"ephemeralThread": true,
"approvalPolicy": "never",
"outputSchema": "per-turn",
}))
} else {
None
};
let stable = serde_json::json!({
"agentMode": agent_mode,
"codexAppServer": codex_app_server,
"codexCli": codex_cli,
"apiKeySha256": format!("{:x}", Sha256::digest(llm.api_key.as_bytes())),
"baseUrl": llm.base_url,
"model": llm.model,
@@ -306,6 +384,30 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_retry_identity(
llm: &GameCreatorLlmConfig,
request: &LlmRunRequest,
) -> Result<AgentRuntimeProviderRetryIdentity, String> {
let app_config = load_game_creator_app_config()?;
game_creator_agent_runtime_provider_retry_identity_for_mode(
snapshot,
llm,
request,
&app_config.agent_mode,
)
}
pub(in crate::agent) fn game_creator_agent_runtime_provider_retry_identity_for_mode(
snapshot: &AgentRuntimeProviderRequestSnapshot,
llm: &GameCreatorLlmConfig,
request: &LlmRunRequest,
agent_mode: &str,
) -> Result<AgentRuntimeProviderRetryIdentity, String> {
let agent_mode = normalize_game_creator_agent_mode(agent_mode)?;
let codex_cli_version = if matches!(
agent_mode.as_str(),
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER | GAME_CREATOR_AGENT_MODE_CODEX_CLI
) {
Some(game_creator_codex_cli_version_identity()?)
} else {
None
};
Ok(AgentRuntimeProviderRetryIdentity {
project_id: snapshot.project_id.clone(),
agent_id: snapshot.agent_id.clone(),
@@ -320,7 +422,12 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_retry_identity(
request_kind: snapshot.request_kind.clone(),
base_request_slot: snapshot.request_slot.clone(),
request_fingerprint: game_creator_agent_runtime_llm_request_fingerprint(request)?,
provider_config_fingerprint: game_creator_agent_runtime_provider_config_fingerprint(llm)?,
provider_config_fingerprint:
game_creator_agent_runtime_provider_config_fingerprint_for_mode(
&agent_mode,
codex_cli_version.as_deref(),
llm,
)?,
web_search_enabled: snapshot.web_search_enabled,
allow_idle_context_compaction: snapshot.allow_idle_context_compaction,
})
@@ -566,11 +673,12 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_persis
config_path: &str,
operation: &str,
request: &LlmRunRequest,
agent_mode: String,
execute: F,
canonicalize_handoff_response: H,
) -> Result<AgentRuntimePersistedProviderRequestOutcome, String>
where
F: FnOnce(LlmClient, LlmRunRequest) -> Fut,
F: FnOnce(LlmRunRequest) -> Fut,
Fut: std::future::Future<Output = Result<platform_llm::LlmRunResponse, platform_llm::LlmError>>,
H: FnOnce(&platform_llm::LlmRunResponse) -> platform_llm::LlmRunResponse,
{
@@ -582,8 +690,12 @@ where
)?;
let retry_autonomous_upstream_400 =
max_retries >= AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR;
let identity =
game_creator_agent_runtime_provider_retry_identity(provider_snapshot, llm, request)?;
let identity = game_creator_agent_runtime_provider_retry_identity_for_mode(
provider_snapshot,
llm,
request,
&agent_mode,
)?;
let tool_plan_handoff_missing = if identity.request_kind == "tool-plan" {
match tool_plan_handoff::lookup_at(
root,
@@ -1008,19 +1120,11 @@ where
.map_err(|error| redact_agent_runtime_error(root, &error, 500))?;
}
let attempt_snapshot = provider_snapshot.with_request_slot(request_slot);
let client = match build_game_creator_agent_runtime_llm_client(llm, config_path) {
Ok(client) => client,
Err(error) => {
crate::provider_retry::remove_at(
root,
&provider_snapshot.agent_id,
&provider_snapshot.run_id,
)?;
return Err(error);
}
};
let provider_request = async {
execute(client, request.clone()).await.map_err(|error| {
// 模式分发的 execute future 会同时携带 Provider、Codex CLI 和 Codex app-server 分支。
// 在进入下层通用 control/lifecycle helper 前装箱完整请求,保证 debug 构建的队列恢复
// 仍可安全运行在 Tokio 默认 worker 栈上。
let provider_request = Box::pin(async {
execute(request.clone()).await.map_err(|error| {
game_creator_agent_runtime_provider_error_with_transient_kind(
&error,
config_path,
@@ -1028,7 +1132,7 @@ where
retry_autonomous_upstream_400,
)
})
};
});
let handoff_identity = identity.clone();
let handoff_request_slot = attempt_snapshot.request_slot.clone();
let persist_handoff = provider_handoff::supports_request_kind(&identity.request_kind);
@@ -1293,6 +1397,11 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_persis
operation: &str,
request: &LlmRunRequest,
) -> Result<AgentRuntimePersistedProviderRequestOutcome, String> {
let agent_mode =
normalize_game_creator_agent_mode(&load_game_creator_app_config()?.agent_mode)?;
let llm_for_request = llm.clone();
let snapshot_for_request = provider_snapshot.clone();
let config_path_for_request = config_path.to_string();
request_game_creator_agent_runtime_llm_with_persisted_transient_retry_using(
root,
provider_snapshot,
@@ -1300,7 +1409,31 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_persis
config_path,
operation,
request,
|client, request| async move { client.run(request).await },
agent_mode.clone(),
move |request| async move {
match agent_mode.as_str() {
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER => {
request_game_creator_agent_codex_app_server(
&snapshot_for_request,
&llm_for_request,
request,
)
.await
}
GAME_CREATOR_AGENT_MODE_CODEX_CLI => {
request_game_creator_agent_codex_cli(request).await
}
GAME_CREATOR_AGENT_MODE_PROVIDER => {
let client = build_game_creator_agent_runtime_llm_client(
&llm_for_request,
&config_path_for_request,
)
.map_err(platform_llm::LlmError::InvalidConfig)?;
client.run(request).await
}
_ => unreachable!("agent mode is normalized"),
}
},
|response| response.clone(),
)
.await
@@ -1329,9 +1462,35 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transi
format!("{}-transient-{attempt}", provider_snapshot.request_slot)
};
let attempt_snapshot = provider_snapshot.with_request_slot(request_slot.clone());
let client = build_game_creator_agent_runtime_llm_client(llm, config_path)?;
let provider_request = async {
client.run(request.clone()).await.map_err(|error| {
let agent_mode =
normalize_game_creator_agent_mode(&load_game_creator_app_config()?.agent_mode)?;
let client = (agent_mode == GAME_CREATOR_AGENT_MODE_PROVIDER)
.then(|| build_game_creator_agent_runtime_llm_client(llm, config_path))
.transpose()?;
// 不让组合后的 Provider/Codex 分发 future 留在调用方 async frame 中;否则 debug
// 构建在把委派子任务回执送回父 Runtime 时会超过 Tokio 默认 worker 栈。
let provider_request = Box::pin(async {
let response = match agent_mode.as_str() {
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER => {
request_game_creator_agent_codex_app_server(
&attempt_snapshot,
llm,
request.clone(),
)
.await
}
GAME_CREATOR_AGENT_MODE_CODEX_CLI => {
request_game_creator_agent_codex_cli(request.clone()).await
}
GAME_CREATOR_AGENT_MODE_PROVIDER => {
client
.expect("provider mode constructs an HTTP client")
.run(request.clone())
.await
}
_ => unreachable!("agent mode is normalized"),
};
response.map_err(|error| {
game_creator_agent_runtime_provider_error_with_transient_kind(
&error,
config_path,
@@ -1339,7 +1498,7 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transi
retry_autonomous_upstream_400,
)
})
};
});
match await_game_creator_agent_runtime_provider_request_with_snapshot(
root,
attempt_snapshot.clone(),
@@ -1471,6 +1630,66 @@ pub(crate) fn append_game_creator_agent_runtime_provider_lifecycle_for_test(
mod tests {
use super::*;
#[test]
fn provider_config_fingerprint_separates_all_agent_modes() {
let llm = GameCreatorLlmConfig::default();
let app_server = game_creator_agent_runtime_provider_config_fingerprint_for_mode(
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER,
Some("codex-cli test-version"),
&llm,
)
.expect("Codex app-server config fingerprint");
let codex = game_creator_agent_runtime_provider_config_fingerprint_for_mode(
GAME_CREATOR_AGENT_MODE_CODEX_CLI,
Some("codex-cli test-version"),
&llm,
)
.expect("Codex CLI config fingerprint");
let provider = game_creator_agent_runtime_provider_config_fingerprint_for_mode(
GAME_CREATOR_AGENT_MODE_PROVIDER,
None,
&llm,
)
.expect("Provider config fingerprint");
assert_ne!(app_server, codex);
assert_ne!(app_server, provider);
assert_ne!(codex, provider);
assert_ne!(
app_server,
game_creator_agent_runtime_provider_config_fingerprint_for_mode(
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER,
Some("codex-cli changed-version"),
&llm,
)
.expect("changed Codex app-server config fingerprint")
);
assert_ne!(
codex,
game_creator_agent_runtime_provider_config_fingerprint_for_mode(
GAME_CREATOR_AGENT_MODE_CODEX_CLI,
Some("codex-cli changed-version"),
&llm,
)
.expect("changed Codex CLI config fingerprint")
);
}
#[test]
fn codex_app_server_unknown_terminal_enters_reconciliation_without_retry() {
let error = platform_llm::LlmError::Transport(format!(
"{GAME_CREATOR_CODEX_APP_SERVER_TERMINAL_UNKNOWN_PREFIX} eof"
));
let encoded = game_creator_agent_runtime_provider_error_with_transient_kind(
&error,
"agentLlm.project-supervisor",
"规划",
false,
);
assert!(encoded.starts_with(AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX));
assert!(!encoded.starts_with(AGENT_RUNTIME_PROVIDER_TRANSIENT_ERROR_PREFIX));
assert!(!encoded.contains("eof"));
}
#[test]
fn provider_retry_error_classification_accepts_durable_failures_and_rejects_client_faults() {
let retryable = vec![
@@ -1075,6 +1075,7 @@ where
root,
&snapshot.agent_id,
&snapshot.run_id,
snapshot.applied_steer_cursor,
)
.map_err(|error| redact_agent_runtime_error(root, &error, 500))?;
before_control_recheck();
@@ -1246,6 +1247,25 @@ where
}
}
unregister_game_creator_agent_runtime_provider_request(&key, &active);
if result
.as_ref()
.is_err_and(|error| error.starts_with(AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX))
{
let control_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"runtime.provider_request.unknown_terminal_reconciliation",
);
if control_lock.is_ok() {
let _ = mark_game_creator_agent_runtime_provider_request_needs_reconciliation_at_locked(
root,
&snapshot,
&request_id,
);
}
return Err(format!(
"{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: requestId={request_id} · Codex app-server turn 终态未知"
));
}
let status = match &result {
Ok(Some(_)) => "completed",
Ok(None) => "interrupted",
@@ -100,6 +100,8 @@ pub(in crate::agent) fn normalize_agent_runtime_steer_id(steer_id: &str) -> Resu
Ok(steer_id.to_string())
}
pub(in crate::agent) const AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX: &str = "agent-steer-";
pub(in crate::agent) fn agent_runtime_steer_message_id(
project_id: &str,
agent_id: &str,
@@ -112,7 +114,7 @@ pub(in crate::agent) fn agent_runtime_steer_message_id(
format!("{project_id}\n{agent_id}\n{task_id}\n{session_id}\n{run_id}\n{steer_id}");
let fingerprint = format!("{:x}", Sha256::digest(payload.as_bytes()));
format!(
"agent-steer-{}",
"{AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX}{}",
fingerprint.chars().take(32).collect::<String>()
)
}
@@ -429,6 +431,202 @@ pub(in crate::agent) fn ensure_game_creator_agent_runtime_steer_audit(
)
}
const AGENT_RUNTIME_STEER_DECISION_RECORD_TYPE: &str = "agent.runtime.steer_decision";
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct AgentRuntimeSteerDecision {
pub(crate) reply: String,
pub(crate) interrupt_current_provider: bool,
pub(crate) reason: String,
}
fn parse_game_creator_agent_runtime_steer_decision_record(
record: &serde_json::Value,
) -> Result<AgentRuntimeSteerDecision, String> {
let reply = record
.get("reply")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| "Supervisor steer decision 缺少 reply".to_string())?;
let reply_sha256 = format!("{:x}", Sha256::digest(reply.as_bytes()));
if record
.get("replySha256")
.and_then(serde_json::Value::as_str)
!= Some(reply_sha256.as_str())
{
return Err("Supervisor steer decision reply 指纹冲突".to_string());
}
let interrupt_current_provider = record
.get("interruptCurrentProvider")
.and_then(serde_json::Value::as_bool)
.ok_or_else(|| "Supervisor steer decision 缺少 interruptCurrentProvider".to_string())?;
let reason = record
.get("reason")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
Ok(AgentRuntimeSteerDecision {
reply: reply.to_string(),
interrupt_current_provider,
reason: reason.to_string(),
})
}
pub(crate) fn read_game_creator_agent_runtime_steer_decision_at(
root: &Path,
agent_id: &str,
run_id: &str,
steer_id: &str,
) -> Result<Option<AgentRuntimeSteerDecision>, String> {
let (records, _) = read_agent_db_records_bounded(root, 16 * 1024 * 1024)?;
let mut decision = None;
for record in records.iter().filter(|record| {
record.get("recordType").and_then(serde_json::Value::as_str)
== Some(AGENT_RUNTIME_STEER_DECISION_RECORD_TYPE)
&& record.get("agentId").and_then(serde_json::Value::as_str) == Some(agent_id)
&& record.get("runId").and_then(serde_json::Value::as_str) == Some(run_id)
&& record.get("steerId").and_then(serde_json::Value::as_str) == Some(steer_id)
}) {
let parsed = parse_game_creator_agent_runtime_steer_decision_record(record)?;
if decision
.as_ref()
.is_some_and(|existing| existing != &parsed)
{
return Err("Supervisor steer decision 出现冲突终态".to_string());
}
decision = Some(parsed);
}
Ok(decision)
}
pub(crate) fn persist_game_creator_agent_runtime_steer_decision_and_reply_at(
root: &Path,
state: &AgentRuntimeState,
steer_id: &str,
sequence: u64,
decision: AgentRuntimeSteerDecision,
) -> Result<AgentRuntimeSteerDecision, String> {
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"runtime.steer_decision.persist",
)?;
let snapshot =
read_game_creator_agent_runtime_steer_ledger(root, &state.agent_id, &state.run_id)?;
let entry = snapshot
.entries
.get(steer_id)
.ok_or_else(|| "Supervisor steer decision 未命中持久 steer".to_string())?;
if entry.identity.sequence != sequence
|| entry.identity.session_id != state.session_id
|| entry.identity.source != state.source
{
return Err("Supervisor steer decision 与持久 steer 身份冲突".to_string());
}
let reply = sanitize_agent_runtime_text(decision.reply.trim(), 2_000);
if reply.is_empty() {
return Err("Supervisor steer decision reply 不能为空".to_string());
}
let reason = redact_agent_runtime_project_paths(
root,
&sanitize_agent_runtime_text(decision.reason.trim(), 500),
500,
);
let decision = AgentRuntimeSteerDecision {
reply,
interrupt_current_provider: decision.interrupt_current_provider,
reason,
};
let persisted = if let Some(existing) = read_game_creator_agent_runtime_steer_decision_at(
root,
&state.agent_id,
&state.run_id,
steer_id,
)? {
existing
} else {
append_agent_db_record(
root,
serde_json::json!({
"recordType": AGENT_RUNTIME_STEER_DECISION_RECORD_TYPE,
"agentId": state.agent_id,
"taskId": state.task_id,
"sessionId": state.session_id,
"runId": state.run_id,
"source": state.source,
"steerId": steer_id,
"sequence": sequence,
"messageId": entry.identity.message_id,
"instructionSha256": entry.identity.instruction_sha256,
"reply": decision.reply,
"replySha256": format!("{:x}", Sha256::digest(decision.reply.as_bytes())),
"interruptCurrentProvider": decision.interrupt_current_provider,
"reason": decision.reason,
"decidedAt": unix_timestamp(),
}),
)?;
decision
};
let message_id = entry.identity.message_id.as_deref().unwrap_or_default();
let correlation_id = message_id
.strip_prefix(AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX)
.ok_or_else(|| "Supervisor steer decision 缺少消息关联身份".to_string())?;
append_game_creator_agent_runtime_public_status_message_for_correlation_at(
root,
correlation_id,
"steer-reply",
&persisted.reply,
)?;
Ok(persisted)
}
pub(crate) fn append_game_creator_agent_runtime_steer_decision_failure_reply_at(
root: &Path,
state: &AgentRuntimeState,
steer_id: &str,
error: &str,
) -> Result<String, String> {
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"runtime.steer_decision.failure",
)?;
let snapshot =
read_game_creator_agent_runtime_steer_ledger(root, &state.agent_id, &state.run_id)?;
let entry = snapshot
.entries
.get(steer_id)
.ok_or_else(|| "Supervisor steer decision 失败记录未命中持久 steer".to_string())?;
let correlation_id = entry
.identity
.message_id
.as_deref()
.and_then(|message_id| message_id.strip_prefix(AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX))
.ok_or_else(|| "Supervisor steer decision 失败记录缺少消息关联身份".to_string())?;
let reply = "我会继续当前任务。这次没有完成中断判断,你的消息仍会在下一个安全边界进入规划。";
append_game_creator_agent_runtime_public_status_message_for_correlation_at(
root,
correlation_id,
"steer-decision-failed",
reply,
)?;
let safe_error = redact_agent_runtime_error(root, error, 500);
append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.steer_decision.failed",
"agentId": state.agent_id,
"taskId": state.task_id,
"sessionId": state.session_id,
"runId": state.run_id,
"source": state.source,
"steerId": steer_id,
"sequence": entry.identity.sequence,
"errorSha256": format!("{:x}", Sha256::digest(safe_error.as_bytes())),
"errorChars": safe_error.chars().count(),
"failedAt": unix_timestamp(),
}),
)?;
Ok(reply.to_string())
}
pub(crate) fn game_creator_agent_runtime_accepts_steer(state: &AgentRuntimeState) -> bool {
!state.agent_id.starts_with("child-")
&& state.source != AGENT_RUNTIME_ISOLATED_CHILD_SOURCE
@@ -658,6 +856,20 @@ pub(crate) fn steer_game_creator_agent_runtime_task_for_profile_at(
latest_status = status.to_string();
}
}
if state.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
&& state.parent_agent_id.is_none()
&& state.parent_run_id.is_none()
{
let correlation_id = message_id
.strip_prefix(AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX)
.ok_or_else(|| "运行中追加指令消息缺少公开状态关联身份".to_string())?;
append_game_creator_agent_runtime_public_status_message_for_correlation_at(
root,
correlation_id,
"steer-queued",
"收到。我正在判断这条消息是否需要调整当前任务;现有任务会继续运行。",
)?;
}
let was_waiting_for_provider_retry = state.phase == "waiting-for-provider-retry";
if was_waiting_for_provider_retry {
remove_game_creator_agent_runtime_provider_recovery_at(root, &agent_id, run_id)?;
@@ -682,8 +894,7 @@ pub(crate) fn steer_game_creator_agent_runtime_task_for_profile_at(
Some(&format!("steerId={steer_id}")),
)?;
}
let provider_interrupted =
interrupt_game_creator_agent_runtime_provider_request_at(root, &agent_id, run_id)?;
let provider_interrupted = false;
ensure_game_creator_agent_runtime_steer_audit(
root,
&state,
@@ -722,6 +933,9 @@ pub(crate) fn steer_game_creator_agent_runtime_task_for_profile_at(
sequence: entry.identity.sequence,
status: latest_status,
provider_interrupted,
assistant_reply: None,
interrupt_decision: None,
decision_reason: None,
})
}
@@ -772,6 +986,44 @@ pub(crate) fn interrupt_game_creator_agent_runtime_provider_request_at(
Ok(first)
}
pub(crate) fn interrupt_game_creator_agent_runtime_provider_for_decided_steer_at(
root: &Path,
agent_id: &str,
run_id: &str,
steer_id: &str,
) -> Result<bool, String> {
let decision =
read_game_creator_agent_runtime_steer_decision_at(root, agent_id, run_id, steer_id)?
.ok_or_else(|| "Supervisor steer 尚无 LLM 中断判定".to_string())?;
if !decision.interrupt_current_provider {
return Ok(false);
}
let snapshot = read_game_creator_agent_runtime_steer_ledger(root, agent_id, run_id)?;
let entry = snapshot
.entries
.get(steer_id)
.ok_or_else(|| "LLM 中断判定未命中持久 steer".to_string())?;
let key = agent_runtime_provider_interrupt_key(root, agent_id, run_id)?;
let active = {
let registry = game_creator_agent_provider_interrupts()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
registry.get(&key).cloned()
};
let Some(active) = active else {
return Ok(false);
};
if active.applied_steer_cursor >= entry.identity.sequence {
// The provider request was created after this steer was already
// applied. Interrupting it would cancel the new plan, not the stale
// request the LLM evaluated.
return Ok(false);
}
let first = !active.interrupted.swap(true, Ordering::AcqRel);
active.notify.notify_one();
Ok(first)
}
pub(crate) fn interrupt_game_creator_agent_runtime_provider_requests_for_roots(
roots: &[PathBuf],
) -> usize {
@@ -833,13 +1085,17 @@ pub(crate) fn validate_game_creator_agent_runtime_steer_notification_at(
Ok(())
}
pub(in crate::agent) fn register_game_creator_agent_runtime_provider_request(
pub(crate) fn register_game_creator_agent_runtime_provider_request(
root: &Path,
agent_id: &str,
run_id: &str,
applied_steer_cursor: u64,
) -> Result<(String, Arc<AgentRuntimeProviderInterrupt>), String> {
let key = agent_runtime_provider_interrupt_key(root, agent_id, run_id)?;
let active = Arc::new(AgentRuntimeProviderInterrupt::default());
let active = Arc::new(AgentRuntimeProviderInterrupt {
applied_steer_cursor,
..AgentRuntimeProviderInterrupt::default()
});
let mut registry = game_creator_agent_provider_interrupts()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
@@ -849,7 +1105,7 @@ pub(in crate::agent) fn register_game_creator_agent_runtime_provider_request(
Ok((key, active))
}
pub(in crate::agent) fn unregister_game_creator_agent_runtime_provider_request(
pub(crate) fn unregister_game_creator_agent_runtime_provider_request(
key: &str,
active: &Arc<AgentRuntimeProviderInterrupt>,
) {
@@ -1216,12 +1472,14 @@ mod shutdown_tests {
&known_root,
"code-prototype",
"known-run",
0,
)
.expect("register known Provider request");
let (other_key, other_request) = register_game_creator_agent_runtime_provider_request(
&other_root,
"code-prototype",
"other-run",
0,
)
.expect("register other Provider request");
@@ -13,6 +13,13 @@ fn game_creator_agent_runtime_public_status_message_id(
) -> String {
let correlation_id =
game_creator_agent_runtime_message_correlation_id(agent_id, session_id, run_id);
game_creator_agent_runtime_public_status_message_id_for_correlation(&correlation_id, status)
}
fn game_creator_agent_runtime_public_status_message_id_for_correlation(
correlation_id: &str,
status: &str,
) -> String {
let status_fingerprint = format!("{:x}", Sha256::digest(status.as_bytes()));
format!(
"{AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX}{correlation_id}-{}",
@@ -62,6 +69,28 @@ pub(crate) fn append_game_creator_agent_runtime_public_status_message_at(
.map(|_| ())
}
pub(crate) fn append_game_creator_agent_runtime_public_status_message_for_correlation_at(
root: &Path,
correlation_id: &str,
status: &str,
content: &str,
) -> Result<(), String> {
let message_id =
game_creator_agent_runtime_public_status_message_id_for_correlation(correlation_id, status);
append_local_conversation_message_for_session_idempotent_at(
root,
None,
None,
LocalConversationMessage {
role: "assistant".to_string(),
content: content.to_string(),
agent_id: None,
},
&message_id,
)
.map(|_| ())
}
pub(crate) fn append_game_creator_agent_runtime_terminal_public_message_at(
root: &Path,
state: &AgentRuntimeState,
@@ -1262,7 +1262,6 @@ fn runtime_tool_input_schema(tool: &str) -> Value {
"missingAssetSlots": {
"type": "array",
"maxItems": 2,
"uniqueItems": true,
"items": { "type": "string", "enum": ["art-spec", "core-spritesheet"] }
}
}
@@ -1386,7 +1385,7 @@ mod tests {
let Some(object) = schema.as_object() else {
return;
};
for keyword in ["oneOf", "anyOf", "allOf", "not"] {
for keyword in ["oneOf", "anyOf", "allOf", "not", "uniqueItems"] {
if object.contains_key(keyword) {
issues.push(format!(
"strict schema contains unsupported {keyword} at {path}"
@@ -289,6 +289,7 @@ pub(crate) fn take_cli_runtime_config_dir(
pub(crate) fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus) -> Vec<String> {
let mut lines = vec![
format!("agent.mode={}", status.agent_mode),
format!("llm.configured={}", status.configured),
format!("llm.apiKeyPresent={}", status.api_key_present),
format!(
@@ -12,6 +12,18 @@ pub(crate) fn init_local_game_project(
init_local_game_project_at(root, project_id.trim(), name.trim())
}
#[tauri::command]
pub(crate) fn import_local_godot_project(
project_path: String,
project_id: String,
name: String,
) -> Result<InitLocalProjectResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.create")?;
let _lock = acquire_project_write_lock(root, "project.create")?;
import_local_godot_project_at(root, project_id.trim(), name.trim())
}
#[tauri::command]
pub(crate) fn is_local_project_directory_non_empty(project_path: String) -> Result<bool, String> {
let root = Path::new(project_path.trim());
@@ -58,6 +70,7 @@ pub(crate) fn inspect_local_project_directory(
exists: root.exists(),
is_directory: root.is_dir(),
is_game_creator_project: is_game_creator_project_directory(root),
is_godot_project: is_godot_project_directory(root),
project_name: game_creator_project_name(root),
manifest_error: game_creator_project_manifest_error(root),
recent_run_status: recent_run_trace.as_ref().map(|trace| trace.status.clone()),
@@ -65,6 +78,16 @@ pub(crate) fn inspect_local_project_directory(
})
}
pub(crate) fn is_godot_project_directory(root: &Path) -> bool {
if !root.is_dir() {
return false;
}
let project_file = root.join("project.godot");
fs::symlink_metadata(project_file)
.map(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
.unwrap_or(false)
}
pub(crate) fn is_game_creator_project_directory(root: &Path) -> bool {
if !root.is_dir() {
return false;
@@ -675,7 +698,7 @@ pub(crate) fn clear_game_creator_agent_goal(
}
#[tauri::command]
pub(crate) fn steer_game_creator_agent_runtime_task(
pub(crate) async fn steer_game_creator_agent_runtime_task(
project_path: String,
agent_id: String,
session_id: String,
@@ -717,13 +740,90 @@ pub(crate) fn steer_game_creator_agent_runtime_task(
run_profile.as_deref(),
"tauri",
)?;
if !result.provider_interrupted
&& external_agent_runner_enabled()
&& !external_agent_runner_is_server_process()
{
result.provider_interrupted =
let external_runner =
external_agent_runner_enabled() && !external_agent_runner_is_server_process();
let wake_external_runner_without_interrupt = || -> Result<(), String> {
let provider_interrupted =
steer_external_agent_runner(root, agent_id.trim(), run_id.trim(), steer_id.trim())?;
if provider_interrupted {
return Err("Agent Runner 的 runtime.steer 非法中断了 Provider".to_string());
}
Ok(())
};
let state = result.runtime.state.clone();
let requires_supervisor_decision = state.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
&& state.parent_agent_id.is_none()
&& state.parent_run_id.is_none();
if requires_supervisor_decision {
match decide_game_creator_agent_runtime_steer_at(
root,
&state,
steer_id.trim(),
result.sequence,
instruction.trim(),
)
.await
{
Ok(decision) => {
result.assistant_reply = Some(decision.reply.clone());
result.interrupt_decision = Some(decision.interrupt_current_provider);
result.decision_reason = Some(decision.reason.clone());
if decision.interrupt_current_provider {
result.provider_interrupted = if external_runner {
interrupt_external_agent_runner_provider_for_steer_decision(
root,
agent_id.trim(),
run_id.trim(),
steer_id.trim(),
)?
} else {
interrupt_game_creator_agent_runtime_provider_for_decided_steer_at(
root,
agent_id.trim(),
run_id.trim(),
steer_id.trim(),
)?
};
if !external_runner {
wake_pending_game_creator_agent_background_tasks_at(root)
.map_err(|error| error.to_string())?;
}
} else if external_runner {
wake_external_runner_without_interrupt()?;
} else {
wake_pending_game_creator_agent_background_tasks_at(root)
.map_err(|error| error.to_string())?;
}
}
Err(error) => {
result.assistant_reply = Some(
append_game_creator_agent_runtime_steer_decision_failure_reply_at(
root,
&state,
steer_id.trim(),
&error,
)?,
);
result.decision_reason = Some("decision-failed".to_string());
if external_runner {
wake_external_runner_without_interrupt()?;
} else {
wake_pending_game_creator_agent_background_tasks_at(root)
.map_err(|error| error.to_string())?;
}
}
}
} else if external_runner {
wake_external_runner_without_interrupt()?;
} else {
wake_pending_game_creator_agent_background_tasks_at(root)
.map_err(|error| error.to_string())?;
}
result.runtime = read_game_creator_agent_runtime_for_session_at(
root,
agent_id.trim(),
Some(session_id.trim()),
)?;
Ok(result)
}
@@ -205,6 +205,7 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi
Ok(config) => config,
Err(error) => {
return GameCreatorLlmConfigStatus {
agent_mode: default_game_creator_agent_mode(),
configured: false,
api_key_present: false,
base_url: None,
@@ -224,6 +225,9 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi
};
}
};
if app_config.agent_mode != GAME_CREATOR_AGENT_MODE_PROVIDER {
return check_game_creator_codex_config(&app_config);
}
let global_route_shape_error =
validate_game_creator_llm_web_search_config(&app_config.llm, "llm").err();
let mut status = check_game_creator_llm_config_values(&app_config.llm, "llm");
@@ -245,6 +249,7 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi
.map(|definition| {
let llm = resolve_game_creator_llm_config_for_agent(&app_config, &definition.agent_id);
check_game_creator_agent_llm_config_values(
&app_config.agent_mode,
&definition.agent_id,
&definition.label,
&llm,
@@ -279,6 +284,120 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi
status
}
fn check_game_creator_codex_config(
app_config: &GameCreatorAppConfig,
) -> GameCreatorLlmConfigStatus {
let cli_error = check_game_creator_codex_cli_available()
.and_then(|()| {
if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER {
check_game_creator_codex_app_server_available()
} else {
Ok(())
}
})
.err();
let global_route_error = game_creator_codex_app_server_llm_route_error(
&app_config.agent_mode,
&app_config.llm,
"llm",
);
let mut status = check_game_creator_llm_config_values(&app_config.llm, "llm");
status.agent_mode = app_config.agent_mode.clone();
status.configured = cli_error.is_none() && global_route_error.is_none();
status.error = cli_error.clone().or(global_route_error);
status.agents = game_creator_llm_agent_status_definitions()
.iter()
.map(|definition| {
let llm = resolve_game_creator_llm_config_for_agent(app_config, &definition.agent_id);
let mut agent = check_game_creator_agent_llm_config_values(
&app_config.agent_mode,
&definition.agent_id,
&definition.label,
&llm,
);
let route_error = game_creator_codex_app_server_llm_route_error(
&app_config.agent_mode,
&llm,
&format!("agentLlm.{}", definition.agent_id),
);
agent.configured = cli_error.is_none() && route_error.is_none();
agent.error = cli_error.clone().or(route_error);
agent
})
.collect();
let required_errors = status
.agents
.iter()
.filter(|agent| GAME_CREATOR_REQUIRED_LLM_AGENT_IDS.contains(&agent.agent_id.as_str()))
.filter_map(|agent| {
agent
.error
.as_ref()
.map(|error| format!("{}{error}", agent.label))
})
.collect::<Vec<_>>();
if !required_errors.is_empty() {
status.configured = false;
let mut errors = status.error.take().into_iter().collect::<Vec<_>>();
errors.extend(required_errors);
errors.dedup();
status.error = Some(errors.join(""));
}
status
}
pub(crate) fn game_creator_codex_app_server_llm_route_error(
agent_mode: &str,
llm: &GameCreatorLlmConfig,
config_path: &str,
) -> Option<String> {
if agent_mode != GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER {
return None;
}
if llm.api_kind != "openai_responses" {
return Some(format!(
"配置项 {config_path}.apiKind={} 不能由 codex_app_server 直接映射;请使用 openai_responses 或切换 provider 模式",
llm.api_kind
));
}
llm.web_search_enabled.then(|| {
format!(
"配置项 {config_path}.webSearchEnabled 在 codex_app_server 模式下必须为 false;该模式由 AGC Runtime 独占工具执行,不能启用 Codex 原生联网工具"
)
})
}
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(())
}
fn check_game_creator_codex_app_server_available() -> Result<(), String> {
let output = std::process::Command::new("codex")
.args(["app-server", "--help"])
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.map_err(|_| "Codex CLI 不支持 app-server 子命令".to_string())?;
if !output.status.success()
|| !String::from_utf8_lossy(&output.stdout).contains("codex app-server")
{
return Err("当前 Codex CLI 不支持 app-server 子命令,请升级 Codex CLI".to_string());
}
Ok(())
}
pub(crate) fn check_game_creator_llm_config_values(
config: &GameCreatorLlmConfig,
config_path: &str,
@@ -317,6 +436,7 @@ pub(crate) fn check_game_creator_llm_config_values(
});
GameCreatorLlmConfigStatus {
agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
configured: error.is_none(),
api_key_present,
base_url,
@@ -339,6 +459,7 @@ pub(crate) fn check_game_creator_llm_config_values(
}
pub(crate) fn check_game_creator_agent_llm_config_values(
agent_mode: &str,
agent_id: &str,
label: &str,
config: &GameCreatorLlmConfig,
@@ -359,6 +480,7 @@ pub(crate) fn check_game_creator_agent_llm_config_values(
}
}
GameCreatorAgentLlmConfigStatus {
agent_mode: agent_mode.to_string(),
agent_id: agent_id.to_string(),
label: label.to_string(),
configured: status.configured,
@@ -1135,11 +1257,53 @@ pub(crate) fn configure_game_creator_runtime_config_dir(
if !config_path.exists() {
write_game_creator_config_atomically(&config_path, DEFAULT_GAME_CREATOR_APP_CONFIG_JSON)
.map_err(std::io::Error::other)?;
} else {
migrate_legacy_game_creator_agent_mode(&config_path).map_err(std::io::Error::other)?;
}
set_game_creator_runtime_config_dir(config_dir);
Ok(())
}
pub(crate) fn legacy_game_creator_agent_mode(
config: &GameCreatorAppConfigFile,
) -> Option<&'static str> {
if config.agent_mode.is_some() {
return None;
}
let responses_only = config
.llm
.as_ref()
.and_then(|llm| llm.api_kind.as_deref())
.map(|kind| kind.trim().to_ascii_lowercase().replace('-', "_") == "openai_responses")
.unwrap_or(true)
&& config
.agent_llm
.as_ref()
.into_iter()
.flat_map(|agents| agents.values())
.filter_map(|llm| llm.api_kind.as_deref())
.all(|kind| kind.trim().to_ascii_lowercase().replace('-', "_") == "openai_responses");
Some(if responses_only {
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER
} else {
GAME_CREATOR_AGENT_MODE_PROVIDER
})
}
fn migrate_legacy_game_creator_agent_mode(path: &Path) -> Result<(), String> {
let content = fs::read_to_string(path)
.map_err(|error| format!("读取客户端配置失败:{}: {error}", path.display()))?;
let mut config = serde_json::from_str::<GameCreatorAppConfigFile>(&content)
.map_err(|error| format!("解析客户端配置失败:{}: {error}", path.display()))?;
let Some(agent_mode) = legacy_game_creator_agent_mode(&config) else {
return Ok(());
};
config.agent_mode = Some(agent_mode.to_string());
let content = serde_json::to_string_pretty(&config)
.map_err(|error| format!("序列化客户端配置失败:{error}"))?;
write_game_creator_config_atomically(path, &format!("{content}\n"))
}
pub(crate) fn game_creator_runtime_config_dir_lock() -> &'static Mutex<Option<PathBuf>> {
GAME_CREATOR_RUNTIME_CONFIG_DIR.get_or_init(|| Mutex::new(None))
}
@@ -1256,6 +1420,9 @@ pub(crate) fn merge_game_creator_config_file(
.map_err(|error| format!("读取客户端配置失败:{}: {error}", read_path.display()))?;
let file_config = serde_json::from_str::<GameCreatorAppConfigFile>(&content)
.map_err(|error| format!("解析客户端配置失败:{}: {error}", read_path.display()))?;
if let Some(agent_mode) = file_config.agent_mode {
config.agent_mode = agent_mode;
}
if let Some(llm) = file_config.llm {
merge_game_creator_llm_config(&mut config.llm, llm);
}
@@ -1508,6 +1675,7 @@ pub(crate) fn trim_config_string(value: &str) -> Option<String> {
pub(crate) fn normalize_game_creator_app_config(
mut config: GameCreatorAppConfig,
) -> Result<GameCreatorAppConfig, String> {
config.agent_mode = normalize_game_creator_agent_mode(&config.agent_mode)?;
config.llm.api_key = config.llm.api_key.trim().to_string();
config.llm.base_url =
trim_config_string(&config.llm.base_url).ok_or_else(|| llm_base_url_config_error("llm"))?;
@@ -1545,6 +1713,19 @@ pub(crate) fn normalize_game_creator_app_config(
Ok(config)
}
pub(crate) fn normalize_game_creator_agent_mode(value: &str) -> Result<String, String> {
match value.trim() {
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER => {
Ok(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string())
}
GAME_CREATOR_AGENT_MODE_CODEX_CLI => Ok(GAME_CREATOR_AGENT_MODE_CODEX_CLI.to_string()),
GAME_CREATOR_AGENT_MODE_PROVIDER => Ok(GAME_CREATOR_AGENT_MODE_PROVIDER.to_string()),
value => Err(format!(
"配置项 agentMode 无效:{value},请使用 codex_app_server、codex_cli 或 provider"
)),
}
}
pub(crate) fn normalize_game_creator_llm_patch_config(
agent_id: &str,
mut patch: GameCreatorLlmConfigFile,
@@ -124,6 +124,7 @@ struct LocalProjectDirectoryStatus {
exists: bool,
is_directory: bool,
is_game_creator_project: bool,
is_godot_project: bool,
project_name: Option<String>,
manifest_error: Option<String>,
recent_run_status: Option<String>,
@@ -621,6 +622,12 @@ struct AgentRuntimeSteerResult {
sequence: u64,
status: String,
provider_interrupted: bool,
#[serde(skip_serializing_if = "Option::is_none")]
assistant_reply: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
interrupt_decision: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
decision_reason: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
@@ -666,6 +673,7 @@ struct GameCreatorAgentProgressEvent {
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorLlmConfigStatus {
agent_mode: String,
configured: bool,
api_key_present: bool,
base_url: Option<String>,
@@ -687,6 +695,7 @@ struct GameCreatorLlmConfigStatus {
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorAgentLlmConfigStatus {
agent_mode: String,
agent_id: String,
label: String,
configured: bool,
@@ -709,6 +718,7 @@ struct GameCreatorAgentLlmConfigStatus {
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorAppConfigFile {
agent_mode: Option<String>,
llm: Option<GameCreatorLlmConfigFile>,
agent_llm: Option<BTreeMap<String, GameCreatorLlmConfigFile>>,
editor_api: Option<GameCreatorEditorApiConfigFile>,
@@ -756,6 +766,8 @@ struct GameCreatorEditorApiConfigFile {
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorAppConfig {
#[serde(default = "default_game_creator_agent_mode")]
agent_mode: String,
llm: GameCreatorLlmConfig,
#[serde(default)]
agent_llm: BTreeMap<String, GameCreatorLlmConfigFile>,
@@ -1196,6 +1208,9 @@ const DEFAULT_GAME_INDEX_HTML: &str = r#"<!doctype html>
const DEFAULT_EDITOR_BASE_URL: &str = "http://127.0.0.1:3000";
const GAME_CREATOR_CONFIG_FILE_NAME: &str = "game-creator.config.json";
const GAME_CREATOR_LOCAL_CONFIG_FILE_NAME: &str = "game-creator.config.local.json";
const GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER: &str = "codex_app_server";
const GAME_CREATOR_AGENT_MODE_CODEX_CLI: &str = "codex_cli";
const GAME_CREATOR_AGENT_MODE_PROVIDER: &str = "provider";
const DEFAULT_GAME_CREATOR_LLM_BASE_URL: &str = "https://api.openai.com/v1";
const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = "gpt-4.1";
const DEFAULT_GAME_CREATOR_LLM_API_KIND: &str = "openai_responses";
@@ -1205,6 +1220,10 @@ const DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT: u64 = 64_000;
const DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT: u64 = 12_000;
const DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES: u32 = 2;
fn default_game_creator_agent_mode() -> String {
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string()
}
fn default_game_creator_llm_context_window_tokens() -> u64 {
DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS
}
@@ -1277,6 +1296,7 @@ static GAME_CREATOR_RUNTIME_CONFIG_DIR: OnceLock<Mutex<Option<PathBuf>>> = OnceL
impl Default for GameCreatorAppConfig {
fn default() -> Self {
Self {
agent_mode: default_game_creator_agent_mode(),
llm: GameCreatorLlmConfig::default(),
agent_llm: BTreeMap::new(),
editor_api: GameCreatorEditorApiConfig::default(),
@@ -2017,6 +2037,7 @@ fn main() {
let app = tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_clipboard_manager::init())
.manage(game_creator_preview_registry())
.setup(move |app| {
@@ -2150,6 +2171,7 @@ fn main() {
})
.invoke_handler(tauri::generate_handler![
init_local_game_project,
import_local_godot_project,
is_local_project_directory_non_empty,
inspect_local_project_directory,
pick_local_project_directory,

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