From 5949c2fd582719e26f31d6d400ab5c46cd22999d Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 10 Aug 2026 20:14:30 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E8=A7=84=E8=8C=83=E9=80=80=E5=BD=B9?= =?UTF-8?q?=E5=AF=B9=E8=B1=A1=E5=9B=9B=E4=B8=8D=E5=86=99=E5=8E=9F=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 明确无现役依赖对象不写历史兼容代码与退役行为测试 禁止墓碑注释和墓碑文档并要求权威文档表达当前状态 保留公开契约、持久化数据和正式迁移的必要兼容边界 --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 694ec26fe..f7969ee42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,12 @@ - 后续新增 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`;历史文档不要求批量重命名,除非本次任务明确涉及。 - 工程修改要同步更新对应 `docs/` 文档;产生长期有效的架构约定、接口变化、排障经验、开发流程或协作规则时,同步更新 `docs/project-memory/shared-memory/`。 - 默认保持系统简洁:优先复用、修改、扩展现有系统、页面和公共组件,不新建平行系统或平行页面。 +- 对已明确退役且不存在现役调用方、公开契约、持久化数据、活跃实例或迁移要求的对象,坚持“四不写”: + 1. 不写历史兼容代码。 + 2. 不写用于维持退役行为的防御性兼容测试。 + 3. 不写仅说明其曾存在或已删除的墓碑注释。 + 4. 不写仅记录其已删除的墓碑文档;直接将权威文档更新为当前状态。 +- 公开 API、持久化数据、SpacetimeDB schema、跨版本重放、活跃实例和正式迁移不适用“四不写”;必要兼容应最小化、白名单化并配套契约或迁移测试,迁移完成后同步删除兼容实现与对应测试。 - UI 面板中不要默认写功能说明、规则描述或开发解释文案;移动端优先,同时保证网页端可正常显示和操作。 - 点击按钮弹出独立面板的设计,不要实现成在当前面板下面追加内容。 From 5550e24f83f85bbf34ce816d5ee0227e4c9b5353 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 10 Aug 2026 20:58:19 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E6=8E=A5=E5=85=A5=20Codex=20=E8=8A=82?= =?UTF-8?q?=E7=82=B9=20Agent=20=E5=B9=B6=E5=AE=8C=E5=96=84=E6=80=BB?= =?UTF-8?q?=E6=8E=A7=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 Codex app-server 与 CLI 节点执行模式并保留 Provider 回退 加固节点凭据隔离、终态未知回收、进程生命周期与持久恢复边界 修复资源画布等价刷新闪烁 为 Supervisor steer 增加 LLM 回复与条件中断 补齐配置界面、测试和技术文档 --- .../game-creator.config.json | 1 + .../scripts/check-config.mjs | 14 +- .../scripts/game-creator-config-wizard.mjs | 33 +- .../src-tauri/src/agent.rs | 8 + .../src-tauri/src/agent/codex_app_server.rs | 1975 +++++++++++++++++ .../src-tauri/src/agent/codex_cli.rs | 783 +++++++ .../src-tauri/src/agent/interaction.rs | 200 ++ .../runtime_actions/provider_final_reply.rs | 69 +- .../runtime_actions/response_stream_tests.rs | 30 +- .../src-tauri/src/agent/runtime_protocol.rs | 8 +- .../src/agent/runtime_protocol/models.rs | 7 +- .../agent/runtime_protocol/provider_retry.rs | 252 ++- .../runtime_protocol/real_e2e_checkpoint.rs | 20 + .../src/agent/runtime_protocol/steering.rs | 270 ++- .../src-tauri/src/agent/runtime_state.rs | 29 + .../src-tauri/src/cli.rs | 1 + .../src-tauri/src/commands.rs | 89 +- .../src-tauri/src/config.rs | 181 ++ .../src-tauri/src/main.rs | 19 + .../src-tauri/src/runner.rs | 5 +- .../src-tauri/src/runner/client.rs | 23 + .../src-tauri/src/runner/dispatch.rs | 24 +- .../src-tauri/src/runner/server.rs | 7 + .../src-tauri/src/runner/tests.rs | 303 ++- .../src-tauri/src/swarm_cli/turn_dispatch.rs | 4 +- .../src-tauri/src/swarm_cli/turn_wait.rs | 20 +- .../src-tauri/src/tests/configuration.rs | 126 ++ .../src-tauri/src/tests/mod.rs | 12 + .../src-tauri/src/tests/provider.rs | 303 ++- .../src-tauri/src/tests/response_stream.rs | 6 + .../tests/runtime_actions/task_lifecycle.rs | 5 +- .../src-tauri/src/tests/runtime_state.rs | 103 + apps/ai-game-creator-shell/src/App.tsx | 30 +- apps/ai-game-creator-shell/src/app/types.ts | 10 + .../src/features/agent-runtime/model.ts | 22 +- .../app-shell/useDeveloperAgentPanel.ts | 40 +- .../project-summary/agentPresentation.ts | 62 +- .../runtime-config/RuntimeConfigDialog.tsx | 906 ++++---- .../src/view/project-development/index.tsx | 81 +- .../tests/agentRuntimeModel.test.ts | 54 + .../tests/agentSwarmTestEntry.test.ts | 2 + .../developer-agent-window.suite.ts | 2 +- .../appSurface/project-conversation.suite.ts | 2 +- .../appSurface/project-development.suite.ts | 153 +- .../appSurface/runtime-settings.suite.ts | 152 ++ .../appSurface/supervisor-runtime.suite.ts | 3 + .../shared-memory/decision-log.md | 23 + docs/project-memory/shared-memory/pitfalls.md | 47 + ...案】AI游戏创作Agent Runtime V1.1-2026-07-12.md | 52 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 27 + 50 files changed, 5998 insertions(+), 600 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs diff --git a/apps/ai-game-creator-shell/game-creator.config.json b/apps/ai-game-creator-shell/game-creator.config.json index 6e225ac31..ae9d1bedf 100644 --- a/apps/ai-game-creator-shell/game-creator.config.json +++ b/apps/ai-game-creator-shell/game-creator.config.json @@ -1,4 +1,5 @@ { + "agentMode": "codex_app_server", "llm": { "apiKey": "", "baseUrl": "https://api.openai.com/v1", diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index be97f7404..41114ddbd 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -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', diff --git a/apps/ai-game-creator-shell/scripts/game-creator-config-wizard.mjs b/apps/ai-game-creator-shell/scripts/game-creator-config-wizard.mjs index dee615c1e..49b12d9fa 100644 --- a/apps/ai-game-creator-shell/scripts/game-creator-config-wizard.mjs +++ b/apps/ai-game-creator-shell/scripts/game-creator-config-wizard.mjs @@ -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( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 946f634ff..7354d0283 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -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() +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs new file mode 100644 index 000000000..eeedc2dfd --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -0,0 +1,1975 @@ +use super::*; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::process::Stdio; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +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"; +const GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES: usize = 4 * 1024 * 1024; +const GAME_CREATOR_CODEX_APP_SERVER_STDERR_RECORD_MAX_BYTES: usize = 256 * 1024; +const GAME_CREATOR_CODEX_APP_SERVER_AUTH_MAX_BYTES: usize = 1024 * 1024; +const GAME_CREATOR_CODEX_APP_SERVER_BACKLOG_TURN_MAX: usize = 128; +const GAME_CREATOR_CODEX_APP_SERVER_POOL_MAX: usize = 32; +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:"; + +type RpcResult = Result; + +struct CodexPendingRpc { + sender: oneshot::Sender, + turn_start_cancellation: Option>, +} + +enum CodexAppServerCredential { + AppDataKey { + fingerprint: String, + }, + AuthBridge { + auth_json: Vec, + fingerprint: String, + }, +} + +impl CodexAppServerCredential { + fn fingerprint(&self) -> &str { + match self { + Self::AppDataKey { fingerprint } | Self::AuthBridge { fingerprint, .. } => fingerprint, + } + } + + fn uses_app_data_key(&self) -> bool { + matches!(self, Self::AppDataKey { .. }) + } +} + +struct CodexAppServerStderrSummary { + byte_len: u64, + sha256: Sha256, + current_record_bytes: usize, + classification: &'static str, +} + +impl CodexAppServerStderrSummary { + fn new() -> Self { + Self { + byte_len: 0, + sha256: Sha256::new(), + current_record_bytes: 0, + classification: "empty", + } + } + + fn observe(&mut self, bytes: &[u8]) -> bool { + self.byte_len = self.byte_len.saturating_add(bytes.len() as u64); + self.sha256.update(bytes); + if self.classification == "empty" && !bytes.is_empty() { + self.classification = "nonempty"; + } + for byte in bytes { + if *byte == b'\n' { + self.current_record_bytes = 0; + } else { + self.current_record_bytes = self.current_record_bytes.saturating_add(1); + if self.current_record_bytes > GAME_CREATOR_CODEX_APP_SERVER_STDERR_RECORD_MAX_BYTES + { + self.classification = "oversized-record"; + return true; + } + } + } + false + } + + fn mark_read_failure(&mut self) { + self.classification = "read-failed"; + } + + fn diagnostic(&self) -> String { + format!( + "stderrClass={};stderrBytes={};stderrSha256={:x}", + self.classification, + self.byte_len, + self.sha256.clone().finalize() + ) + } +} + +struct CodexTurnStartCancellation { + inner: Weak, + thread_id: String, + turn_id: std::sync::Mutex>, + cancelled: AtomicBool, + interrupt_sent: AtomicBool, +} + +impl CodexTurnStartCancellation { + fn new(inner: &Arc, thread_id: &str) -> Self { + Self { + inner: Arc::downgrade(inner), + thread_id: thread_id.to_string(), + turn_id: std::sync::Mutex::new(None), + cancelled: AtomicBool::new(false), + interrupt_sent: AtomicBool::new(false), + } + } + + fn observe_turn_id(&self, turn_id: &str) { + if let Ok(mut slot) = self.turn_id.lock() { + *slot = Some(turn_id.to_string()); + } + self.maybe_interrupt(); + } + + fn cancel(&self) { + self.cancelled.store(true, Ordering::Release); + self.maybe_interrupt(); + } + + fn maybe_interrupt(&self) { + if !self.cancelled.load(Ordering::Acquire) { + return; + } + let turn_id = self + .turn_id + .lock() + .ok() + .and_then(|slot| slot.as_ref().cloned()); + let Some(turn_id) = turn_id else { + return; + }; + if self + .interrupt_sent + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return; + } + let Some(inner) = self.inner.upgrade() else { + return; + }; + let connection = CodexAppServerConnection { inner }; + let thread_id = self.thread_id.clone(); + tokio::spawn(async move { + let _ = connection + .request( + "turn/interrupt", + serde_json::json!({ "threadId": thread_id, "turnId": turn_id }), + ) + .await; + }); + } +} + +fn game_creator_codex_app_server_terminal_unknown( + detail: impl AsRef, +) -> platform_llm::LlmError { + platform_llm::LlmError::Transport(format!( + "{GAME_CREATOR_CODEX_APP_SERVER_TERMINAL_UNKNOWN_PREFIX} {}", + detail.as_ref() + )) +} + +async fn isolate_game_creator_codex_app_server_terminal_unknown( + inner: &Arc, + detail: impl Into, +) -> platform_llm::LlmError { + let detail = detail.into(); + game_creator_codex_app_server_pool() + .lock() + .await + .retain(|_, candidate| !Arc::ptr_eq(candidate, inner)); + shutdown_game_creator_codex_app_server_inner(inner, &detail).await; + game_creator_codex_app_server_terminal_unknown(detail) +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct CodexNodeThreadKey { + project_id: String, + agent_id: String, + session_id: String, + run_id: String, +} + +impl From<&AgentRuntimeProviderRequestSnapshot> for CodexNodeThreadKey { + fn from(snapshot: &AgentRuntimeProviderRequestSnapshot) -> Self { + Self { + project_id: snapshot.project_id.clone(), + agent_id: snapshot.agent_id.clone(), + session_id: snapshot.session_id.clone(), + run_id: snapshot.run_id.clone(), + } + } +} + +#[derive(Clone, Debug)] +enum CodexTurnEvent { + AgentMessageDelta(String), + Item(serde_json::Value), + Terminal(serde_json::Value), + TransportClosed(String), +} + +struct CodexAppServerInner { + writer: Mutex, + child: Mutex>, + pending: Mutex>, + turns: Mutex>>, + turn_backlog: Mutex>>, + threads: Mutex>, + stderr_summary: Mutex, + turn_gate: Mutex<()>, + next_request_id: AtomicU64, + last_used: AtomicU64, + closed: AtomicBool, + _working_dir: tempfile::TempDir, +} + +#[derive(Clone)] +struct CodexAppServerConnection { + inner: Arc, +} + +static GAME_CREATOR_CODEX_APP_SERVER_POOL: OnceLock< + Mutex>>, +> = OnceLock::new(); +static GAME_CREATOR_CODEX_APP_SERVER_USAGE_CLOCK: AtomicU64 = AtomicU64::new(1); + +#[derive(Clone, Debug)] +struct CodexThreadEntry { + thread_id: String, + last_used: u64, + active_uses: usize, +} + +fn next_game_creator_codex_app_server_usage_tick() -> u64 { + GAME_CREATOR_CODEX_APP_SERVER_USAGE_CLOCK.fetch_add(1, Ordering::Relaxed) +} + +fn game_creator_codex_app_server_pool() -> &'static Mutex>> +{ + GAME_CREATOR_CODEX_APP_SERVER_POOL.get_or_init(|| Mutex::new(HashMap::new())) +} + +pub(in crate::agent) fn game_creator_codex_app_server_protocol_identity() -> &'static str { + GAME_CREATOR_CODEX_APP_SERVER_PROTOCOL +} + +fn game_creator_codex_app_server_pool_key( + llm: &GameCreatorLlmConfig, + codex_cli_version: &str, + snapshot: &AgentRuntimeProviderRequestSnapshot, + credential_fingerprint: &str, +) -> String { + let stable = serde_json::json!({ + "credentialFingerprint": credential_fingerprint, + "baseUrl": llm.base_url, + "apiKind": llm.api_kind, + "codexCliVersion": codex_cli_version, + "node": { + "projectId": snapshot.project_id, + "agentId": snapshot.agent_id, + "sessionId": snapshot.session_id, + "runId": snapshot.run_id, + }, + }); + format!("{:x}", Sha256::digest(stable.to_string().as_bytes())) +} + +fn find_game_creator_codex_auth_path() -> Option { + std::env::var_os("CODEX_HOME") + .map(std::path::PathBuf::from) + .or_else(|| { + std::env::var_os("HOME").map(|home| std::path::PathBuf::from(home).join(".codex")) + }) + .or_else(|| { + std::env::var_os("USERPROFILE") + .map(|home| std::path::PathBuf::from(home).join(".codex")) + }) + .map(|home| home.join("auth.json")) + .filter(|path| path.is_file()) +} + +fn read_game_creator_codex_auth_bridge( + source_auth: &std::path::Path, +) -> Result { + let mut auth_json = Vec::new(); + { + use std::io::Read; + std::fs::File::open(source_auth) + .map_err(|_| { + platform_llm::LlmError::InvalidConfig( + "读取 Codex CLI 登录态失败;请重新登录 Codex CLI 后重试".to_string(), + ) + })? + .take(GAME_CREATOR_CODEX_APP_SERVER_AUTH_MAX_BYTES.saturating_add(1) as u64) + .read_to_end(&mut auth_json) + .map_err(|_| { + platform_llm::LlmError::InvalidConfig( + "读取 Codex CLI 登录态失败;请重新登录 Codex CLI 后重试".to_string(), + ) + })?; + } + if auth_json.is_empty() || auth_json.len() > GAME_CREATOR_CODEX_APP_SERVER_AUTH_MAX_BYTES { + return Err(platform_llm::LlmError::InvalidConfig( + "Codex CLI 登录态文件为空或超过大小上限;请重新登录 Codex CLI 后重试".to_string(), + )); + } + Ok(CodexAppServerCredential::AuthBridge { + fingerprint: format!("auth-bridge:{:x}", Sha256::digest(&auth_json)), + auth_json, + }) +} + +fn resolve_game_creator_codex_app_server_credential( + llm: &GameCreatorLlmConfig, +) -> Result { + if !llm.api_key.trim().is_empty() { + return Ok(CodexAppServerCredential::AppDataKey { + fingerprint: format!("app-data-key:{:x}", Sha256::digest(llm.api_key.as_bytes())), + }); + } + let source_auth = find_game_creator_codex_auth_path().ok_or_else(|| { + platform_llm::LlmError::InvalidConfig( + "codex_app_server 未配置 API Key,且未找到可桥接的 Codex CLI 登录态;请先登录 Codex CLI 或填写 Agent LLM API Key" + .to_string(), + ) + })?; + read_game_creator_codex_auth_bridge(&source_auth) +} + +fn game_creator_codex_app_server_validate_llm_config( + llm: &GameCreatorLlmConfig, +) -> Result<(), platform_llm::LlmError> { + if llm.api_kind != "openai_responses" { + return Err(platform_llm::LlmError::InvalidConfig(format!( + "codex_app_server 仅支持 apiKind=openai_responses;当前 apiKind={},请改用 provider 模式", + llm.api_kind + ))); + } + if llm.web_search_enabled { + return Err(platform_llm::LlmError::InvalidConfig( + "codex_app_server 模式下 webSearchEnabled 必须为 false;AGC Runtime 是唯一 ToolHost" + .to_string(), + )); + } + Ok(()) +} + +fn quoted_toml_string(value: &str) -> Result { + serde_json::to_string(value).map_err(|error| { + platform_llm::LlmError::InvalidConfig(format!("序列化 Codex app-server 配置失败:{error}")) + }) +} + +fn configure_game_creator_codex_app_server_command( + command: &mut tokio::process::Command, + llm: &GameCreatorLlmConfig, +) -> Result<(), platform_llm::LlmError> { + command + .arg("app-server") + .arg("--stdio") + .arg("-c") + .arg("mcp_servers={}") + .arg("-c") + .arg("web_search=\"disabled\"") + .arg("-c") + .arg("agents.enabled=false"); + for feature in [ + "apps", + "browser_use", + "browser_use_external", + "browser_use_full_cdp_access", + "computer_use", + "goals", + "image_generation", + "in_app_browser", + "plugins", + "remote_plugin", + "shell_tool", + "skill_search", + "tool_suggest", + "unified_exec", + "view_image", + "workspace_dependencies", + ] { + command.arg("--disable").arg(feature); + } + if !llm.api_key.trim().is_empty() { + command + .arg("-c") + .arg(format!( + "model_provider={}", + quoted_toml_string(GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID)? + )) + .arg("-c") + .arg(format!( + "model_providers.{GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID}.name={}", + quoted_toml_string("Genarrative AGC")? + )) + .arg("-c") + .arg(format!( + "model_providers.{GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID}.base_url={}", + quoted_toml_string(llm.base_url.trim_end_matches('/'))? + )) + .arg("-c") + .arg(format!( + "model_providers.{GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID}.env_key={}", + quoted_toml_string(GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV)? + )) + .arg("-c") + .arg(format!( + "model_providers.{GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID}.wire_api={}", + quoted_toml_string("responses")? + )) + .env(GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV, &llm.api_key); + } + Ok(()) +} + +fn prepare_isolated_game_creator_codex_home( + root: &std::path::Path, + credential: &CodexAppServerCredential, +) -> Result { + let isolated_home = root.join("codex-home"); + std::fs::create_dir(&isolated_home).map_err(|error| { + platform_llm::LlmError::Transport(format!("创建隔离 Codex app-server HOME 失败:{error}")) + })?; + let CodexAppServerCredential::AuthBridge { auth_json, .. } = credential else { + return Ok(isolated_home); + }; + let target_auth = isolated_home.join("auth.json"); + std::fs::write(&target_auth, auth_json).map_err(|error| { + platform_llm::LlmError::Transport(format!( + "桥接 Codex 登录态到隔离 app-server 失败:{error}" + )) + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&target_auth, std::fs::Permissions::from_mode(0o600)).map_err( + |error| { + platform_llm::LlmError::Transport(format!("收紧隔离 Codex 登录态权限失败:{error}")) + }, + )?; + } + Ok(isolated_home) +} + +impl CodexAppServerConnection { + async fn acquire( + snapshot: &AgentRuntimeProviderRequestSnapshot, + llm: &GameCreatorLlmConfig, + ) -> Result { + game_creator_codex_app_server_validate_llm_config(llm)?; + let codex_cli_version = game_creator_codex_cli_version_identity() + .map_err(platform_llm::LlmError::InvalidConfig)?; + let credential = resolve_game_creator_codex_app_server_credential(llm)?; + let key = game_creator_codex_app_server_pool_key( + llm, + &codex_cli_version, + snapshot, + credential.fingerprint(), + ); + let mut pool = game_creator_codex_app_server_pool().lock().await; + if let Some(inner) = pool.get(&key) { + if !inner.closed.load(Ordering::Acquire) { + inner.last_used.store( + next_game_creator_codex_app_server_usage_tick(), + Ordering::Release, + ); + return Ok(Self { + inner: Arc::clone(inner), + }); + } + } + pool.retain(|_, inner| !inner.closed.load(Ordering::Acquire)); + if pool.len() >= GAME_CREATOR_CODEX_APP_SERVER_POOL_MAX { + let evict_key = pool + .iter() + .filter(|(_, inner)| Arc::strong_count(inner) == 1) + .min_by_key(|(_, inner)| inner.last_used.load(Ordering::Acquire)) + .map(|(key, _)| key.clone()); + let Some(evict_key) = evict_key else { + return Err(platform_llm::LlmError::InvalidConfig(format!( + "Codex app-server 同时活动的 LLM 路由超过 {} 个,请等待现有节点完成或重启 Agent Runner", + GAME_CREATOR_CODEX_APP_SERVER_POOL_MAX + ))); + }; + if let Some(inner) = pool.remove(&evict_key) { + shutdown_game_creator_codex_app_server_inner( + &inner, + "Codex app-server LRU 路由已淘汰", + ) + .await; + } + } + let connection = Self::spawn(llm, &credential).await?; + pool.insert(key, Arc::clone(&connection.inner)); + Ok(connection) + } + + async fn spawn( + llm: &GameCreatorLlmConfig, + credential: &CodexAppServerCredential, + ) -> Result { + Self::spawn_with_executable_and_credential( + llm, + credential, + std::ffi::OsStr::new(GAME_CREATOR_CODEX_APP_SERVER_EXECUTABLE), + ) + .await + } + + async fn spawn_with_executable( + llm: &GameCreatorLlmConfig, + executable: &std::ffi::OsStr, + ) -> Result { + let credential = resolve_game_creator_codex_app_server_credential(llm)?; + Self::spawn_with_executable_and_credential(llm, &credential, executable).await + } + + async fn spawn_with_executable_and_credential( + llm: &GameCreatorLlmConfig, + credential: &CodexAppServerCredential, + executable: &std::ffi::OsStr, + ) -> Result { + let working_dir = tempfile::Builder::new() + .prefix("genarrative-agc-codex-app-server-") + .tempdir() + .map_err(|error| { + platform_llm::LlmError::Transport(format!( + "创建 Codex app-server 临时目录失败:{error}" + )) + })?; + let isolated_codex_home = + prepare_isolated_game_creator_codex_home(working_dir.path(), credential)?; + let isolated_workspace = working_dir.path().join("workspace"); + std::fs::create_dir(&isolated_workspace).map_err(|error| { + platform_llm::LlmError::Transport(format!( + "创建 Codex app-server 隔离工作目录失败:{error}" + )) + })?; + std::fs::create_dir(isolated_workspace.join(".git")).map_err(|error| { + platform_llm::LlmError::Transport(format!( + "创建 Codex app-server 隔离仓库边界失败:{error}" + )) + })?; + let isolated_os_home = working_dir.path().join("home"); + let isolated_app_data = isolated_os_home.join("appdata"); + let isolated_local_app_data = isolated_os_home.join("local-appdata"); + for path in [ + &isolated_os_home, + &isolated_app_data, + &isolated_local_app_data, + ] { + std::fs::create_dir_all(path).map_err(|error| { + platform_llm::LlmError::Transport(format!( + "创建 Codex app-server 隔离用户目录失败:{error}" + )) + })?; + } + let mut command = tokio::process::Command::new(executable); + configure_game_creator_codex_app_server_command(&mut command, llm)?; + command + .current_dir(&isolated_workspace) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + game_creator_codex_cli_minimal_environment(&mut command); + command + .env("CODEX_HOME", &isolated_codex_home) + .env("HOME", &isolated_os_home) + .env("USERPROFILE", &isolated_os_home) + .env("APPDATA", &isolated_app_data) + .env("LOCALAPPDATA", &isolated_local_app_data) + .env_remove("CODEX_API_KEY"); + if credential.uses_app_data_key() { + command.env(GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV, &llm.api_key); + } + configure_game_creator_codex_cli_process(&mut command); + let mut child = command.spawn().map_err(|error| { + platform_llm::LlmError::InvalidConfig(format!( + "Codex app-server 不可用,请安装并登录 Codex CLI:{error}" + )) + })?; + let stdin = child.stdin.take().ok_or_else(|| { + platform_llm::LlmError::Transport("Codex app-server stdin 未建立".to_string()) + })?; + let stdout = child.stdout.take().ok_or_else(|| { + platform_llm::LlmError::Transport("Codex app-server stdout 未建立".to_string()) + })?; + let stderr = child.stderr.take().ok_or_else(|| { + platform_llm::LlmError::Transport("Codex app-server stderr 未建立".to_string()) + })?; + let inner = Arc::new(CodexAppServerInner { + writer: Mutex::new(stdin), + child: Mutex::new(Some(child)), + pending: Mutex::new(HashMap::new()), + turns: Mutex::new(HashMap::new()), + turn_backlog: Mutex::new(HashMap::new()), + threads: Mutex::new(HashMap::new()), + stderr_summary: Mutex::new(CodexAppServerStderrSummary::new()), + turn_gate: Mutex::new(()), + next_request_id: AtomicU64::new(1), + last_used: AtomicU64::new(next_game_creator_codex_app_server_usage_tick()), + closed: AtomicBool::new(false), + _working_dir: working_dir, + }); + tokio::spawn(read_game_creator_codex_app_server_stdout( + Arc::downgrade(&inner), + stdout, + )); + tokio::spawn(read_game_creator_codex_app_server_stderr( + Arc::downgrade(&inner), + stderr, + )); + let connection = Self { inner }; + connection + .request( + "initialize", + serde_json::json!({ + "clientInfo": { + "name": "genarrative-ai-game-creator", + "title": "Genarrative AI Game Creator", + "version": env!("CARGO_PKG_VERSION") + }, + "capabilities": { "experimentalApi": true } + }), + ) + .await + .map_err(platform_llm::LlmError::Transport)?; + connection + .notify("initialized", serde_json::json!({})) + .await + .map_err(platform_llm::LlmError::Transport)?; + Ok(connection) + } + + async fn write_message(&self, value: &serde_json::Value) -> Result<(), String> { + if self.inner.closed.load(Ordering::Acquire) { + return Err("Codex app-server 连接已关闭".to_string()); + } + let mut bytes = serde_json::to_vec(value) + .map_err(|error| format!("序列化 Codex app-server JSON-RPC 失败:{error}"))?; + bytes.push(b'\n'); + let mut writer = self.inner.writer.lock().await; + writer + .write_all(&bytes) + .await + .map_err(|error| format!("写入 Codex app-server 失败:{error}"))?; + writer + .flush() + .await + .map_err(|error| format!("刷新 Codex app-server stdin 失败:{error}")) + } + + async fn notify(&self, method: &str, params: serde_json::Value) -> Result<(), String> { + self.write_message(&serde_json::json!({ + "method": method, + "params": params, + })) + .await + } + + async fn request(&self, method: &str, params: serde_json::Value) -> RpcResult { + self.request_with_turn_start_cancellation(method, params, None) + .await + } + + async fn request_with_turn_start_cancellation( + &self, + method: &str, + params: serde_json::Value, + turn_start_cancellation: Option>, + ) -> RpcResult { + let id = self.inner.next_request_id.fetch_add(1, Ordering::Relaxed); + let (sender, receiver) = oneshot::channel(); + self.inner.pending.lock().await.insert( + id, + CodexPendingRpc { + sender, + turn_start_cancellation, + }, + ); + let weak_inner = Arc::downgrade(&self.inner); + let method_for_timeout = method.to_string(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis( + GAME_CREATOR_CODEX_APP_SERVER_RPC_TIMEOUT_MS, + )) + .await; + let Some(inner) = weak_inner.upgrade() else { + return; + }; + let pending = inner.pending.lock().await.remove(&id); + if let Some(pending) = pending { + let _ = pending.sender.send(Err(format!( + "Codex app-server RPC {method_for_timeout} 超时" + ))); + } + }); + if let Err(error) = self + .write_message(&serde_json::json!({ + "id": id, + "method": method, + "params": params, + })) + .await + { + self.inner.pending.lock().await.remove(&id); + return Err(error); + } + match tokio::time::timeout( + std::time::Duration::from_millis(GAME_CREATOR_CODEX_APP_SERVER_RPC_TIMEOUT_MS), + receiver, + ) + .await + { + Ok(Ok(result)) => result, + Ok(Err(_)) => Err("Codex app-server RPC 响应通道已关闭".to_string()), + Err(_) => { + self.inner.pending.lock().await.remove(&id); + Err(format!("Codex app-server RPC {method} 超时")) + } + } + } + + async fn thread_for( + &self, + snapshot: &AgentRuntimeProviderRequestSnapshot, + request: &LlmRunRequest, + llm: &GameCreatorLlmConfig, + ) -> Result { + let key = CodexNodeThreadKey::from(snapshot); + let mut threads = self.inner.threads.lock().await; + if let Some(entry) = threads.get_mut(&key) { + entry.active_uses = entry.active_uses.saturating_add(1); + entry.last_used = next_game_creator_codex_app_server_usage_tick(); + return Ok(CodexThreadLease { + connection: self.clone(), + key, + thread_id: entry.thread_id.clone(), + }); + } + if threads.len() >= GAME_CREATOR_CODEX_APP_SERVER_THREAD_MAX { + let evict_key = threads + .iter() + .filter(|(_, entry)| entry.active_uses == 0) + .min_by_key(|(_, entry)| entry.last_used) + .map(|(key, _)| key.clone()); + let Some(evict_key) = evict_key else { + return Err(platform_llm::LlmError::InvalidRequest(format!( + "Codex app-server 同时活动的节点 thread 超过 {} 个", + GAME_CREATOR_CODEX_APP_SERVER_THREAD_MAX + ))); + }; + if let Some(entry) = threads.remove(&evict_key) { + let _ = self + .request( + "thread/unsubscribe", + serde_json::json!({ "threadId": entry.thread_id }), + ) + .await; + } + } + let model = request + .model + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or(&llm.model); + let mut params = serde_json::json!({ + "model": model, + "cwd": self.inner._working_dir.path().join("workspace"), + "approvalPolicy": "never", + "sandbox": "read-only", + "ephemeral": true, + "baseInstructions": "You are an isolated reasoning engine for Genarrative AGC. Never use native tools, shell, files, network, MCP, skills, plugins, hooks, or sub-agents. Use only the complete message snapshot provided in each turn and return only the requested answer contract." + }); + if !llm.api_key.trim().is_empty() { + params["modelProvider"] = + serde_json::Value::String(GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID.to_string()); + } + let result = self + .request("thread/start", params) + .await + .map_err(platform_llm::LlmError::Transport)?; + let thread_id = result + .pointer("/thread/id") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + platform_llm::LlmError::Deserialize( + "Codex app-server thread/start 缺少 thread.id".to_string(), + ) + })? + .to_string(); + threads.insert( + key.clone(), + CodexThreadEntry { + thread_id: thread_id.clone(), + last_used: next_game_creator_codex_app_server_usage_tick(), + active_uses: 1, + }, + ); + Ok(CodexThreadLease { + connection: self.clone(), + key, + thread_id, + }) + } + + async fn register_turn(&self, turn_id: &str) -> mpsc::UnboundedReceiver { + let (sender, receiver) = mpsc::unbounded_channel(); + self.inner + .turns + .lock() + .await + .insert(turn_id.to_string(), sender.clone()); + if let Some(events) = self.inner.turn_backlog.lock().await.remove(turn_id) { + for event in events { + let _ = sender.send(event); + } + } + receiver + } + + async fn release_thread( + &self, + snapshot: &AgentRuntimeProviderRequestSnapshot, + thread_id: &str, + ) { + let key = CodexNodeThreadKey::from(snapshot); + let removed = { + let mut threads = self.inner.threads.lock().await; + (threads.get(&key).map(|entry| entry.thread_id.as_str()) == Some(thread_id)) + .then(|| threads.remove(&key)) + .flatten() + }; + if removed.is_some() { + let _ = self + .request( + "thread/unsubscribe", + serde_json::json!({ "threadId": thread_id }), + ) + .await; + } + } + + async fn run_turn( + &self, + snapshot: &AgentRuntimeProviderRequestSnapshot, + llm: &GameCreatorLlmConfig, + request: LlmRunRequest, + mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>, + ) -> Result { + let _turn_guard = self.inner.turn_gate.lock().await; + let thread_lease = self.thread_for(snapshot, &request, llm).await?; + let thread_id = thread_lease.thread_id.clone(); + let prompt = render_game_creator_codex_cli_prompt(&request) + .map_err(platform_llm::LlmError::InvalidRequest)?; + let model = request + .model + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or(&llm.model); + let mut params = serde_json::json!({ + "threadId": thread_id, + "input": [{ "type": "text", "text": prompt }], + "model": model, + "approvalPolicy": "never", + "sandboxPolicy": { "type": "readOnly", "networkAccess": false }, + }); + if let Some(effort) = game_creator_codex_cli_reasoning_effort(&request) { + params["effort"] = serde_json::Value::String(effort.to_string()); + } + if let Some(schema) = game_creator_codex_cli_tool_output_schema(&request) { + params["outputSchema"] = schema; + } + let turn_start_cancellation = + Arc::new(CodexTurnStartCancellation::new(&self.inner, &thread_id)); + let mut turn_start_guard = CodexTurnStartGuard { + cancellation: Arc::clone(&turn_start_cancellation), + armed: true, + }; + let result = match self + .request_with_turn_start_cancellation( + "turn/start", + params, + Some(turn_start_cancellation), + ) + .await + { + Ok(result) => result, + Err(error) => { + return Err(isolate_game_creator_codex_app_server_terminal_unknown( + &self.inner, + format!("turn/start 终态未知:{error}"), + ) + .await) + } + }; + let turn_id = match result + .pointer("/turn/id") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + { + Some(turn_id) => turn_id.to_string(), + None => { + return Err(isolate_game_creator_codex_app_server_terminal_unknown( + &self.inner, + "turn/start 响应缺少 turn.id", + ) + .await) + } + }; + turn_start_guard.armed = false; + let mut receiver = self.register_turn(&turn_id).await; + let mut guard = CodexTurnGuard { + connection: self.clone(), + thread_id: thread_id.clone(), + turn_id: turn_id.clone(), + armed: true, + }; + let timeout_ms = request + .request_timeout_ms + .unwrap_or(GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS) + .max(1); + let collect = async { + let mut final_text = None; + let mut streamed_text = String::new(); + loop { + match receiver.recv().await { + Some(CodexTurnEvent::AgentMessageDelta(delta)) => { + streamed_text.push_str(&delta); + if let Some(callback) = on_agent_message_delta.as_deref_mut() { + callback(&platform_llm::LlmStreamDelta { + accumulated_text: streamed_text.clone(), + delta_text: delta, + finish_reason: None, + }); + } + } + Some(CodexTurnEvent::Item(params)) => { + if let Some(item) = params.get("item") { + let item_type = item + .get("type") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if item_type == "agentMessage" { + if let Some(text) = item + .get("text") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + { + final_text = Some(text.to_string()); + } + } else if !matches!( + item_type, + "userMessage" | "plan" | "reasoning" | "contextCompaction" + ) { + return Err(platform_llm::LlmError::InvalidRequest(format!( + "Codex app-server 违反 AGC ToolHost 边界,产生非被动 item {item_type}" + ))); + } + } + } + Some(CodexTurnEvent::Terminal(params)) => { + let turn = params.get("turn").unwrap_or(¶ms); + if final_text.is_none() { + final_text = turn + .get("items") + .and_then(serde_json::Value::as_array) + .and_then(|items| { + items.iter().rev().find_map(|item| { + (item.get("type")?.as_str()? == "agentMessage") + .then(|| item.get("text")?.as_str().map(str::to_string)) + .flatten() + }) + }); + } + match turn + .get("status") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + { + "completed" => { + return final_text + .filter(|text| !text.trim().is_empty()) + .ok_or(platform_llm::LlmError::EmptyResponse) + } + "interrupted" => { + return Err(platform_llm::LlmError::InvalidRequest( + "Codex app-server turn 已中断".to_string(), + )) + } + "failed" => { + return Err(platform_llm::LlmError::InvalidRequest( + "Codex app-server turn 执行失败".to_string(), + )) + } + status => { + return Err(platform_llm::LlmError::Deserialize(format!( + "Codex app-server turn/completed 状态无效:{status}" + ))) + } + } + } + Some(CodexTurnEvent::TransportClosed(error)) => { + return Err(isolate_game_creator_codex_app_server_terminal_unknown( + &self.inner, + error, + ) + .await) + } + None => { + return Err(isolate_game_creator_codex_app_server_terminal_unknown( + &self.inner, + "Codex app-server turn 事件通道已关闭", + ) + .await) + } + } + } + }; + let text = match tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), collect) + .await + { + Ok(result) => result?, + Err(_) => { + return Err(isolate_game_creator_codex_app_server_terminal_unknown( + &self.inner, + "等待 turn/completed 超时", + ) + .await) + } + }; + guard.armed = false; + self.inner.turns.lock().await.remove(&turn_id); + let response = parse_game_creator_codex_app_server_text(&text, &thread_id, &request)?; + if matches!( + snapshot.request_kind.as_str(), + "final-reply" | "steer-decision" + ) { + self.release_thread(snapshot, &thread_id).await; + } + Ok(response) + } +} + +struct CodexTurnStartGuard { + cancellation: Arc, + armed: bool, +} + +impl Drop for CodexTurnStartGuard { + fn drop(&mut self) { + if self.armed { + self.cancellation.cancel(); + } + } +} + +struct CodexThreadLease { + connection: CodexAppServerConnection, + key: CodexNodeThreadKey, + thread_id: String, +} + +impl Drop for CodexThreadLease { + fn drop(&mut self) { + let connection = self.connection.clone(); + let key = self.key.clone(); + let thread_id = self.thread_id.clone(); + tokio::spawn(async move { + let mut threads = connection.inner.threads.lock().await; + if let Some(entry) = threads.get_mut(&key) { + if entry.thread_id == thread_id { + entry.active_uses = entry.active_uses.saturating_sub(1); + entry.last_used = next_game_creator_codex_app_server_usage_tick(); + } + } + }); + } +} + +struct CodexTurnGuard { + connection: CodexAppServerConnection, + thread_id: String, + turn_id: String, + armed: bool, +} + +impl Drop for CodexTurnGuard { + fn drop(&mut self) { + if !self.armed || self.connection.inner.closed.load(Ordering::Acquire) { + return; + } + let connection = self.connection.clone(); + let thread_id = self.thread_id.clone(); + let turn_id = self.turn_id.clone(); + tokio::spawn(async move { + connection.inner.turns.lock().await.remove(&turn_id); + connection.inner.turn_backlog.lock().await.remove(&turn_id); + let _ = connection + .request( + "turn/interrupt", + serde_json::json!({ "threadId": thread_id, "turnId": turn_id }), + ) + .await; + }); + } +} + +fn parse_game_creator_codex_app_server_text( + text: &str, + thread_id: &str, + request: &LlmRunRequest, +) -> Result { + let tool_calls = if request.function_tools.is_empty() { + Vec::new() + } else { + let envelope = serde_json::from_str::(text).map_err(|_| { + platform_llm::LlmError::Deserialize( + "Codex app-server structured output 不是严格 JSON".to_string(), + ) + })?; + let calls = envelope + .get("toolCalls") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| { + platform_llm::LlmError::Deserialize( + "Codex app-server structured output 缺少 toolCalls".to_string(), + ) + })?; + calls + .iter() + .enumerate() + .map(|(index, call)| { + let name = call + .get("name") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + platform_llm::LlmError::Deserialize( + "Codex app-server tool call 缺少 name".to_string(), + ) + })?; + let arguments = call + .get("arguments") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + platform_llm::LlmError::Deserialize( + "Codex app-server tool call 缺少 arguments".to_string(), + ) + })?; + if !request.function_tools.iter().any(|tool| tool.name == name) { + return Err(platform_llm::LlmError::Deserialize( + "Codex app-server 返回未广告函数".to_string(), + )); + } + Ok(platform_llm::LlmToolCall { + id: format!("codex-app-server-call-{}", index.saturating_add(1)), + name: name.to_string(), + arguments: arguments.to_string(), + }) + }) + .collect::, _>>()? + }; + Ok(platform_llm::LlmRunResponse { + provider: platform_llm::LlmProvider::OpenAiCompatible, + model: "codex-app-server".to_string(), + text: if tool_calls.is_empty() { + text.to_string() + } else { + String::new() + }, + finish_reason: Some("stop".to_string()), + response_id: Some(thread_id.to_string()), + usage: None, + tool_calls, + }) +} + +async fn read_game_creator_codex_app_server_stdout( + inner: Weak, + stdout: tokio::process::ChildStdout, +) { + let mut reader = BufReader::new(stdout); + loop { + let mut buffer = match read_bounded_game_creator_codex_app_server_line(&mut reader).await { + Ok(Some(buffer)) => buffer, + Ok(None) => { + fail_game_creator_codex_app_server_connection( + &inner, + "Codex app-server 已退出".to_string(), + ) + .await; + return; + } + Err(error) => { + fail_game_creator_codex_app_server_connection(&inner, error).await; + return; + } + }; + while matches!(buffer.last(), Some(b'\n' | b'\r')) { + buffer.pop(); + } + if buffer.is_empty() { + continue; + } + let message = match serde_json::from_slice::(&buffer) { + Ok(message) => message, + Err(_) => { + fail_game_creator_codex_app_server_connection( + &inner, + "Codex app-server 返回无效 JSON-RPC".to_string(), + ) + .await; + return; + } + }; + let Some(inner) = inner.upgrade() else { + return; + }; + if let Some(id) = message.get("id").and_then(serde_json::Value::as_u64) { + if message.get("method").is_some() { + let writer = Arc::clone(&inner); + tokio::spawn(async move { + let response = serde_json::json!({ + "id": id, + "error": { + "code": -32601, + "message": "Genarrative AGC 拒绝 app-server 的交互、审批与工具请求" + } + }); + let mut bytes = match serde_json::to_vec(&response) { + Ok(bytes) => bytes, + Err(_) => return, + }; + bytes.push(b'\n'); + let mut stdin = writer.writer.lock().await; + let _ = stdin.write_all(&bytes).await; + let _ = stdin.flush().await; + }); + continue; + } + if let Some(pending) = inner.pending.lock().await.remove(&id) { + let result = if let Some(error) = message.get("error") { + Err(format!( + "Codex app-server JSON-RPC 失败:{}", + error + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or("未知错误") + )) + } else { + Ok(message + .get("result") + .cloned() + .unwrap_or(serde_json::Value::Null)) + }; + if let (Some(cancellation), Ok(value)) = + (pending.turn_start_cancellation.as_ref(), &result) + { + if let Some(turn_id) = value + .pointer("/turn/id") + .and_then(serde_json::Value::as_str) + { + cancellation.observe_turn_id(turn_id); + } + } + let _ = pending.sender.send(result); + } + continue; + } + let method = message + .get("method") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if !matches!( + method, + "item/agentMessage/delta" | "item/started" | "item/completed" | "turn/completed" + ) { + continue; + } + let params = message + .get("params") + .cloned() + .unwrap_or(serde_json::Value::Null); + let turn_id = params + .get("turnId") + .and_then(serde_json::Value::as_str) + .or_else(|| { + params + .pointer("/turn/id") + .and_then(serde_json::Value::as_str) + }) + .map(str::to_string); + let Some(turn_id) = turn_id else { + continue; + }; + let event = match method { + "item/agentMessage/delta" => { + let Some(delta) = params + .get("delta") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + else { + continue; + }; + CodexTurnEvent::AgentMessageDelta(delta.to_string()) + } + "item/started" | "item/completed" => CodexTurnEvent::Item(params), + _ => CodexTurnEvent::Terminal(params), + }; + let sender = if method == "turn/completed" { + inner.turns.lock().await.remove(&turn_id) + } else { + inner.turns.lock().await.get(&turn_id).cloned() + }; + if let Some(sender) = sender { + let _ = sender.send(event); + } else { + let mut backlog = inner.turn_backlog.lock().await; + if !backlog.contains_key(&turn_id) + && backlog.len() >= GAME_CREATOR_CODEX_APP_SERVER_BACKLOG_TURN_MAX + { + continue; + } + let events = backlog.entry(turn_id).or_default(); + if events.len() < 64 { + events.push(event); + } + } + } +} + +async fn read_bounded_game_creator_codex_app_server_line( + reader: &mut R, +) -> Result>, String> +where + R: AsyncBufRead + Unpin, +{ + let mut line = Vec::new(); + loop { + let available = reader + .fill_buf() + .await + .map_err(|error| format!("读取 Codex app-server stdout 失败:{error}"))?; + if available.is_empty() { + return Ok((!line.is_empty()).then_some(line)); + } + let consumed = available + .iter() + .position(|byte| *byte == b'\n') + .map(|index| index.saturating_add(1)) + .unwrap_or(available.len()); + if line.len().saturating_add(consumed) > GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES { + return Err("Codex app-server JSON-RPC 单行超过大小上限".to_string()); + } + let completed = available.get(consumed.saturating_sub(1)) == Some(&b'\n'); + line.extend_from_slice(&available[..consumed]); + reader.consume(consumed); + if completed { + return Ok(Some(line)); + } + } +} + +async fn read_game_creator_codex_app_server_stderr( + inner: Weak, + mut stderr: tokio::process::ChildStderr, +) { + let mut buffer = [0_u8; 8 * 1024]; + loop { + let count = match stderr.read(&mut buffer).await { + Ok(0) => return, + Err(_) => { + if let Some(inner) = inner.upgrade() { + inner.stderr_summary.lock().await.mark_read_failure(); + } + fail_game_creator_codex_app_server_connection( + &inner, + "Codex app-server stderr 读取失败".to_string(), + ) + .await; + return; + } + Ok(count) => count, + }; + let Some(upgraded) = inner.upgrade() else { + return; + }; + let oversized_record = upgraded + .stderr_summary + .lock() + .await + .observe(&buffer[..count]); + drop(upgraded); + if oversized_record { + fail_game_creator_codex_app_server_connection( + &inner, + "Codex app-server stderr 单条记录超过大小上限".to_string(), + ) + .await; + return; + } + } +} + +async fn fail_game_creator_codex_app_server_connection( + inner: &Weak, + error: String, +) { + let Some(inner) = inner.upgrade() else { + return; + }; + if inner.closed.swap(true, Ordering::AcqRel) { + return; + } + let exit_status = inner + .child + .lock() + .await + .as_mut() + .and_then(|child| child.try_wait().ok().flatten()) + .map(|status| status.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let stderr = inner.stderr_summary.lock().await.diagnostic(); + let diagnostic = format!("{error};exitStatus={exit_status};{stderr}"); + eprintln!("agent.runner.failed: Codex app-server 连接终止:{diagnostic}"); + for (_, pending) in inner.pending.lock().await.drain() { + let _ = pending.sender.send(Err(diagnostic.clone())); + } + for (_, sender) in inner.turns.lock().await.drain() { + let _ = sender.send(CodexTurnEvent::TransportClosed(diagnostic.clone())); + } + inner.threads.lock().await.clear(); + inner.turn_backlog.lock().await.clear(); +} + +async fn shutdown_game_creator_codex_app_server_inner( + inner: &Arc, + reason: &str, +) { + inner.closed.store(true, Ordering::Release); + for (_, pending) in inner.pending.lock().await.drain() { + let _ = pending.sender.send(Err(reason.to_string())); + } + for (_, sender) in inner.turns.lock().await.drain() { + let _ = sender.send(CodexTurnEvent::TransportClosed(reason.to_string())); + } + inner.threads.lock().await.clear(); + inner.turn_backlog.lock().await.clear(); + if let Some(mut child) = inner.child.lock().await.take() { + terminate_game_creator_codex_cli_process_tree(&mut child).await; + } +} + +pub(in crate::agent) async fn request_game_creator_agent_codex_app_server( + snapshot: &AgentRuntimeProviderRequestSnapshot, + llm: &GameCreatorLlmConfig, + request: LlmRunRequest, +) -> Result { + CodexAppServerConnection::acquire(snapshot, llm) + .await? + .run_turn(snapshot, llm, request, None) + .await +} + +pub(in crate::agent) async fn stream_game_creator_agent_codex_app_server( + snapshot: &AgentRuntimeProviderRequestSnapshot, + llm: &GameCreatorLlmConfig, + request: LlmRunRequest, + mut on_agent_message_delta: F, +) -> Result +where + F: FnMut(&platform_llm::LlmStreamDelta) + Send, +{ + CodexAppServerConnection::acquire(snapshot, llm) + .await? + .run_turn(snapshot, llm, request, Some(&mut on_agent_message_delta)) + .await +} + +pub(in crate::agent) fn shutdown_game_creator_codex_app_servers_impl() -> Result<(), String> { + tauri::async_runtime::block_on(async { + let connections = game_creator_codex_app_server_pool() + .lock() + .await + .drain() + .map(|(_, connection)| connection) + .collect::>(); + for inner in connections { + shutdown_game_creator_codex_app_server_inner(&inner, "Codex app-server 正在关闭").await; + } + Ok(()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_llm() -> GameCreatorLlmConfig { + GameCreatorLlmConfig { + api_key: "fixture-secret".to_string(), + base_url: "https://example.invalid/v1".to_string(), + model: "fixture-model".to_string(), + api_kind: "openai_responses".to_string(), + reasoning_effort: "high".to_string(), + stream: false, + web_search_enabled: false, + context_window_tokens: 128_000, + auto_compact_token_limit: 64_000, + tool_output_token_limit: 12_000, + request_timeout_ms: 10_000, + max_retries: 0, + retry_backoff_ms: 100, + } + } + + fn test_snapshot() -> AgentRuntimeProviderRequestSnapshot { + AgentRuntimeProviderRequestSnapshot { + project_id: "project-1".to_string(), + agent_id: "planner".to_string(), + task_id: "task-1".to_string(), + session_id: "session-1".to_string(), + run_id: "run-1".to_string(), + source: "test".to_string(), + goal_id: None, + goal_revision: 0, + goal_snapshot_fingerprint: String::new(), + applied_steer_cursor: 0, + request_kind: "tool-plan".to_string(), + request_slot: "slot-1".to_string(), + web_search_enabled: false, + allow_idle_context_compaction: false, + } + } + + fn tool_request() -> LlmRunRequest { + LlmRunRequest::single_turn("系统", "任务").with_function_tools(vec![ + platform_llm::LlmFunctionTool::new( + "runtime_tool_file_read", + "读取文件", + serde_json::json!({"type":"object"}), + ), + ]) + } + + #[test] + fn codex_app_server_maps_structured_output_to_runtime_tool_calls() { + let text = r#"{"toolCalls":[{"name":"runtime_tool_file_read","arguments":"{\"path\":\"game/index.html\"}"}]}"#; + let response = parse_game_creator_codex_app_server_text(text, "thread-1", &tool_request()) + .expect("parse app-server output"); + assert_eq!(response.response_id.as_deref(), Some("thread-1")); + assert_eq!(response.tool_calls.len(), 1); + assert_eq!(response.tool_calls[0].name, "runtime_tool_file_read"); + assert!(response.text.is_empty()); + } + + #[test] + fn codex_app_server_rejects_non_responses_key_mapping() { + let mut llm = test_llm(); + llm.api_kind = "anthropic".to_string(); + assert!(game_creator_codex_app_server_validate_llm_config(&llm).is_err()); + llm.api_kind = "openai_responses".to_string(); + llm.web_search_enabled = true; + assert!(game_creator_codex_app_server_validate_llm_config(&llm).is_err()); + } + + #[test] + fn codex_app_server_pool_key_isolated_by_credentials_and_route() { + let mut base = test_llm(); + let snapshot = test_snapshot(); + base.api_key = "key-a".to_string(); + let base_credential = + resolve_game_creator_codex_app_server_credential(&base).expect("base credential"); + let mut changed = base.clone(); + changed.api_key = "key-b".to_string(); + let changed_credential = + resolve_game_creator_codex_app_server_credential(&changed).expect("changed credential"); + assert_ne!( + game_creator_codex_app_server_pool_key( + &base, + "codex-cli 0.147.0", + &snapshot, + base_credential.fingerprint() + ), + game_creator_codex_app_server_pool_key( + &changed, + "codex-cli 0.147.0", + &snapshot, + changed_credential.fingerprint() + ) + ); + changed = base.clone(); + changed.base_url = "https://other.invalid/v1".to_string(); + assert_ne!( + game_creator_codex_app_server_pool_key( + &base, + "codex-cli 0.147.0", + &snapshot, + base_credential.fingerprint() + ), + game_creator_codex_app_server_pool_key( + &changed, + "codex-cli 0.147.0", + &snapshot, + base_credential.fingerprint() + ) + ); + assert_ne!( + game_creator_codex_app_server_pool_key( + &base, + "codex-cli 0.147.0", + &snapshot, + base_credential.fingerprint() + ), + game_creator_codex_app_server_pool_key( + &base, + "codex-cli 0.148.0", + &snapshot, + base_credential.fingerprint() + ) + ); + let mut other_node = snapshot.clone(); + other_node.agent_id = "other-agent".to_string(); + assert_ne!( + game_creator_codex_app_server_pool_key( + &base, + "codex-cli 0.147.0", + &snapshot, + base_credential.fingerprint() + ), + game_creator_codex_app_server_pool_key( + &base, + "codex-cli 0.147.0", + &other_node, + base_credential.fingerprint() + ) + ); + } + + #[test] + fn codex_app_server_auth_bridge_snapshot_drives_pool_and_isolated_home() { + let temp = tempfile::tempdir().expect("temp dir"); + let source_auth = temp.path().join("source-auth.json"); + std::fs::write( + &source_auth, + br#"{"tokens":{"access_token":"first-secret"}}"#, + ) + .expect("write first auth"); + let first = read_game_creator_codex_auth_bridge(&source_auth).expect("first snapshot"); + std::fs::write( + &source_auth, + br#"{"tokens":{"access_token":"second-secret"}}"#, + ) + .expect("write second auth"); + let second = read_game_creator_codex_auth_bridge(&source_auth).expect("second snapshot"); + let llm = GameCreatorLlmConfig { + api_key: String::new(), + ..test_llm() + }; + let snapshot = test_snapshot(); + assert_ne!( + game_creator_codex_app_server_pool_key( + &llm, + "codex-cli 0.147.0", + &snapshot, + first.fingerprint() + ), + game_creator_codex_app_server_pool_key( + &llm, + "codex-cli 0.147.0", + &snapshot, + second.fingerprint() + ) + ); + + let isolated = temp.path().join("isolated"); + std::fs::create_dir(&isolated).expect("create isolated root"); + let home = prepare_isolated_game_creator_codex_home(&isolated, &first) + .expect("prepare auth bridge"); + assert_eq!( + std::fs::read(home.join("auth.json")).expect("read bridged auth"), + br#"{"tokens":{"access_token":"first-secret"}}"# + ); + } + + #[tokio::test] + async fn codex_app_server_ndjson_and_stderr_records_are_bounded_without_newline() { + let oversized_stdout = vec![b'x'; GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES + 1]; + let mut reader = BufReader::new(oversized_stdout.as_slice()); + let error = read_bounded_game_creator_codex_app_server_line(&mut reader) + .await + .expect_err("oversized unterminated JSON-RPC record must fail"); + assert!(error.contains("单行超过大小上限")); + + let secret = vec![b's'; GAME_CREATOR_CODEX_APP_SERVER_STDERR_RECORD_MAX_BYTES + 1]; + let mut summary = CodexAppServerStderrSummary::new(); + assert!(summary.observe(&secret)); + let diagnostic = summary.diagnostic(); + assert!(diagnostic.contains("stderrClass=oversized-record")); + assert!(diagnostic.contains("stderrBytes=262145")); + assert!(!diagnostic.contains(std::str::from_utf8(&secret).expect("fixture utf8"))); + } + + #[cfg(unix)] + #[tokio::test] + async fn codex_app_server_uses_initialize_thread_and_turn_protocol() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let executable = temp.path().join("fake-codex-app-server"); + std::fs::write( + &executable, + r#"#!/bin/sh +case " $* " in + *"fixture-secret"*) exit 41 ;; +esac +case " $* " in + *'web_search="disabled"'*'agents.enabled=false'*'--disable shell_tool'*) ;; + *) exit 40 ;; +esac +[ "$GENARRATIVE_AGC_CODEX_API_KEY" = "fixture-secret" ] || exit 42 +[ -z "$CODEX_API_KEY" ] || exit 39 +case "$HOME" in */home) ;; *) exit 38 ;; esac +[ -d .git ] || exit 37 +IFS= read -r initialize +case "$initialize" in *'"method":"initialize"'*) ;; *) exit 43 ;; esac +printf '%s\n' '{"id":1,"result":{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}' +IFS= read -r initialized +case "$initialized" in *'"method":"initialized"'*) ;; *) exit 44 ;; esac +IFS= read -r thread_start +case "$thread_start" in *'"method":"thread/start"'*'"modelProvider":"genarrative_agc"'*) ;; *) exit 45 ;; esac +printf '%s\n' '{"id":2,"result":{"thread":{"id":"thread-1"}}}' +IFS= read -r turn_start +case "$turn_start" in *'"method":"turn/start"'*'"outputSchema"'*) ;; *) exit 46 ;; esac +printf '%s\n' '{"id":3,"result":{"turn":{"id":"turn-1","items":[],"status":"inProgress"}}}' +printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"item-1","delta":"{\"toolCalls\":"}}' +printf '%s\n' '{"method":"item/completed","params":{"completedAtMs":1,"threadId":"thread-1","turnId":"turn-1","item":{"id":"item-1","type":"agentMessage","text":"{\"toolCalls\":[{\"name\":\"runtime_tool_file_read\",\"arguments\":\"{\\\"path\\\":\\\"game/index.html\\\"}\"}]}"}}}' +printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thread-1","turn":{"id":"turn-1","items":[],"status":"completed"}}}' +while IFS= read -r line; do :; done +"#, + ) + .expect("write fake app-server"); + let mut permissions = std::fs::metadata(&executable) + .expect("fake metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&executable, permissions).expect("chmod fake app-server"); + + let llm = test_llm(); + let connection = + CodexAppServerConnection::spawn_with_executable(&llm, executable.as_os_str()) + .await + .expect("spawn fake app-server"); + let mut streamed = String::new(); + let mut on_delta = + |delta: &platform_llm::LlmStreamDelta| streamed.push_str(&delta.delta_text); + let response = connection + .run_turn(&test_snapshot(), &llm, tool_request(), Some(&mut on_delta)) + .await + .expect("run fake app-server turn"); + assert_eq!(streamed, "{\"toolCalls\":"); + assert_eq!(response.response_id.as_deref(), Some("thread-1")); + assert_eq!(response.tool_calls.len(), 1); + assert_eq!(response.tool_calls[0].name, "runtime_tool_file_read"); + assert_eq!(connection.inner.threads.lock().await.len(), 1); + } + + #[cfg(unix)] + #[tokio::test] + async fn codex_app_server_interrupts_turn_when_request_is_dropped_before_start_response() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let executable = temp.path().join("fake-codex-app-server-cancel"); + std::fs::write( + &executable, + r#"#!/bin/sh +IFS= read -r initialize +printf '%s\n' '{"id":1,"result":{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}' +IFS= read -r initialized +IFS= read -r thread_start +printf '%s\n' '{"id":2,"result":{"thread":{"id":"thread-cancel"}}}' +IFS= read -r turn_start +sleep 0.2 +printf '%s\n' '{"id":3,"result":{"turn":{"id":"turn-cancel","items":[],"status":"inProgress"}}}' +IFS= read -r interrupt +case "$interrupt" in *'"method":"turn/interrupt"'*'"turnId":"turn-cancel"'*) ;; *) exit 51 ;; esac +: > "$HOME/interrupt-seen" +printf '%s\n' '{"id":4,"result":{}}' +while IFS= read -r line; do :; done +"#, + ) + .expect("write cancel fake app-server"); + let mut permissions = std::fs::metadata(&executable) + .expect("cancel fake metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&executable, permissions).expect("chmod cancel fake app-server"); + + let llm = test_llm(); + let connection = + CodexAppServerConnection::spawn_with_executable(&llm, executable.as_os_str()) + .await + .expect("spawn cancel fake app-server"); + let task_connection = connection.clone(); + let task_llm = llm.clone(); + let task = tokio::spawn(async move { + task_connection + .run_turn(&test_snapshot(), &task_llm, tool_request(), None) + .await + }); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + task.abort(); + let _ = task.await; + + let marker = connection + .inner + ._working_dir + .path() + .join("home/interrupt-seen"); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + while !marker.is_file() { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + }) + .await + .expect("dropped turn must be interrupted after start response arrives"); + } + + #[cfg(unix)] + #[tokio::test] + async fn codex_app_server_terminal_unknown_isolates_only_timed_out_node() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let executable = temp.path().join("fake-codex-app-server-no-terminal"); + std::fs::write( + &executable, + r#"#!/bin/sh +IFS= read -r initialize +printf '%s\n' '{"id":1,"result":{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}' +IFS= read -r initialized +IFS= read -r thread_start +printf '%s\n' '{"id":2,"result":{"thread":{"id":"thread-timeout"}}}' +IFS= read -r turn_start +printf '%s\n' '{"id":3,"result":{"turn":{"id":"turn-timeout","items":[],"status":"inProgress"}}}' +while IFS= read -r line; do :; done +"#, + ) + .expect("write no-terminal fake app-server"); + let mut permissions = std::fs::metadata(&executable) + .expect("no-terminal fake metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&executable, permissions) + .expect("chmod no-terminal fake app-server"); + + let llm = test_llm(); + let failing = CodexAppServerConnection::spawn_with_executable(&llm, executable.as_os_str()) + .await + .expect("spawn failing fake app-server"); + let sibling = CodexAppServerConnection::spawn_with_executable(&llm, executable.as_os_str()) + .await + .expect("spawn sibling fake app-server"); + let failing_key = format!( + "test-terminal-unknown-failing-{}", + failing + .inner + .child + .lock() + .await + .as_ref() + .and_then(tokio::process::Child::id) + .expect("failing child pid") + ); + let sibling_key = format!( + "test-terminal-unknown-sibling-{}", + sibling + .inner + .child + .lock() + .await + .as_ref() + .and_then(tokio::process::Child::id) + .expect("sibling child pid") + ); + { + let mut pool = game_creator_codex_app_server_pool().lock().await; + pool.insert(failing_key.clone(), Arc::clone(&failing.inner)); + pool.insert(sibling_key.clone(), Arc::clone(&sibling.inner)); + } + + let result = failing + .run_turn( + &test_snapshot(), + &llm, + tool_request().with_request_timeout_ms(50), + None, + ) + .await; + let failing_closed = failing.inner.closed.load(Ordering::Acquire); + let failing_child_reaped = failing.inner.child.lock().await.is_none(); + let sibling_closed = sibling.inner.closed.load(Ordering::Acquire); + let sibling_child_live = sibling.inner.child.lock().await.is_some(); + let (failing_still_pooled, sibling_still_pooled) = { + let mut pool = game_creator_codex_app_server_pool().lock().await; + let failing_still_pooled = pool + .values() + .any(|candidate| Arc::ptr_eq(candidate, &failing.inner)); + let sibling_still_pooled = pool + .values() + .any(|candidate| Arc::ptr_eq(candidate, &sibling.inner)); + pool.remove(&failing_key); + pool.remove(&sibling_key); + (failing_still_pooled, sibling_still_pooled) + }; + shutdown_game_creator_codex_app_server_inner(&failing.inner, "test cleanup").await; + shutdown_game_creator_codex_app_server_inner(&sibling.inner, "test cleanup").await; + + let error = result.expect_err("missing turn/completed must fail closed"); + let platform_llm::LlmError::Transport(message) = error else { + panic!("terminal-unknown must stay a transport error: {error}"); + }; + assert!(message.starts_with(GAME_CREATOR_CODEX_APP_SERVER_TERMINAL_UNKNOWN_PREFIX)); + assert!(message.contains("等待 turn/completed 超时")); + assert!(failing_closed); + assert!(failing_child_reaped); + assert!(!failing_still_pooled); + assert!(!sibling_closed); + assert!(sibling_child_live); + assert!(sibling_still_pooled); + } + + #[tokio::test] + #[ignore = "requires an installed and authenticated Codex CLI and consumes one model turn"] + async fn codex_app_server_real_smoke_uses_existing_local_auth() { + let mut llm = GameCreatorLlmConfig::default(); + llm.api_key.clear(); + if let Ok(model) = std::env::var("AGC_CODEX_APP_SERVER_SMOKE_MODEL") { + llm.model = model; + } + let request = LlmRunRequest::single_turn( + "只回复指定文本,不使用任何工具。", + "请只回复 CODEX_APP_SERVER_SMOKE_OK", + ); + let response = request_game_creator_agent_codex_app_server(&test_snapshot(), &llm, request) + .await + .expect("real Codex app-server request"); + assert_eq!(response.text.trim(), "CODEX_APP_SERVER_SMOKE_OK"); + assert!(response.tool_calls.is_empty()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs new file mode 100644 index 000000000..d26107510 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs @@ -0,0 +1,783 @@ +use super::*; +use std::process::Stdio; + +use sha2::{Digest, Sha256}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; + +const GAME_CREATOR_CODEX_CLI_EXECUTABLE: &str = "codex"; +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; + +struct CodexCliStderrSummary { + byte_len: usize, + sha256: String, + classification: &'static str, +} + +pub(in crate::agent) fn game_creator_codex_cli_version_identity() -> Result { + let output = std::process::Command::new(GAME_CREATOR_CODEX_CLI_EXECUTABLE) + .arg("--version") + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .map_err(|_| "Codex CLI 未安装或不在当前 Agent Runner PATH 中".to_string())?; + if !output.status.success() { + return Err("Codex CLI 版本检查失败".to_string()); + } + let version = std::str::from_utf8(&output.stdout) + .map_err(|_| "Codex CLI 版本信息不是 UTF-8".to_string())? + .trim(); + if !version.starts_with("codex-cli ") || version.len() > 120 { + return Err("Codex CLI 返回了无法识别的版本信息".to_string()); + } + Ok(version.to_string()) +} + +pub(in crate::agent) fn game_creator_codex_cli_reasoning_effort( + request: &LlmRunRequest, +) -> Option<&'static str> { + request + .response_reasoning_effort + .map(|effort| match effort { + platform_llm::LlmResponseReasoningEffort::Low => "low", + platform_llm::LlmResponseReasoningEffort::Medium => "medium", + platform_llm::LlmResponseReasoningEffort::High => "high", + }) +} + +pub(in crate::agent) fn game_creator_codex_cli_tool_output_schema( + request: &LlmRunRequest, +) -> Option { + if request.function_tools.is_empty() { + return None; + } + let names = request + .function_tools + .iter() + .map(|tool| serde_json::Value::String(tool.name.clone())) + .collect::>(); + Some(serde_json::json!({ + "type": "object", + "required": ["toolCalls"], + "additionalProperties": false, + "properties": { + "toolCalls": { + "type": "array", + "minItems": 1, + "maxItems": AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT, + "items": { + "type": "object", + "required": ["name", "arguments"], + "additionalProperties": false, + "properties": { + "name": { "type": "string", "enum": names }, + "arguments": { + "type": "string", + "description": "严格 JSON 对象字符串,必须符合对应函数 parameters schema" + } + } + } + } + } + })) +} + +pub(in crate::agent) fn render_game_creator_codex_cli_prompt( + request: &LlmRunRequest, +) -> Result { + let messages = serde_json::to_string_pretty(&request.messages) + .map_err(|error| format!("序列化 Codex CLI Agent 消息失败:{error}"))?; + let functions = serde_json::to_string_pretty(&request.function_tools) + .map_err(|error| format!("序列化 Codex CLI Agent 函数目录失败:{error}"))?; + let output_contract = if request.function_tools.is_empty() { + "本次没有 Runtime 函数目录。请直接返回请求要求的最终正文;不要使用 Markdown 代码围栏。" + .to_string() + } else { + "本次必须只返回 output schema 要求的 toolCalls 对象。每个 name 必须来自函数目录;arguments 必须是一个严格 JSON 对象的字符串形式,并符合该函数 parameters。不要返回普通正文、Markdown、解释或额外字段。" + .to_string() + }; + let prompt = format!( + "你是 Genarrative AI 游戏创作 Runtime 当前节点的推理 Agent。\n\n安全边界:不要调用 Codex 内置 shell、文件、网络、MCP、插件、Skill 或子 Agent;不要读取当前临时目录。项目事实只来自下面的消息,项目行动只能通过返回 Runtime 函数请求完成。不得声称已经执行尚未由 Runtime observation 证明的动作。\n\n{output_contract}\n\n以下消息按 role 保持原顺序,是本次节点的完整请求:\n{messages}\n\n以下是当前 Runtime 实际广告的函数目录:\n{functions}" + ); + if prompt.len() > GAME_CREATOR_CODEX_CLI_PROMPT_MAX_BYTES { + return Err(format!( + "Codex CLI Agent prompt 超过 {} 字节上限", + GAME_CREATOR_CODEX_CLI_PROMPT_MAX_BYTES + )); + } + Ok(prompt) +} + +pub(in crate::agent) fn game_creator_codex_cli_minimal_environment( + command: &mut tokio::process::Command, +) { + command.env_clear(); + for name in [ + "PATH", + "HOME", + "USERPROFILE", + "APPDATA", + "LOCALAPPDATA", + "CODEX_HOME", + "TMPDIR", + "TEMP", + "TMP", + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "SystemRoot", + "WINDIR", + "ComSpec", + "PATHEXT", + ] { + if let Some(value) = std::env::var_os(name) { + command.env(name, value); + } + } + command.env("NO_COLOR", "1").env("TERM", "dumb"); +} + +pub(in crate::agent) fn configure_game_creator_codex_cli_process( + command: &mut tokio::process::Command, +) { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.as_std_mut().process_group(0); + #[cfg(target_os = "linux")] + unsafe { + command.as_std_mut().pre_exec(|| { + if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) != 0 { + return Err(std::io::Error::last_os_error()); + } + if libc::getppid() == 1 { + return Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "Codex CLI parent exited before child exec", + )); + } + Ok(()) + }); + } + } + #[cfg(windows)] + crate::configure_windows_background_tokio_command(command, true); +} + +pub(in crate::agent) async fn terminate_game_creator_codex_cli_process_tree( + child: &mut tokio::process::Child, +) { + if let Some(process_id) = child.id() { + #[cfg(unix)] + unsafe { + libc::kill(-(process_id as i32), libc::SIGKILL); + } + #[cfg(windows)] + { + let mut command = tokio::process::Command::new("taskkill"); + command + .args(["/PID", &process_id.to_string(), "/T", "/F"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + crate::configure_windows_background_tokio_command(&mut command, false); + let _ = command.status().await; + } + } + let _ = child.kill().await; + let _ = child.wait().await; +} + +async fn read_game_creator_codex_cli_output( + mut reader: R, + max_bytes: usize, +) -> Result, String> +where + R: AsyncRead + Unpin, +{ + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 8 * 1024]; + let mut exceeded = false; + loop { + let count = reader + .read(&mut buffer) + .await + .map_err(|error| format!("读取 Codex CLI Agent 输出失败:{error}"))?; + if count == 0 { + break; + } + let remaining = max_bytes.saturating_sub(bytes.len()); + bytes.extend_from_slice(&buffer[..count.min(remaining)]); + exceeded |= count > remaining; + } + if exceeded { + return Err(format!("Codex CLI Agent 输出超过 {max_bytes} 字节上限")); + } + Ok(bytes) +} + +async fn summarize_game_creator_codex_cli_stderr( + mut reader: R, + max_bytes: usize, +) -> Result +where + R: AsyncRead + Unpin, +{ + let mut byte_len = 0_usize; + let mut sha256 = Sha256::new(); + let mut buffer = [0_u8; 8 * 1024]; + loop { + let count = reader + .read(&mut buffer) + .await + .map_err(|_| "读取 Codex CLI Agent stderr 失败".to_string())?; + if count == 0 { + break; + } + byte_len = byte_len.saturating_add(count); + sha256.update(&buffer[..count]); + } + Ok(CodexCliStderrSummary { + byte_len, + sha256: format!("{:x}", sha256.finalize()), + classification: if byte_len == 0 { + "empty" + } else if byte_len > max_bytes { + "oversized" + } else { + "nonempty" + }, + }) +} + +fn parse_game_creator_codex_cli_response( + stdout: &[u8], + request: &LlmRunRequest, +) -> Result { + let stdout = std::str::from_utf8(stdout).map_err(|_| { + platform_llm::LlmError::Deserialize("Codex CLI Agent JSONL 不是 UTF-8".to_string()) + })?; + let mut response_id = None; + let mut final_message = None; + let mut usage = None; + let mut completed = false; + for (index, line) in stdout.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + let event = serde_json::from_str::(line).map_err(|_| { + platform_llm::LlmError::Deserialize(format!( + "Codex CLI Agent JSONL 第 {} 行无效", + index.saturating_add(1) + )) + })?; + match event.get("type").and_then(serde_json::Value::as_str) { + Some("thread.started") => { + response_id = event + .get("thread_id") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + } + Some("item.completed") + if event + .pointer("/item/type") + .and_then(serde_json::Value::as_str) + == Some("agent_message") => + { + final_message = event + .pointer("/item/text") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + } + Some("turn.completed") => { + completed = true; + let prompt_tokens = event + .pointer("/usage/input_tokens") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let completion_tokens = event + .pointer("/usage/output_tokens") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let total_tokens = prompt_tokens.saturating_add(completion_tokens); + if total_tokens > 0 { + usage = Some(platform_llm::LlmTokenUsage { + prompt_tokens, + completion_tokens, + total_tokens, + }); + } + } + Some("turn.failed") => { + return Err(platform_llm::LlmError::Transport( + "Codex CLI Agent 返回失败终态".to_string(), + )); + } + _ => {} + } + } + if !completed { + return Err(platform_llm::LlmError::Deserialize( + "Codex CLI Agent 缺少 turn.completed 终态".to_string(), + )); + } + let text = final_message + .filter(|text| !text.trim().is_empty()) + .ok_or(platform_llm::LlmError::EmptyResponse)?; + let tool_calls = if request.function_tools.is_empty() { + Vec::new() + } else { + let envelope = serde_json::from_str::(&text).map_err(|_| { + platform_llm::LlmError::Deserialize( + "Codex CLI Agent structured output 不是严格 JSON".to_string(), + ) + })?; + let calls = envelope + .get("toolCalls") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| { + platform_llm::LlmError::Deserialize( + "Codex CLI Agent structured output 缺少 toolCalls".to_string(), + ) + })?; + calls + .iter() + .enumerate() + .map(|(index, call)| { + let name = call + .get("name") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + platform_llm::LlmError::Deserialize( + "Codex CLI Agent tool call 缺少 name".to_string(), + ) + })?; + let arguments = call + .get("arguments") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + platform_llm::LlmError::Deserialize( + "Codex CLI Agent tool call 缺少 arguments".to_string(), + ) + })?; + if !request.function_tools.iter().any(|tool| tool.name == name) { + return Err(platform_llm::LlmError::Deserialize( + "Codex CLI Agent 返回未广告函数".to_string(), + )); + } + Ok(platform_llm::LlmToolCall { + id: format!("codex-cli-call-{}", index.saturating_add(1)), + name: name.to_string(), + arguments: arguments.to_string(), + }) + }) + .collect::, _>>()? + }; + Ok(platform_llm::LlmRunResponse { + provider: platform_llm::LlmProvider::OpenAiCompatible, + model: "codex-cli".to_string(), + text: if tool_calls.is_empty() { + text + } else { + String::new() + }, + finish_reason: Some("stop".to_string()), + response_id, + usage, + tool_calls, + }) +} + +async fn request_game_creator_agent_codex_cli_with_executable( + executable: &std::ffi::OsStr, + request: LlmRunRequest, +) -> Result { + let prompt = render_game_creator_codex_cli_prompt(&request) + .map_err(platform_llm::LlmError::InvalidRequest)?; + let temp_dir = tempfile::Builder::new() + .prefix("genarrative-agc-codex-cli-") + .tempdir() + .map_err(|error| { + platform_llm::LlmError::Transport(format!("创建 Codex CLI Agent 临时目录失败:{error}")) + })?; + let schema_path = if let Some(schema) = game_creator_codex_cli_tool_output_schema(&request) { + let path = temp_dir.path().join("tool-output.schema.json"); + let content = serde_json::to_vec(&schema).map_err(|error| { + platform_llm::LlmError::InvalidRequest(format!( + "序列化 Codex CLI Agent output schema 失败:{error}" + )) + })?; + std::fs::write(&path, content).map_err(|error| { + platform_llm::LlmError::Transport(format!( + "写入 Codex CLI Agent output schema 失败:{error}" + )) + })?; + Some(path) + } else { + None + }; + + let mut command = tokio::process::Command::new(executable); + command + .arg("exec") + .arg("--json") + .arg("--ephemeral") + .arg("--ignore-user-config") + .arg("--ignore-rules") + .arg("--skip-git-repo-check") + .arg("--sandbox") + .arg("read-only") + .arg("--disable") + .arg("shell_tool") + .arg("--config") + .arg("approval_policy=\"never\"") + .arg("--cd") + .arg(temp_dir.path()); + if let Some(effort) = game_creator_codex_cli_reasoning_effort(&request) { + command + .arg("--config") + .arg(format!("model_reasoning_effort=\"{effort}\"")); + } + if let Some(path) = schema_path.as_ref() { + command.arg("--output-schema").arg(path); + } + command + .arg("-") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + game_creator_codex_cli_minimal_environment(&mut command); + configure_game_creator_codex_cli_process(&mut command); + + let mut child = command.spawn().map_err(|error| { + platform_llm::LlmError::InvalidConfig(format!( + "Codex CLI Agent 不可用,请安装并登录 Codex CLI:{error}" + )) + })?; + let mut stdin = child.stdin.take().ok_or_else(|| { + platform_llm::LlmError::Transport("Codex CLI Agent stdin 未建立".to_string()) + })?; + stdin.write_all(prompt.as_bytes()).await.map_err(|error| { + platform_llm::LlmError::Transport(format!("写入 Codex CLI Agent prompt 失败:{error}")) + })?; + stdin.shutdown().await.map_err(|error| { + platform_llm::LlmError::Transport(format!("关闭 Codex CLI Agent stdin 失败:{error}")) + })?; + drop(stdin); + + let stdout = child.stdout.take().ok_or_else(|| { + platform_llm::LlmError::Transport("Codex CLI Agent stdout 未建立".to_string()) + })?; + let stderr = child.stderr.take().ok_or_else(|| { + platform_llm::LlmError::Transport("Codex CLI Agent stderr 未建立".to_string()) + })?; + let stdout_task = tokio::spawn(read_game_creator_codex_cli_output( + stdout, + GAME_CREATOR_CODEX_CLI_STDOUT_MAX_BYTES, + )); + let stderr_task = tokio::spawn(summarize_game_creator_codex_cli_stderr( + stderr, + GAME_CREATOR_CODEX_CLI_STDERR_MAX_BYTES, + )); + let timeout_ms = request + .request_timeout_ms + .unwrap_or(GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS) + .max(1); + let wait = + tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), child.wait()).await; + let (status, timed_out) = match wait { + Ok(Ok(status)) => (Some(status), false), + Ok(Err(error)) => { + terminate_game_creator_codex_cli_process_tree(&mut child).await; + return Err(platform_llm::LlmError::Transport(format!( + "等待 Codex CLI Agent 退出失败:{error}" + ))); + } + Err(_) => { + terminate_game_creator_codex_cli_process_tree(&mut child).await; + (None, true) + } + }; + let stdout = stdout_task + .await + .map_err(|_| { + platform_llm::LlmError::Transport("Codex CLI Agent stdout 读取任务失败".to_string()) + })? + .map_err(platform_llm::LlmError::Transport)?; + let stderr = stderr_task + .await + .map_err(|_| { + platform_llm::LlmError::Transport("Codex CLI Agent stderr 读取任务失败".to_string()) + })? + .map_err(platform_llm::LlmError::Transport)?; + if timed_out { + return Err(platform_llm::LlmError::Timeout { attempts: 1 }); + } + let status = status.expect("non-timeout Codex CLI wait has exit status"); + if stderr.classification == "oversized" { + return Err(platform_llm::LlmError::Transport(format!( + "Codex CLI Agent stderr 超过 {} 字节上限;stderrClass={};stderrBytes={};stderrSha256={}", + GAME_CREATOR_CODEX_CLI_STDERR_MAX_BYTES, + stderr.classification, + stderr.byte_len, + stderr.sha256 + ))); + } + if !status.success() { + return Err(platform_llm::LlmError::Transport(format!( + "Codex CLI Agent 退出失败(code={});stderrClass={};stderrBytes={};stderrSha256={};请检查 Codex CLI 登录状态、网络和本机配置", + status + .code() + .map(|code| code.to_string()) + .unwrap_or_else(|| "none".to_string()), + stderr.classification, + stderr.byte_len, + stderr.sha256 + ))); + } + parse_game_creator_codex_cli_response(&stdout, &request) +} + +pub(in crate::agent) async fn request_game_creator_agent_codex_cli( + request: LlmRunRequest, +) -> Result { + request_game_creator_agent_codex_cli_with_executable( + std::ffi::OsStr::new(GAME_CREATOR_CODEX_CLI_EXECUTABLE), + request, + ) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tool_request() -> LlmRunRequest { + LlmRunRequest::single_turn("系统", "任务").with_function_tools(vec![ + platform_llm::LlmFunctionTool::new( + "runtime_tool_file_read", + "读取文件", + serde_json::json!({"type":"object"}), + ), + ]) + } + + #[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"); + assert!(prompt.contains("runtime_tool_file_read")); + assert!(prompt.contains("项目行动只能通过返回 Runtime 函数请求完成")); + assert!(!prompt.contains(GAME_CREATOR_CODEX_CLI_EXECUTABLE)); + let schema = game_creator_codex_cli_tool_output_schema(&tool_request()).expect("schema"); + assert_eq!( + schema.pointer("/properties/toolCalls/items/properties/name/enum/0"), + Some(&serde_json::json!("runtime_tool_file_read")) + ); + } + + #[test] + fn codex_cli_mode_removes_ambient_api_key_from_effective_environment() { + let mut command = tokio::process::Command::new("codex"); + command.env("CODEX_API_KEY", "ambient-secret"); + game_creator_codex_cli_minimal_environment(&mut command); + assert!(!command + .as_std() + .get_envs() + .any(|(name, value)| name == "CODEX_API_KEY" && value.is_some())); + } + + #[tokio::test] + async fn codex_cli_mode_bounds_unterminated_output_and_summarizes_stderr_without_raw_text() { + let output = vec![b'x'; 33]; + let error = read_game_creator_codex_cli_output(output.as_slice(), 32) + .await + .expect_err("unterminated output over the cap must fail"); + assert!(error.contains("超过 32 字节上限")); + + let stderr = b"private-auth-detail-without-newline"; + let summary = summarize_game_creator_codex_cli_stderr(stderr.as_slice(), 256) + .await + .expect("summarize stderr"); + assert_eq!(summary.classification, "nonempty"); + assert_eq!(summary.byte_len, stderr.len()); + assert_eq!(summary.sha256, format!("{:x}", Sha256::digest(stderr))); + assert!(!summary.sha256.contains("private-auth-detail")); + } + + #[test] + fn codex_cli_mode_maps_structured_agent_message_to_existing_tool_calls() { + let stdout = concat!( + "{\"type\":\"thread.started\",\"thread_id\":\"thread-1\"}\n", + "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"{\\\"toolCalls\\\":[{\\\"name\\\":\\\"runtime_tool_file_read\\\",\\\"arguments\\\":\\\"{\\\\\\\"reason\\\\\\\":\\\\\\\"检查\\\\\\\",\\\\\\\"input\\\\\\\":{\\\\\\\"path\\\\\\\":\\\\\\\"game/index.html\\\\\\\"}}\\\"}]}\"}}\n", + "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":12,\"output_tokens\":7}}\n" + ); + let response = parse_game_creator_codex_cli_response(stdout.as_bytes(), &tool_request()) + .expect("parse response"); + assert_eq!(response.response_id.as_deref(), Some("thread-1")); + assert_eq!(response.tool_calls.len(), 1); + assert_eq!(response.tool_calls[0].name, "runtime_tool_file_read"); + assert_eq!(response.usage.expect("usage").total_tokens, 19); + } + + #[test] + fn codex_cli_mode_maps_plain_final_message_and_requires_terminal_event() { + let request = LlmRunRequest::single_turn("系统", "任务"); + let stdout = concat!( + "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"完成\"}}\n", + "{\"type\":\"turn.completed\",\"usage\":{}}\n" + ); + let response = parse_game_creator_codex_cli_response(stdout.as_bytes(), &request) + .expect("parse response"); + assert_eq!(response.text, "完成"); + assert!(response.tool_calls.is_empty()); + assert!(matches!( + parse_game_creator_codex_cli_response( + b"{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"x\"}}\n", + &request + ), + Err(platform_llm::LlmError::Deserialize(_)) + )); + + let recovered_stdout = concat!( + "{\"type\":\"error\",\"message\":\"Reconnecting\"}\n", + "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"恢复成功\"}}\n", + "{\"type\":\"turn.completed\",\"usage\":{}}\n" + ); + assert_eq!( + parse_game_creator_codex_cli_response(recovered_stdout.as_bytes(), &request) + .expect("短暂 error 事件后完成") + .text, + "恢复成功" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn codex_cli_mode_builds_ephemeral_json_exec_with_prompt_on_stdin() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let executable = temp.path().join("fake-codex"); + std::fs::write( + &executable, + r#"#!/bin/sh +prompt="$(cat)" +case "$prompt" in + *"CODEx_STDIN_MARKER"*) ;; + *) exit 41 ;; +esac +has_exec=0 +has_json=0 +has_ephemeral=0 +has_ignore_user_config=0 +has_read_only=0 +has_shell_disabled=0 +previous="" +for argument in "$@"; do + [ "$argument" = "exec" ] && has_exec=1 + [ "$argument" = "--json" ] && has_json=1 + [ "$argument" = "--ephemeral" ] && has_ephemeral=1 + [ "$argument" = "--ignore-user-config" ] && has_ignore_user_config=1 + [ "$previous:$argument" = "--sandbox:read-only" ] && has_read_only=1 + [ "$previous:$argument" = "--disable:shell_tool" ] && has_shell_disabled=1 + previous="$argument" +done +[ "$has_exec$has_json$has_ephemeral$has_ignore_user_config$has_read_only$has_shell_disabled" = "111111" ] || exit 42 +printf '%s\n' '{"type":"thread.started","thread_id":"fake-thread"}' +printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"fixture-ok"}}' +printf '%s\n' '{"type":"turn.completed","usage":{"input_tokens":3,"output_tokens":2}}' +"#, + ) + .expect("write fake codex"); + let mut permissions = std::fs::metadata(&executable) + .expect("fake metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&executable, permissions).expect("chmod fake codex"); + + let request = LlmRunRequest::single_turn("系统", "CODEx_STDIN_MARKER"); + let response = + request_game_creator_agent_codex_cli_with_executable(executable.as_os_str(), request) + .await + .expect("fake codex request"); + assert_eq!(response.text, "fixture-ok"); + assert_eq!(response.response_id.as_deref(), Some("fake-thread")); + assert_eq!(response.usage.expect("usage").total_tokens, 5); + } + + #[cfg(unix)] + #[tokio::test] + async fn codex_cli_mode_does_not_expose_process_stderr_on_failure() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let executable = temp.path().join("fake-codex-failure"); + std::fs::write( + &executable, + "#!/bin/sh\nprintf '%s\\n' 'secret-auth-detail' >&2\nexit 43\n", + ) + .expect("write fake codex"); + let mut permissions = std::fs::metadata(&executable) + .expect("fake metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&executable, permissions).expect("chmod fake codex"); + + let error = request_game_creator_agent_codex_cli_with_executable( + executable.as_os_str(), + LlmRunRequest::single_turn("系统", "任务"), + ) + .await + .expect_err("fake Codex should fail") + .to_string(); + assert!(error.contains("code=43")); + assert!(error.contains("stderrClass=nonempty")); + assert!(error.contains("stderrBytes=19")); + assert!(error.contains("stderrSha256=")); + assert!(error.contains("登录状态")); + assert!(!error.contains("secret-auth-detail")); + } + + #[cfg(unix)] + #[tokio::test] + async fn codex_cli_mode_enforces_runtime_request_timeout() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let executable = temp.path().join("fake-codex-timeout"); + std::fs::write(&executable, "#!/bin/sh\ncat >/dev/null\nsleep 30\n") + .expect("write fake codex"); + let mut permissions = std::fs::metadata(&executable) + .expect("fake metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&executable, permissions).expect("chmod fake codex"); + + let error = request_game_creator_agent_codex_cli_with_executable( + executable.as_os_str(), + LlmRunRequest::single_turn("系统", "任务").with_request_timeout_ms(20), + ) + .await + .expect_err("fake Codex should time out"); + assert_eq!(error, platform_llm::LlmError::Timeout { attempts: 1 }); + } + + #[tokio::test] + #[ignore = "requires an installed and authenticated Codex CLI"] + async fn codex_cli_real_smoke_uses_existing_local_auth() { + let request = LlmRunRequest::single_turn( + "只回复指定文本,不使用任何工具。", + "请只回复 CODEX_CLI_SMOKE_OK", + ); + let response = request_game_creator_agent_codex_cli(request) + .await + .expect("real Codex CLI request"); + assert_eq!(response.text.trim(), "CODEX_CLI_SMOKE_OK"); + assert!(response.tool_calls.is_empty()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs index cf43c709d..1c3ef53a9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs @@ -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 { + 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::(&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 { + 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"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_final_reply.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_final_reply.rs index 6cd4c006b..1543951f7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_final_reply.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_final_reply.rs @@ -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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs index ae7de3fc0..aecddbe12 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs @@ -1008,7 +1008,10 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon "agentLlm.design-director", "测试 Provider handoff 身份漂移", &request, - |_client, _request| async { + load_game_creator_app_config() + .expect("load test agent mode") + .agent_mode, + |_request| async { Err(platform_llm::LlmError::Transport( "identity drift must not call Provider".to_string(), )) @@ -1135,7 +1138,10 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo "agentLlm.project-supervisor", "测试 tool-plan handoff 身份漂移", &base_request, - |_client, _request| async { + load_game_creator_app_config() + .expect("load test agent mode") + .agent_mode, + |_request| async { Err(platform_llm::LlmError::Transport( "identity drift must not call Provider".to_string(), )) @@ -1214,7 +1220,10 @@ async fn generic_retry_identity_drift_closes_tool_plan_repair_chain_before_remov "agentLlm.project-supervisor", "测试通用 retry 身份漂移", &request, - |_client, _request| async { + load_game_creator_app_config() + .expect("load test agent mode") + .agent_mode, + |_request| async { Err(platform_llm::LlmError::Transport( "generic retry drift must not call Provider".to_string(), )) @@ -1326,7 +1335,10 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() { "agentLlm.project-supervisor", "测试 tool-plan 请求前容量门禁", &request, - move |_client, _request| async move { + load_game_creator_app_config() + .expect("load test agent mode") + .agent_mode, + 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(), @@ -1458,7 +1470,10 @@ 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 { + load_game_creator_app_config() + .expect("load test agent mode") + .agent_mode, + |_request| async { Err(platform_llm::LlmError::Transport( "durable control must not call Provider".to_string(), )) @@ -1723,7 +1738,10 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat "agentLlm.design-director", "测试 Provider recovery 冲突", &request, - |_client, _request| async { + load_game_creator_app_config() + .expect("load test agent mode") + .agent_mode, + |_request| async { Err(platform_llm::LlmError::Transport( "reconciliation must not call Provider".to_string(), )) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs index 59cdf8a39..6c595a236 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs @@ -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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/models.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/models.rs index 2739cd17a..5f5b26ccf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/models.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/models.rs @@ -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< diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs index 74b0ce6f6..ea4cf1013 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs @@ -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 { + 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 { + 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 { + 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 { + 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 where - F: FnOnce(LlmClient, LlmRunRequest) -> Fut, + F: FnOnce(LlmRunRequest) -> Fut, Fut: std::future::Future>, 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,8 @@ 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(request.clone()).await.map_err(|error| { game_creator_agent_runtime_provider_error_with_transient_kind( &error, config_path, @@ -1293,6 +1394,11 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_persis operation: &str, request: &LlmRunRequest, ) -> Result { + 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 +1406,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 +1459,33 @@ 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 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()?; let provider_request = async { - client.run(request.clone()).await.map_err(|error| { + 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, @@ -1471,6 +1625,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![ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs index aff04b4a2..caa6910ae 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs @@ -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", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs index 76dfa658c..566577b58 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs @@ -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::() ) } @@ -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 { + 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, 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 { + 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 { + 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 { + 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), 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, ) { @@ -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"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs index 6dd236e66..c5d65d7b6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs @@ -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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/cli.rs b/apps/ai-game-creator-shell/src-tauri/src/cli.rs index 9f08cf712..eec0c244f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -289,6 +289,7 @@ pub(crate) fn take_cli_runtime_config_dir( pub(crate) fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus) -> Vec { let mut lines = vec![ + format!("agent.mode={}", status.agent_mode), format!("llm.configured={}", status.configured), format!("llm.apiKeyPresent={}", status.api_key_present), format!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index bff274d4b..7e1fb6bc0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -675,7 +675,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 +717,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) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 36ce07533..27f8ad432 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -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::>(); + if !required_errors.is_empty() { + status.configured = false; + let mut errors = status.error.take().into_iter().collect::>(); + 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 { + 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::(&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> { 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::(&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 { pub(crate) fn normalize_game_creator_app_config( mut config: GameCreatorAppConfig, ) -> Result { + 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 { + 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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 66ba1a5ed..81ee7ca50 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -621,6 +621,12 @@ struct AgentRuntimeSteerResult { sequence: u64, status: String, provider_interrupted: bool, + #[serde(skip_serializing_if = "Option::is_none")] + assistant_reply: Option, + #[serde(skip_serializing_if = "Option::is_none")] + interrupt_decision: Option, + #[serde(skip_serializing_if = "Option::is_none")] + decision_reason: Option, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -666,6 +672,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, @@ -687,6 +694,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 +717,7 @@ struct GameCreatorAgentLlmConfigStatus { #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorAppConfigFile { + agent_mode: Option, llm: Option, agent_llm: Option>, editor_api: Option, @@ -756,6 +765,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, @@ -1196,6 +1207,9 @@ const DEFAULT_GAME_INDEX_HTML: &str = r#" 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 +1219,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 +1295,7 @@ static GAME_CREATOR_RUNTIME_CONFIG_DIR: OnceLock>> = 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(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner.rs index 475f62ca9..202a774b8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -12,8 +12,9 @@ pub(crate) use client::{ compact_external_agent_runner_context, configure_external_agent_runner, configure_external_agent_runner_read_only, continue_external_agent_runner_action, ensure_external_agent_runner_started, ensure_external_agent_runner_started_for_gui, - notify_external_agent_runner, pause_external_agent_runner, - read_external_agent_runner_mcp_catalog, read_external_agent_runner_status, + interrupt_external_agent_runner_provider_for_steer_decision, notify_external_agent_runner, + pause_external_agent_runner, read_external_agent_runner_mcp_catalog, + read_external_agent_runner_status, require_external_agent_runner_configured_for_cli_runtime_write, require_external_agent_runner_for_cli_runtime_write, resume_external_agent_runner, shutdown_external_agent_runner, shutdown_external_agent_runner_for_client_exit, diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index e4d35d654..4f626d26b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -1438,6 +1438,29 @@ pub(crate) fn steer_external_agent_runner( parse_external_agent_runner_steer_result(&result) } +pub(crate) fn interrupt_external_agent_runner_provider_for_steer_decision( + root: &Path, + agent: &str, + run_id: &str, + steer_id: &str, +) -> Result { + if [agent, run_id, steer_id] + .into_iter() + .any(|value| value.trim().is_empty()) + { + return Err("LLM steer 中断判定必须同时提供 agent/runId/steerId".to_string()); + } + let result = send_external_agent_runner_runtime_request( + root, + "runtime.interrupt_for_steer_decision", + Some(agent.trim()), + Some(run_id.trim()), + None, + Some(steer_id.trim()), + )?; + parse_external_agent_runner_steer_result(&result) +} + pub(crate) fn pause_external_agent_runner( root: &Path, agent: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs index aa17ffefe..7657aeb2b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs @@ -412,6 +412,7 @@ pub(super) fn dispatch_external_agent_runner_runtime_request( | "runtime.resume" | "runtime.continue_action" | "runtime.steer" + | "runtime.interrupt_for_steer_decision" | "runtime.pause" | "runtime.cancel" | "runtime.compact" @@ -430,6 +431,7 @@ pub(super) fn dispatch_external_agent_runner_runtime_request( | "runtime.resume" | "runtime.continue_action" | "runtime.steer" + | "runtime.interrupt_for_steer_decision" | "runtime.pause" | "runtime.cancel" | "runtime.compact" => { @@ -490,10 +492,23 @@ pub(super) fn dispatch_external_agent_runner_runtime_request( crate::validate_game_creator_agent_runtime_steer_notification_at( &root, &agent, &run_id, &steer_id, )?; - let provider_interrupted = - crate::interrupt_game_creator_agent_runtime_provider_request_at( - &root, &agent, &run_id, - )?; + crate::wake_pending_game_creator_agent_background_tasks_at(&root) + .map_err(|error| error.to_string())?; + Ok(json!({ + "accepted": true, + "providerInterrupted": false, + })) + })(), + "runtime.interrupt_for_steer_decision" => (|| { + let agent = external_agent_runner_request_agent(request)?; + let run_id = external_agent_runner_request_run_id(request)?; + let steer_id = external_agent_runner_request_steer_id(request)?; + let provider_interrupted = crate::interrupt_game_creator_agent_runtime_provider_for_decided_steer_at( + &root, + &agent, + &run_id, + &steer_id, + )?; crate::wake_pending_game_creator_agent_background_tasks_at(&root) .map_err(|error| error.to_string())?; Ok(json!({ @@ -845,6 +860,7 @@ pub(super) fn handle_external_agent_runner_request( | "runtime.resume" | "runtime.continue_action" | "runtime.steer" + | "runtime.interrupt_for_steer_decision" | "runtime.pause" | "runtime.cancel" | "runtime.compact" diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs index 20ee160c3..289c76e12 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs @@ -194,6 +194,7 @@ pub(super) fn spawn_external_agent_runner_gui_owner_watchdog( .store(true, Ordering::Release); state.shutdown_requested.store(true, Ordering::Release); thread::sleep(EXTERNAL_AGENT_RUNNER_GUI_OWNER_WATCHDOG_HARD_EXIT_TIMEOUT); + let _ = crate::agent::shutdown_game_creator_codex_app_servers(); remove_external_agent_runner_endpoint_if_boot_matches(&endpoint_path, &boot_id); std::process::exit(1); }) @@ -356,6 +357,12 @@ pub(crate) fn run_external_agent_runner_server( }); } } + if let Err(app_server_error) = crate::agent::shutdown_game_creator_codex_app_servers() { + process_shutdown = Err(match process_shutdown { + Ok(()) => app_server_error, + Err(process_error) => format!("{process_error};{app_server_error}"), + }); + } if let Some(error) = server_error { Err(match process_shutdown { Ok(()) => error, diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index 4e28d62ac..0a12e9b69 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -1231,7 +1231,7 @@ fn typed_steer_result_requires_provider_interrupted_boolean() { } #[test] -fn runtime_steer_reports_provider_interrupt_and_deduplicates_request_id() { +fn runtime_steer_queues_without_interrupt_and_deduplicates_request_id() { let directory = unique_test_directory(); let root = directory.0.join("project"); crate::init_local_game_project_at(&root, "project-steer-rpc", "Runner steer 测试") @@ -1265,6 +1265,14 @@ fn runtime_steer_reports_provider_interrupt_and_deduplicates_request_id() { appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), test_endpoint(token, "steer-rpc-boot", 30303), ); + let (provider_key, active_provider) = + crate::register_game_creator_agent_runtime_provider_request( + &root, + "code-prototype", + "run-steer-rpc", + 0, + ) + .expect("register active Provider before runtime.steer"); let request = ExternalAgentRunnerRequest { protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, request_id: "steer-rpc-request-1".to_string(), @@ -1288,9 +1296,14 @@ fn runtime_steer_reports_provider_interrupt_and_deduplicates_request_id() { .and_then(|value| value["providerInterrupted"].as_bool()), Some(false) ); + assert!( + !active_provider.interrupted.load(Ordering::Acquire), + "runtime.steer must not interrupt an active Provider before the LLM decision" + ); let replay = dispatch_external_agent_runner_runtime_request(&request, &state); assert_eq!(replay, first); + assert!(!active_provider.interrupted.load(Ordering::Acquire)); let mut conflict = request; conflict.params.steer_id = Some("steer-rpc-2".to_string()); @@ -1300,6 +1313,294 @@ fn runtime_steer_reports_provider_interrupt_and_deduplicates_request_id() { conflict.error.as_ref().map(|error| error.code.as_str()), Some("request-id-conflict") ); + crate::unregister_game_creator_agent_runtime_provider_request(&provider_key, &active_provider); +} + +#[test] +fn runtime_interrupt_for_steer_decision_rejects_missing_durable_decision() { + let directory = unique_test_directory(); + let root = directory.0.join("project"); + crate::init_local_game_project_at( + &root, + "project-steer-missing-decision-rpc", + "Runner steer 缺少判定测试", + ) + .expect("initialize missing-decision project"); + let runtime = crate::start_game_creator_agent_runtime_task_for_session_at( + &root, + "code-prototype", + None, + "保持当前 Provider 请求运行", + "run-steer-missing-decision-rpc", + "agent-background-task", + "等待 Supervisor 判定", + vec!["完成当前任务".to_string()], + ) + .expect("start missing-decision runtime"); + let steer = crate::steer_game_creator_agent_runtime_task_at( + &root, + &runtime.agent_id, + &runtime.session_id, + &runtime.run_id, + "steer-missing-decision-rpc-1", + "先告诉我进度。", + "runner-test", + ) + .expect("persist steer without decision"); + assert_eq!(steer.sequence, 1); + let (provider_key, active_provider) = + crate::register_game_creator_agent_runtime_provider_request( + &root, + &runtime.agent_id, + &runtime.run_id, + 0, + ) + .expect("register active Provider before missing decision request"); + let token = "steer-missing-decision-token-steer-missing-decision-token"; + let state = ExternalAgentRunnerServerState::new( + directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint(token, "steer-missing-decision-boot", 30304), + ); + let response = dispatch_external_agent_runner_runtime_request( + &ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: "steer-missing-decision-request-1".to_string(), + token: token.to_string(), + method: "runtime.interrupt_for_steer_decision".to_string(), + params: ExternalAgentRunnerRequestParams { + root: Some(root.to_string_lossy().into_owned()), + agent: Some(runtime.agent_id.clone()), + run_id: Some(runtime.run_id.clone()), + steer_id: Some(steer.steer_id), + ..ExternalAgentRunnerRequestParams::default() + }, + }, + &state, + ); + + assert!(!response.ok); + assert_eq!( + response.error.as_ref().map(|error| error.code.as_str()), + Some("runtime-error") + ); + assert!(response + .error + .as_ref() + .is_some_and(|error| error.message.contains("尚无 LLM 中断判定"))); + assert!( + !active_provider.interrupted.load(Ordering::Acquire), + "a missing durable LLM decision must leave the active Provider running" + ); + crate::unregister_game_creator_agent_runtime_provider_request(&provider_key, &active_provider); +} + +#[test] +fn runtime_interrupt_for_false_steer_decision_keeps_provider_running() { + let directory = unique_test_directory(); + let root = directory.0.join("project"); + crate::init_local_game_project_at( + &root, + "project-steer-false-decision-rpc", + "Runner steer 不中断判定测试", + ) + .expect("initialize false-decision project"); + let runtime = crate::start_game_creator_agent_runtime_task_for_session_at( + &root, + "code-prototype", + None, + "保持当前 Provider 请求运行", + "run-steer-false-decision-rpc", + "agent-background-task", + "处理状态询问", + vec!["完成当前任务".to_string()], + ) + .expect("start false-decision runtime"); + let steer = crate::steer_game_creator_agent_runtime_task_at( + &root, + &runtime.agent_id, + &runtime.session_id, + &runtime.run_id, + "steer-false-decision-rpc-1", + "现在做到哪一步了?", + "runner-test", + ) + .expect("persist status-question steer"); + crate::persist_game_creator_agent_runtime_steer_decision_and_reply_at( + &root, + &runtime, + &steer.steer_id, + steer.sequence, + crate::AgentRuntimeSteerDecision { + reply: "当前任务仍在继续,我会按现有方向完成。".to_string(), + interrupt_current_provider: false, + reason: "状态询问不会让当前方案过期。".to_string(), + }, + ) + .expect("persist non-interrupting LLM decision"); + let (provider_key, active_provider) = + crate::register_game_creator_agent_runtime_provider_request( + &root, + &runtime.agent_id, + &runtime.run_id, + 0, + ) + .expect("register active Provider for false decision"); + let token = "steer-false-decision-token-steer-false-decision-token"; + let state = ExternalAgentRunnerServerState::new( + directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint(token, "steer-false-decision-boot", 30305), + ); + let response = dispatch_external_agent_runner_runtime_request( + &ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: "steer-false-decision-request-1".to_string(), + token: token.to_string(), + method: "runtime.interrupt_for_steer_decision".to_string(), + params: ExternalAgentRunnerRequestParams { + root: Some(root.to_string_lossy().into_owned()), + agent: Some(runtime.agent_id.clone()), + run_id: Some(runtime.run_id.clone()), + steer_id: Some(steer.steer_id), + ..ExternalAgentRunnerRequestParams::default() + }, + }, + &state, + ); + + assert!( + response.ok, + "false decision RPC failed: {:?}", + response.error + ); + assert_eq!( + response + .result + .as_ref() + .and_then(|value| value["providerInterrupted"].as_bool()), + Some(false) + ); + assert!(!active_provider.interrupted.load(Ordering::Acquire)); + crate::unregister_game_creator_agent_runtime_provider_request(&provider_key, &active_provider); +} + +#[test] +fn runtime_interrupt_for_true_steer_decision_only_interrupts_older_provider_cursor() { + let directory = unique_test_directory(); + let root = directory.0.join("project"); + crate::init_local_game_project_at( + &root, + "project-steer-true-decision-rpc", + "Runner steer 条件中断测试", + ) + .expect("initialize true-decision project"); + let runtime = crate::start_game_creator_agent_runtime_task_for_session_at( + &root, + "code-prototype", + None, + "按当前方向实现玩法", + "run-steer-true-decision-rpc", + "agent-background-task", + "生成当前方案", + vec!["完成当前任务".to_string()], + ) + .expect("start true-decision runtime"); + let steer = crate::steer_game_creator_agent_runtime_task_at( + &root, + &runtime.agent_id, + &runtime.session_id, + &runtime.run_id, + "steer-true-decision-rpc-1", + "停止旧方向,改成全新的玩法。", + "runner-test", + ) + .expect("persist conflicting steer"); + crate::persist_game_creator_agent_runtime_steer_decision_and_reply_at( + &root, + &runtime, + &steer.steer_id, + steer.sequence, + crate::AgentRuntimeSteerDecision { + reply: "这个改动会让旧方案过期,我会安全切换方向。".to_string(), + interrupt_current_provider: true, + reason: "当前 Provider 正在生成已经冲突的旧方案。".to_string(), + }, + ) + .expect("persist interrupting LLM decision"); + let token = "steer-true-decision-token-steer-true-decision-token"; + let state = ExternalAgentRunnerServerState::new( + directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint(token, "steer-true-decision-boot", 30306), + ); + let request = |request_id: &str| ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: request_id.to_string(), + token: token.to_string(), + method: "runtime.interrupt_for_steer_decision".to_string(), + params: ExternalAgentRunnerRequestParams { + root: Some(root.to_string_lossy().into_owned()), + agent: Some(runtime.agent_id.clone()), + run_id: Some(runtime.run_id.clone()), + steer_id: Some(steer.steer_id.clone()), + ..ExternalAgentRunnerRequestParams::default() + }, + }; + + let (old_provider_key, old_provider) = + crate::register_game_creator_agent_runtime_provider_request( + &root, + &runtime.agent_id, + &runtime.run_id, + steer.sequence.saturating_sub(1), + ) + .expect("register Provider created before steer"); + let old_response = dispatch_external_agent_runner_runtime_request( + &request("steer-true-decision-old-provider-request"), + &state, + ); + assert!( + old_response.ok, + "old Provider interrupt RPC failed: {:?}", + old_response.error + ); + assert_eq!( + old_response + .result + .as_ref() + .and_then(|value| value["providerInterrupted"].as_bool()), + Some(true) + ); + assert!(old_provider.interrupted.load(Ordering::Acquire)); + crate::unregister_game_creator_agent_runtime_provider_request(&old_provider_key, &old_provider); + + let (new_provider_key, new_provider) = + crate::register_game_creator_agent_runtime_provider_request( + &root, + &runtime.agent_id, + &runtime.run_id, + steer.sequence, + ) + .expect("register Provider created after steer was applied"); + let new_response = dispatch_external_agent_runner_runtime_request( + &request("steer-true-decision-new-provider-request"), + &state, + ); + assert!( + new_response.ok, + "new Provider keep-running RPC failed: {:?}", + new_response.error + ); + assert_eq!( + new_response + .result + .as_ref() + .and_then(|value| value["providerInterrupted"].as_bool()), + Some(false) + ); + assert!( + !new_provider.interrupted.load(Ordering::Acquire), + "a Provider created at the decided steer cursor must keep running" + ); + crate::unregister_game_creator_agent_runtime_provider_request(&new_provider_key, &new_provider); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs index bcf3f77b0..531b0b4c6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs @@ -348,7 +348,7 @@ fn steer_and_wait_for_swarm_turn( ) -> Result { require_external_agent_runner_for_cli_runtime_write(root)?; let steer_id = format!("swarm-steer-{}", unix_millis()); - let result = steer_game_creator_agent_runtime_task( + let result = tauri::async_runtime::block_on(steer_game_creator_agent_runtime_task( root.display().to_string(), parent_agent_id.to_string(), session_id.to_string(), @@ -357,7 +357,7 @@ fn steer_and_wait_for_swarm_turn( message.to_string(), Some(run_profile.to_string()), None, - )?; + ))?; writeln!( output, "[{label}] run={} steer={} providerInterrupted={}", diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs index 99e24580b..994acd3e2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs @@ -347,15 +347,17 @@ pub(super) fn wait_for_swarm_turn( &runtimes, ) { let steer_id = format!("swarm-steer-{}", unix_millis()); - let result = steer_game_creator_agent_runtime_task( - root.display().to_string(), - parent_agent_id.to_string(), - parent.state.session_id.clone(), - parent.state.run_id.clone(), - steer_id.clone(), - message, - Some(run_profile.to_string()), - None, + let result = tauri::async_runtime::block_on( + steer_game_creator_agent_runtime_task( + root.display().to_string(), + parent_agent_id.to_string(), + parent.state.session_id.clone(), + parent.state.run_id.clone(), + steer_id.clone(), + message, + Some(run_profile.to_string()), + None, + ), )?; writeln!( output, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs index 0276cb20d..3983af83f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs @@ -100,6 +100,114 @@ fn config_file_overrides_defaults_without_env() { fs::remove_dir_all(root).expect("cleanup test config dir"); } +#[test] +fn agent_mode_defaults_to_codex_app_server_and_preserves_explicit_modes() { + let default_config = GameCreatorAppConfig::default(); + assert_eq!( + default_config.agent_mode, + GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER + ); + + let root = unique_project_path(); + fs::create_dir_all(&root).expect("runtime config dir"); + let config_path = root.join(GAME_CREATOR_CONFIG_FILE_NAME); + fs::write(&config_path, r#"{"agentMode":"provider"}"#).expect("write explicit provider mode"); + let mut config = GameCreatorAppConfig::default(); + merge_game_creator_config_file(&mut config, &config_path).expect("merge provider mode"); + assert_eq!(config.agent_mode, GAME_CREATOR_AGENT_MODE_PROVIDER); + assert_eq!( + normalize_game_creator_agent_mode(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER) + .expect("app-server mode"), + GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER + ); + assert_eq!( + normalize_game_creator_agent_mode(GAME_CREATOR_AGENT_MODE_CODEX_CLI).expect("cli mode"), + GAME_CREATOR_AGENT_MODE_CODEX_CLI + ); + assert!(normalize_game_creator_agent_mode("unknown") + .expect_err("unknown mode") + .contains("agentMode")); + fs::remove_dir_all(root).expect("cleanup runtime config dir"); +} + +#[test] +fn legacy_agent_mode_migration_preserves_non_responses_provider_routes() { + let responses: GameCreatorAppConfigFile = serde_json::from_value(serde_json::json!({ + "llm": { "apiKind": "openai_responses" } + })) + .expect("responses config"); + assert_eq!( + legacy_game_creator_agent_mode(&responses), + Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER) + ); + + let chat: GameCreatorAppConfigFile = serde_json::from_value(serde_json::json!({ + "llm": { "apiKind": "openai_chat" } + })) + .expect("chat config"); + assert_eq!( + legacy_game_creator_agent_mode(&chat), + Some(GAME_CREATOR_AGENT_MODE_PROVIDER) + ); + + let mixed: GameCreatorAppConfigFile = serde_json::from_value(serde_json::json!({ + "llm": { "apiKind": "openai_responses" }, + "agentLlm": { "art-director": { "apiKind": "anthropic" } } + })) + .expect("mixed config"); + assert_eq!( + legacy_game_creator_agent_mode(&mixed), + Some(GAME_CREATOR_AGENT_MODE_PROVIDER) + ); + + let explicit: GameCreatorAppConfigFile = serde_json::from_value(serde_json::json!({ + "agentMode": "codex_cli", + "llm": { "apiKind": "openai_chat" } + })) + .expect("explicit config"); + assert_eq!(legacy_game_creator_agent_mode(&explicit), None); +} + +#[test] +fn codex_app_server_requires_responses_route_and_disables_native_web_search() { + let mut llm = GameCreatorLlmConfig::default(); + llm.api_key = "secret".to_string(); + llm.api_kind = "anthropic".to_string(); + assert!(game_creator_codex_app_server_llm_route_error( + GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER, + &llm, + "llm" + ) + .expect("unsupported route") + .contains("provider 模式")); + + llm.api_key.clear(); + assert!(game_creator_codex_app_server_llm_route_error( + GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER, + &llm, + "llm" + ) + .expect("unsupported empty-key route") + .contains("provider 模式")); + llm.api_kind = "openai_responses".to_string(); + llm.web_search_enabled = true; + assert!(game_creator_codex_app_server_llm_route_error( + GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER, + &llm, + "llm" + ) + .expect("unsupported web search") + .contains("webSearchEnabled")); + llm.web_search_enabled = false; + llm.api_key = "secret".to_string(); + assert!(game_creator_codex_app_server_llm_route_error( + GAME_CREATOR_AGENT_MODE_CODEX_CLI, + &llm, + "llm" + ) + .is_none()); +} + #[test] fn legacy_llm_config_deserialization_supplies_context_budget_defaults() { let llm: GameCreatorLlmConfig = serde_json::from_value(serde_json::json!({ @@ -189,6 +297,10 @@ fn canonical_agent_reasoning_effort_defaults_are_exhaustive_and_auditable() { let template = serde_json::from_str::(DEFAULT_GAME_CREATOR_APP_CONFIG_JSON) .expect("parse bundled runtime config template"); + assert_eq!( + template.agent_mode.as_deref(), + Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER) + ); assert_eq!( template.llm.as_ref().and_then(|llm| llm.max_retries), Some(DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES) @@ -260,6 +372,7 @@ fn empty_legacy_agent_llm_uses_agent_defaults_and_explicit_patch_wins() { fs::write( root.join(GAME_CREATOR_CONFIG_FILE_NAME), r#"{ + "agentMode": "provider", "llm": { "apiKey": "global-key", "baseUrl": "https://global.example.test/v1", @@ -365,6 +478,10 @@ fn runtime_config_read_returns_defaults_when_file_is_missing() { .to_string() ); assert_eq!(result.config.llm.api_key, ""); + assert_eq!( + result.config.agent_mode, + GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER + ); assert_eq!( result.config.llm.base_url, DEFAULT_GAME_CREATOR_LLM_BASE_URL @@ -436,6 +553,7 @@ fn app_config_commands_write_runtime_config_file() { agent_llm.insert("generator".to_string(), GameCreatorLlmConfigFile::default()); let saved = write_game_creator_app_config(GameCreatorAppConfig { + agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(), llm: GameCreatorLlmConfig { api_key: " unit-test-key ".to_string(), base_url: " https://runtime.example.test/v1 ".to_string(), @@ -528,6 +646,7 @@ fn app_config_write_rejects_invalid_api_kind() { let _guard = use_test_runtime_config_dir(root.clone()); let result = write_game_creator_app_config(GameCreatorAppConfig { + agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(), llm: GameCreatorLlmConfig { api_key: String::new(), api_kind: "legacy".to_string(), @@ -552,6 +671,7 @@ fn app_config_write_rejects_invalid_reasoning_effort() { let _guard = use_test_runtime_config_dir(root.clone()); let result = write_game_creator_app_config(GameCreatorAppConfig { + agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(), llm: GameCreatorLlmConfig { reasoning_effort: "maximum".to_string(), ..GameCreatorLlmConfig::default() @@ -575,6 +695,7 @@ fn app_config_write_rejects_too_small_request_timeout() { let _guard = use_test_runtime_config_dir(root.clone()); let result = write_game_creator_app_config(GameCreatorAppConfig { + agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(), llm: GameCreatorLlmConfig { request_timeout_ms: MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS - 1, ..GameCreatorLlmConfig::default() @@ -697,6 +818,7 @@ fn llm_config_check_reports_per_agent_status_without_leaking_keys() { fs::write( root.join(GAME_CREATOR_CONFIG_FILE_NAME), r#"{ + "agentMode": "provider", "llm": { "apiKey": "", "baseUrl": "https://global.example.test/v1", @@ -799,6 +921,7 @@ fn llm_config_check_reports_agent_specific_config_paths() { fs::write( root.join(GAME_CREATOR_CONFIG_FILE_NAME), r#"{ + "agentMode": "provider", "llm": { "apiKey": "global-key", "baseUrl": "https://global.example.test/v1", @@ -835,6 +958,7 @@ fn llm_config_check_reports_agent_specific_config_paths() { #[test] fn llm_status_cli_lines_include_agent_errors_without_leaking_keys() { let status = GameCreatorLlmConfigStatus { + agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(), configured: false, api_key_present: false, base_url: Some("https://global.example.test/v1".to_string()), @@ -851,6 +975,7 @@ fn llm_status_cli_lines_include_agent_errors_without_leaking_keys() { retry_backoff_ms: 500, error: Some("Generator:缺少 API Key".to_string()), agents: vec![GameCreatorAgentLlmConfigStatus { + agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(), agent_id: "generator".to_string(), label: "Generator".to_string(), configured: false, @@ -1050,6 +1175,7 @@ fn anthropic_web_search_is_rejected_with_precise_global_and_agent_paths() { fn llm_config_status_preserves_global_web_search_error_when_required_agents_override() { let _config_guard = write_test_local_config( r#"{ + "agentMode": "provider", "llm": { "apiKey": "test-key", "baseUrl": "https://anthropic.example.test/v1", diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 888893f6a..5e2dad361 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -1075,6 +1075,17 @@ pub(crate) fn write_test_local_config(content: String) -> TestConfigGuard { .unwrap_or_else(|poisoned| poisoned.into_inner()); let path = test_local_config_path(); let previous = fs::read(&path).ok(); + let content = match serde_json::from_str::(&content) { + Ok(mut config) => { + if let Some(config) = config.as_object_mut() { + config + .entry("agentMode") + .or_insert_with(|| serde_json::json!(GAME_CREATOR_AGENT_MODE_PROVIDER)); + } + serde_json::to_vec_pretty(&config).expect("serialize explicit Provider test config") + } + Err(_) => content.into_bytes(), + }; replace_test_local_config(&path, content); TestConfigGuard { _lock: lock, @@ -2077,6 +2088,7 @@ fn spawn_mock_llm_tool_plan_then_transient_final_compaction( &config_path, format!( r#"{{ + "agentMode": "provider", "agentLlm": {{ "design-director": {{ "apiKey": "final-compaction-key", diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index d74919e16..cc12b52ee 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -3821,6 +3821,7 @@ async fn provider_retry_http_and_deserialize_failures_recover_through_durable_si ); let _config_guard = write_test_local_config(format!( r#"{{ + "agentMode": "provider", "agentLlm": {{ "design-director": {{ "apiKey": "provider-classification-key", @@ -5430,6 +5431,7 @@ async fn tool_plan_handoff_base_restart_replays_success_without_network_request( &config_path, format!( r#"{{ + "agentMode": "provider", "agentLlm": {{ "design-director": {{ "apiKey": "tool-plan-handoff-base-key", @@ -5568,6 +5570,7 @@ async fn tool_plan_handoff_repair_restart_replays_chain_without_network_request( &config_path, format!( r#"{{ + "agentMode": "provider", "agentLlm": {{ "design-director": {{ "apiKey": "tool-plan-handoff-repair-key", @@ -8213,7 +8216,7 @@ fn prompt_context_tail_truncation_keeps_latest_blackboard_and_agent_message() { } #[tokio::test] -async fn agent_runtime_steer_interrupts_only_the_active_provider_wait() { +async fn agent_runtime_steer_waits_for_llm_decision_before_interrupting_old_provider() { let root = unique_project_path(); let state = start_agent_runtime_steer_fixture(&root, "steer-provider-run"); let (provider_started_tx, provider_started_rx) = tokio::sync::oneshot::channel(); @@ -8221,7 +8224,7 @@ async fn agent_runtime_steer_interrupts_only_the_active_provider_wait() { let wait_agent = state.agent_id.clone(); let wait_session = state.session_id.clone(); let wait_run = state.run_id.clone(); - let provider_wait = tokio::spawn(async move { + let mut provider_wait = tokio::spawn(async move { await_game_creator_agent_runtime_provider_request( &wait_root, &wait_agent, @@ -8248,10 +8251,37 @@ async fn agent_runtime_steer_interrupts_only_the_active_provider_wait() { "test", ) .expect("queue steer during provider wait"); - assert!(steer.provider_interrupted); - let outcome = tokio::time::timeout(Duration::from_secs(2), provider_wait) + assert!(!steer.provider_interrupted); + assert!( + tokio::time::timeout(Duration::from_millis(100), &mut provider_wait) + .await + .is_err(), + "queueing steer alone must leave the active Provider request running" + ); + persist_game_creator_agent_runtime_steer_decision_and_reply_at( + &root, + &state, + "steer-provider-1", + steer.sequence, + AgentRuntimeSteerDecision { + reply: "这个要求会使旧方案过期,我会改向。".to_string(), + interrupt_current_provider: true, + reason: "旧 Provider 正在生成与新要求冲突的方案。".to_string(), + }, + ) + .expect("persist LLM steer decision"); + assert!( + interrupt_game_creator_agent_runtime_provider_for_decided_steer_at( + &root, + &state.agent_id, + &state.run_id, + "steer-provider-1", + ) + .expect("interrupt stale Provider after LLM decision") + ); + let outcome = tokio::time::timeout(Duration::from_secs(2), &mut provider_wait) .await - .expect("provider wait interrupted promptly") + .expect("provider wait interrupted after decision") .expect("provider wait task joined") .expect("provider wait result"); assert!(outcome.is_none()); @@ -8278,6 +8308,269 @@ async fn agent_runtime_steer_interrupts_only_the_active_provider_wait() { })); } +#[tokio::test] +async fn root_supervisor_steer_decision_uses_llm_and_persists_non_terminal_reply() { + let root = unique_project_path(); + init_local_game_project_at(&root, "supervisor-steer-llm", "总控边做边聊测试") + .expect("initialize Supervisor steer decision project"); + let state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "完成当前可玩版本", + "supervisor-steer-llm-run", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + "正在实现玩法", + vec!["实现并验证玩法".to_string()], + ) + .expect("start Supervisor runtime"); + let steer = steer_game_creator_agent_runtime_task_at( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "supervisor-steer-llm-1", + "你现在在做什么?", + "test", + ) + .expect("queue Supervisor status question"); + let arguments = serde_json::json!({ + "reply": "我正在实现核心玩法,当前任务会继续。", + "interruptCurrentProvider": false, + "reason": "状态询问不会让当前方案过期。" + }) + .to_string(); + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_raw_responses_with_capture( + vec![native_agent_tool_plan_chat_response( + "steer-decision-call", + AGENT_RUNTIME_STEER_DECISION_TOOL, + arguments, + )], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentMode": "provider", + "agentLlm": {{ + "project-supervisor": {{ + "apiKey": "steer-decision-key", + "baseUrl": {base_url:?}, + "model": "steer-decision-model", + "apiKind": "openai_chat", + "stream": false, + "maxRetries": 0 + }} + }} +}}"# + )); + + let decision = decide_game_creator_agent_runtime_steer_at( + &root, + &state, + &steer.steer_id, + steer.sequence, + "你现在在做什么?", + ) + .await + .expect("run Supervisor steer LLM decision"); + + assert_eq!(decision.reply, "我正在实现核心玩法,当前任务会继续。"); + assert!(!decision.interrupt_current_provider); + let request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("capture Supervisor steer decision request"); + assert!(request.contains(AGENT_RUNTIME_STEER_DECISION_TOOL)); + assert!(request.contains("你现在在做什么?")); + let project_conversation = + read_local_conversation_at(&root, None).expect("read public Supervisor steer reply"); + assert_eq!( + project_conversation + .messages + .iter() + .filter(|message| message.content == decision.reply) + .count(), + 1 + ); + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn root_supervisor_steer_decision_failure_keeps_provider_and_persists_fallback_reply() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "supervisor-steer-decision-failure", + "总控判定失败继续任务测试", + ) + .expect("initialize Supervisor steer decision failure project"); + let state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "完成当前可玩版本", + "supervisor-steer-decision-failure-run", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + "正在实现玩法", + vec!["实现并验证玩法".to_string()], + ) + .expect("start Supervisor runtime"); + let (provider_started_tx, provider_started_rx) = tokio::sync::oneshot::channel(); + let wait_root = root.clone(); + let wait_agent = state.agent_id.clone(); + let wait_session = state.session_id.clone(); + let wait_run = state.run_id.clone(); + let mut provider_wait = tokio::spawn(async move { + await_game_creator_agent_runtime_provider_request( + &wait_root, + &wait_agent, + &wait_session, + &wait_run, + "tool-plan", + "steer-decision-failure-active-provider", + 0, + async move { + let _ = provider_started_tx.send(()); + std::future::pending::>().await + }, + ) + .await + }); + provider_started_rx.await.expect("provider future started"); + let steer = steer_game_creator_agent_runtime_task_at( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "supervisor-steer-decision-failure-1", + "现在做到哪里了?", + "test", + ) + .expect("queue Supervisor steer before failed decision"); + let base_url = spawn_mock_llm_raw_responses_with_capture( + vec![native_agent_tool_plan_chat_response( + "unexpected-steer-decision-call", + "runtime_execute", + "{}".to_string(), + )], + None, + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentMode": "provider", + "agentLlm": {{ + "project-supervisor": {{ + "apiKey": "steer-decision-failure-key", + "baseUrl": {base_url:?}, + "model": "steer-decision-failure-model", + "apiKind": "openai_chat", + "stream": false, + "maxRetries": 0 + }} + }} +}}"# + )); + + let error = decide_game_creator_agent_runtime_steer_at( + &root, + &state, + &steer.steer_id, + steer.sequence, + "现在做到哪里了?", + ) + .await + .expect_err("invalid decision tool must fail closed"); + let fallback = append_game_creator_agent_runtime_steer_decision_failure_reply_at( + &root, + &state, + &steer.steer_id, + &error, + ) + .expect("persist non-terminal decision failure reply"); + + assert!( + tokio::time::timeout(Duration::from_millis(100), &mut provider_wait) + .await + .is_err(), + "failed steer decision must not interrupt the active Provider" + ); + let runtime = read_game_creator_agent_runtime_at(&root, &state.agent_id) + .expect("read continuing Supervisor runtime"); + assert_eq!(runtime.state.run_id, state.run_id); + assert!(!matches!( + runtime.state.status.as_str(), + "completed" | "failed" | "cancelled" + )); + let project_conversation = + read_local_conversation_at(&root, None).expect("read public fallback reply"); + assert_eq!( + project_conversation + .messages + .iter() + .filter(|message| message.content == fallback) + .count(), + 1 + ); + assert!(read_game_creator_agent_runtime_steer_decision_at( + &root, + &state.agent_id, + &state.run_id, + &steer.steer_id, + ) + .expect("read absent failed decision") + .is_none()); + + provider_wait.abort(); + let _ = provider_wait.await; + fs::remove_dir_all(root).ok(); +} + +#[test] +fn steer_decision_never_interrupts_provider_started_after_steer_was_applied() { + let root = unique_project_path(); + let state = start_agent_runtime_steer_fixture(&root, "steer-new-provider-run"); + let steer = steer_game_creator_agent_runtime_task_at( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "steer-new-provider-1", + "改成新的实现方向。", + "test", + ) + .expect("queue steer before new Provider"); + persist_game_creator_agent_runtime_steer_decision_and_reply_at( + &root, + &state, + "steer-new-provider-1", + steer.sequence, + AgentRuntimeSteerDecision { + reply: "我会按新方向继续。".to_string(), + interrupt_current_provider: true, + reason: "旧方向已经过期。".to_string(), + }, + ) + .expect("persist interrupt decision"); + let (key, active) = register_game_creator_agent_runtime_provider_request( + &root, + &state.agent_id, + &state.run_id, + steer.sequence, + ) + .expect("register Provider created after steer application"); + + assert!( + !interrupt_game_creator_agent_runtime_provider_for_decided_steer_at( + &root, + &state.agent_id, + &state.run_id, + "steer-new-provider-1", + ) + .expect("keep new Provider running") + ); + assert!(!active.interrupted.load(Ordering::Acquire)); + unregister_game_creator_agent_runtime_provider_request(&key, &active); + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn agent_runtime_durable_cancel_after_provider_registration_starts_no_request() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs index 37c5f11f1..b9d345fde 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs @@ -341,6 +341,7 @@ async fn response_stream_disabled_keeps_direct_planning_reply_to_one_request() { let base_url = mock.base_url.clone(); let _config_guard = write_test_local_config(format!( r#"{{ + "agentMode": "provider", "agentLlm": {{ "design-director": {{ "apiKey": "response-stream-direct-key", @@ -434,6 +435,7 @@ async fn response_stream_private_process_output_is_never_published_or_committed_ let base_url = mock.base_url.clone(); let _config_guard = write_test_local_config(format!( r#"{{ + "agentMode": "provider", "agentLlm": {{ "code-prototype": {{ "apiKey": "response-stream-private-key", @@ -632,6 +634,7 @@ async fn response_stream_final_failure_with_retry_disabled_commits_planning_fall let base_url = mock.base_url.clone(); let _config_guard = write_test_local_config(format!( r#"{{ + "agentMode": "provider", "agentLlm": {{ "design-director": {{ "apiKey": "response-stream-retry-key", @@ -797,6 +800,7 @@ async fn provider_retry_final_reply_thinking_only_response_retries_before_handof &config_path, format!( r#"{{ + "agentMode": "provider", "agentLlm": {{ "design-director": {{ "apiKey": "final-reply-empty-normalization-key", @@ -3820,6 +3824,7 @@ async fn provider_handoff_final_reply_restart_replays_success_without_network_re &config_path, format!( r#"{{ + "agentMode": "provider", "agentLlm": {{ "design-director": {{ "apiKey": "handoff-final-reply-key", @@ -4071,6 +4076,7 @@ async fn provider_handoff_final_reply_compaction_restart_only_requests_final_rep &config_path, format!( r#"{{ + "agentMode": "provider", "agentLlm": {{ "design-director": {{ "apiKey": "handoff-final-compaction-key", diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/task_lifecycle.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/task_lifecycle.rs index 905bfc1fa..d44047373 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/task_lifecycle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/task_lifecycle.rs @@ -580,7 +580,10 @@ async fn background_agent_runtime_inflight_steer_discards_old_plan_without_new_t "test", ) .expect("steer in-flight provider"); - assert!(steer.provider_interrupted); + assert!( + !steer.provider_interrupted, + "steer must not interrupt an in-flight Provider before an LLM decision" + ); let old_plan = serde_json::json!({ "thinkingSummary": "旧计划准备写文件", diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs index 5fb1cd29f..7a8db23a2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs @@ -957,6 +957,7 @@ async fn background_final_reply_failure_keeps_private_conversation_and_hashes_pu &config_path, format!( r#"{{ + "agentMode": "provider", "agentLlm": {{ "design-director": {{ "apiKey": "design-key", @@ -1168,6 +1169,7 @@ fn structured_plan_state_write_failure_stops_before_context_and_audit() { ); let _config_guard = write_test_local_config(format!( r#"{{ + "agentMode": "provider", "agentLlm": {{ "design-director": {{ "apiKey": "design-key", @@ -4934,6 +4936,107 @@ fn agent_runtime_steer_is_idempotent_rejects_conflicts_and_enforces_capacity() { .is_err()); } +#[test] +fn root_supervisor_steer_persists_one_correlated_public_acknowledgement() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-steer-public-ack", "总控追加消息确认测试") + .expect("initialize Supervisor steer fixture"); + let state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "制作一个可玩的游戏", + "supervisor-steer-public-ack-run", + "project-supervisor-gui", + "正在制作游戏", + vec!["完成可玩版本".to_string()], + ) + .expect("start root Supervisor runtime"); + + for _ in 0..2 { + steer_game_creator_agent_runtime_task_at( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "supervisor-steer-public-ack-1", + "你在做什么?", + "test", + ) + .expect("queue idempotent Supervisor steer"); + } + for _ in 0..2 { + persist_game_creator_agent_runtime_steer_decision_and_reply_at( + &root, + &state, + "supervisor-steer-public-ack-1", + 1, + AgentRuntimeSteerDecision { + reply: "我正在完成玩法实现;这是状态询问,当前任务会继续。".to_string(), + interrupt_current_provider: false, + reason: "状态询问不需要中断当前 Provider。".to_string(), + }, + ) + .expect("persist idempotent Supervisor steer reply"); + } + + let supervisor_conversation = read_local_conversation_for_session_at( + &root, + Some(&state.agent_id), + Some(&state.session_id), + ) + .expect("read Supervisor steer conversation"); + let steer_message = supervisor_conversation + .messages + .iter() + .find(|message| message.role == "user" && message.content == "你在做什么?") + .expect("find persisted steer user message"); + let correlation_id = steer_message + .message_id + .as_deref() + .and_then(|message_id| message_id.strip_prefix("agent-steer-")) + .expect("read steer correlation id"); + + let project_conversation = + read_local_conversation_at(&root, None).expect("read public steer acknowledgement"); + let acknowledgements = project_conversation + .messages + .iter() + .filter(|message| { + message.content == "收到。我正在判断这条消息是否需要调整当前任务;现有任务会继续运行。" + }) + .collect::>(); + assert_eq!(acknowledgements.len(), 1); + assert!(acknowledgements[0] + .message_id + .as_deref() + .is_some_and(|message_id| { + message_id.starts_with(&format!( + "{AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX}{correlation_id}-" + )) + })); + assert_eq!( + project_conversation + .messages + .iter() + .filter(|message| { + message.content == "我正在完成玩法实现;这是状态询问,当前任务会继续。" + }) + .count(), + 1 + ); + let decision = read_game_creator_agent_runtime_steer_decision_at( + &root, + &state.agent_id, + &state.run_id, + "supervisor-steer-public-ack-1", + ) + .expect("read persisted Supervisor steer decision") + .expect("Supervisor steer decision exists"); + assert!(!decision.interrupt_current_provider); + + fs::remove_dir_all(root).ok(); +} + #[test] fn agent_runtime_steer_sequences_are_unique_under_concurrent_acceptance() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index fc097ae68..266eb823e 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -141,8 +141,10 @@ import { formatAgentDialogLlmStatus, formatAgentLlmConfigWarning, formatAgentRunControlError, + formatCodexAgentModeLabel, formatLlmAgentStatusLine, formatLlmRouteEndpoint, + isCodexAgentMode, isMissingAgentRunTraceError, projectAgentRuntimeSummaries, readablePassArtifactsFromAgentRunTrace, @@ -831,9 +833,7 @@ export function App({ projectSupervisorResponseStreamRef.current = projectSupervisorResponseStream; const refreshManifest = useCallback( - ( - nextProjectPath = localProjectPathRef.current ?? '', - ): Promise => { + (nextProjectPath = localProjectPathRef.current ?? ''): Promise => { const invoke = resolveTauriInvoke(); if (!invoke || !nextProjectPath) { return Promise.resolve(); @@ -5601,13 +5601,19 @@ export function App({ setLlmConfigStatus(status); setCommandLog((current) => [...current, 'llm.config_check']); const agentLines = (status.agents ?? []).map(formatLlmAgentStatusLine); - const summary = status.configured - ? `LLM 已配置:${formatLlmRouteEndpoint(status)}。` - : `LLM 未就绪:${status.error ?? '配置不完整'}。${ - status.reasoningEffort ? `推理 ${status.reasoningEffort},` : '' - }联网检索 ${status.webSearchEnabled ? '开启' : '关闭'},API Key:${ - status.apiKeyPresent ? '已读取' : '未读取' - }。`; + const summary = isCodexAgentMode(status.agentMode) + ? status.configured + ? `${formatCodexAgentModeLabel(status.agentMode)} 已检测到;登录与网络将在首次节点调用时验证。` + : `${formatCodexAgentModeLabel(status.agentMode)} 未就绪:${ + status.error ?? 'Codex CLI 不可用' + }。` + : status.configured + ? `LLM 已配置:${formatLlmRouteEndpoint(status)}。` + : `LLM 未就绪:${status.error ?? '配置不完整'}。${ + status.reasoningEffort ? `推理 ${status.reasoningEffort},` : '' + }联网检索 ${status.webSearchEnabled ? '开启' : '关闭'},API Key:${ + status.apiKeyPresent ? '已读取' : '未读取' + }。`; setMessages((current) => [ ...current, { @@ -5797,7 +5803,9 @@ export function App({ prompt, runtime: runtimeAtSubmission, runProfile: submissionRunProfile, - ...(gameChatOnly ? { source: 'project-supervisor-game-chat' } : {}), + source: gameChatOnly + ? 'project-supervisor-game-chat' + : 'project-supervisor-gui', }); const runtimeResult = submission.runtimeResult; const acceptedRunId = submission.acceptedRunId.trim(); diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index dc33606dc..7a1a8887b 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -419,6 +419,9 @@ export interface AgentRuntimeSteerResult { sequence: number; status: string; providerInterrupted: boolean; + assistantReply?: string | null; + interruptDecision?: boolean | null; + decisionReason?: string | null; } export interface GameCreatorAgentRuntimeUpdateEvent { @@ -446,6 +449,7 @@ export type GameCreatorLlmReasoningEffort = (typeof gameCreatorLlmReasoningEfforts)[number]; export interface GameCreatorLlmConfigStatus { + agentMode: GameCreatorAgentMode; configured: boolean; apiKeyPresent: boolean; baseUrl: string | null; @@ -462,6 +466,7 @@ export interface GameCreatorLlmConfigStatus { } export interface GameCreatorAgentLlmConfigStatus { + agentMode: GameCreatorAgentMode; agentId: string; label: string; configured: boolean; @@ -482,6 +487,10 @@ export type GameCreatorLlmApiKind = | 'openai_responses' | 'openai_chat' | 'anthropic'; +export type GameCreatorAgentMode = + | 'codex_app_server' + | 'codex_cli' + | 'provider'; export type RuntimeLlmProviderPresetId = | 'custom' | 'openai' @@ -568,6 +577,7 @@ export interface GameCreatorMcpCatalog { } export interface GameCreatorAppConfig { + agentMode: GameCreatorAgentMode; llm: GameCreatorLlmConfig; agentLlm: Record; editorApi: { diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index 4307d42cd..0f8329aba 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -23,9 +23,12 @@ import type { const AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX = 'runtime-public-status-'; const AGENT_RUNTIME_TASK_MESSAGE_ID_PREFIX = 'runtime-task-'; +const AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX = 'agent-steer-'; const AGENT_RUNTIME_MESSAGE_CORRELATION_PATTERN = /^[0-9a-f]{32}$/; -function agentRuntimeMessageCorrelationId(messageId: string | null | undefined) { +function agentRuntimeMessageCorrelationId( + messageId: string | null | undefined, +) { const normalized = messageId?.trim() ?? ''; const prefix = normalized.startsWith( AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX, @@ -33,7 +36,9 @@ function agentRuntimeMessageCorrelationId(messageId: string | null | undefined) ? AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX : normalized.startsWith(AGENT_RUNTIME_TASK_MESSAGE_ID_PREFIX) ? AGENT_RUNTIME_TASK_MESSAGE_ID_PREFIX - : null; + : normalized.startsWith(AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX) + ? AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX + : null; if (!prefix) { return null; } @@ -907,7 +912,13 @@ export async function submitProjectSupervisorRuntimeTask({ export function agentRuntimeSteerStatus(result: AgentRuntimeSteerResult) { const runId = result.runtime.state.runId; if (result.providerInterrupted) { - return `已中断 Provider,正在同一 Run 重新规划:${runId}`; + return `LLM 已判定需要改向,旧 Provider 已安全中断:${runId}`; + } + if (result.interruptDecision === false) { + return `LLM 已回复且判定无需中断,当前 Run 继续:${runId}`; + } + if (result.interruptDecision === true) { + return `LLM 已判定需要改向;旧请求已结束或新规划已开始:${runId}`; } if (result.status === 'applied') { return `追加指令已应用,当前 Run 正在继续:${runId}`; @@ -1249,7 +1260,10 @@ export function mergeProjectSupervisorConversation( return; } const correlationId = agentRuntimeMessageCorrelationId(record.messageId); - if (correlationId && !supervisorRecordIndexByCorrelation.has(correlationId)) { + if ( + correlationId && + !supervisorRecordIndexByCorrelation.has(correlationId) + ) { supervisorRecordIndexByCorrelation.set(correlationId, index); } }); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentPanel.ts b/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentPanel.ts index a5a4e46fb..b71ccccb8 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentPanel.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentPanel.ts @@ -44,6 +44,8 @@ import { } from '../agent-runtime'; import { formatAgentLlmConfigWarning, + formatCodexAgentModeLabel, + isCodexAgentMode, llmStatusForAgentCard, } from '../project-summary/agentPresentation'; import { @@ -597,21 +599,29 @@ export function useDeveloperAgentPanel(launcherView: LauncherView) { return; } setAgentChatLlmStatus( - agentStatus.configured - ? `当前 Agent LLM 已配置:${agentStatus.model ?? '未命名模型'}${ - agentStatus.reasoningEffort - ? `,推理 ${agentStatus.reasoningEffort}` - : '' - },联网检索 ${agentStatus.webSearchEnabled ? '开启' : '关闭'},API Key ${ - agentStatus.apiKeyPresent ? '已读取' : '未读取' - }` - : `当前 Agent LLM 未就绪:${ - agentStatus.error ?? '缺少 API Key 或模型配置' - }${ - agentStatus.reasoningEffort - ? `(推理 ${agentStatus.reasoningEffort})` - : '' - },联网检索 ${agentStatus.webSearchEnabled ? '开启' : '关闭'}`, + isCodexAgentMode(agentStatus.agentMode) + ? agentStatus.configured + ? `当前 Agent 已检测到 ${formatCodexAgentModeLabel( + agentStatus.agentMode, + )};登录与网络将在首次调用时验证` + : `当前 Agent ${formatCodexAgentModeLabel( + agentStatus.agentMode, + )} 未就绪:${agentStatus.error ?? 'Codex CLI 不可用'}` + : agentStatus.configured + ? `当前 Agent LLM 已配置:${agentStatus.model ?? '未命名模型'}${ + agentStatus.reasoningEffort + ? `,推理 ${agentStatus.reasoningEffort}` + : '' + },联网检索 ${agentStatus.webSearchEnabled ? '开启' : '关闭'},API Key ${ + agentStatus.apiKeyPresent ? '已读取' : '未读取' + }` + : `当前 Agent LLM 未就绪:${ + agentStatus.error ?? '缺少 API Key 或模型配置' + }${ + agentStatus.reasoningEffort + ? `(推理 ${agentStatus.reasoningEffort})` + : '' + },联网检索 ${agentStatus.webSearchEnabled ? '开启' : '关闭'}`, ); } catch (error) { setAgentChatLlmConfigStatus(null); diff --git a/apps/ai-game-creator-shell/src/features/project-summary/agentPresentation.ts b/apps/ai-game-creator-shell/src/features/project-summary/agentPresentation.ts index b8370f567..88f6c7361 100644 --- a/apps/ai-game-creator-shell/src/features/project-summary/agentPresentation.ts +++ b/apps/ai-game-creator-shell/src/features/project-summary/agentPresentation.ts @@ -21,6 +21,7 @@ import type { AgentStatusCard, AgentTaskGraphState, GameCreatorAgentLlmConfigStatus, + GameCreatorAgentMode, GameCreatorLlmConfigStatus, LocalProjectFileEntry, ProjectPermissionPolicy, @@ -714,9 +715,23 @@ export function formatAgentRunControlError(action: string, message: string) { : '暂无可控制的 Agent run。先生成一次游戏草案后再操作。'; } +export function isCodexAgentMode(mode: GameCreatorAgentMode | undefined) { + return mode === 'codex_app_server' || mode === 'codex_cli'; +} + +export function formatCodexAgentModeLabel(mode: GameCreatorAgentMode) { + return mode === 'codex_app_server' ? 'Codex App Server' : 'Codex CLI'; +} + export function formatLlmAgentStatusLine( agent: GameCreatorAgentLlmConfigStatus, ) { + if (isCodexAgentMode(agent.agentMode)) { + const modeLabel = formatCodexAgentModeLabel(agent.agentMode); + return `${agent.label}:${agent.configured ? `${modeLabel} 已检测到` : `${modeLabel} 未就绪`}${ + !agent.configured && agent.error ? `,错误:${agent.error}` : '' + }`; + } const parts = [ `${agent.label}:${agent.configured ? '已配置' : '未就绪'}`, `${agent.model ?? '未命名模型'} @ ${agent.baseUrl ?? '未设置 base_url'}`, @@ -735,6 +750,7 @@ export function formatLlmAgentStatusLine( export function formatLlmRouteEndpoint( status: Pick< GameCreatorLlmConfigStatus, + | 'agentMode' | 'baseUrl' | 'model' | 'apiKind' @@ -744,6 +760,9 @@ export function formatLlmRouteEndpoint( | 'apiKeyPresent' >, ) { + if (isCodexAgentMode(status.agentMode)) { + return formatCodexAgentModeLabel(status.agentMode); + } return `${status.model ?? '未命名模型'} @ ${ status.baseUrl ?? '未设置 base_url' },${status.apiKind}${ @@ -758,6 +777,7 @@ export function isSameResolvedLlmRouteAsGlobal( agentStatus: GameCreatorAgentLlmConfigStatus, ) { return ( + agentStatus.agentMode === globalStatus.agentMode && agentStatus.baseUrl === globalStatus.baseUrl && agentStatus.model === globalStatus.model && agentStatus.apiKind === globalStatus.apiKind && @@ -795,7 +815,9 @@ export function summarizeAgentLlmRoutes(status: GameCreatorLlmConfigStatus) { return { text: [ - 'Agent LLM 路由:', + isCodexAgentMode(status.agentMode) + ? 'Agent 执行模式:' + : 'Agent LLM 路由:', `- 默认路由:${formatLlmRouteEndpoint(status)}`, `- Agent:${readyCount}/${agents.length} 就绪 · ${separateRouteAgents.length} 个单独路由 · ${gapAgents.length} 个缺口`, `- 路由清单:\n${routeLines.join('\n')}`, @@ -827,8 +849,14 @@ export function formatAgentLlmConfigWarning( if (!agentStatus || agentStatus.configured) { return null; } - return `当前 Agent LLM 未就绪:${ - agentStatus.error ?? `${agentStatus.label} 缺少 API Key 或模型配置` + const routeLabel = isCodexAgentMode(agentStatus.agentMode) + ? formatCodexAgentModeLabel(agentStatus.agentMode) + : 'LLM'; + return `当前 Agent ${routeLabel} 未就绪:${ + agentStatus.error ?? + (isCodexAgentMode(agentStatus.agentMode) + ? `${routeLabel} 不可用` + : `${agentStatus.label} 缺少 API Key 或模型配置`) }`; } @@ -840,16 +868,26 @@ export function formatAgentCardLlmStatus( if (!agentStatus) { return null; } + const codexMode = isCodexAgentMode(agentStatus.agentMode); + const routeLabel = codexMode + ? formatCodexAgentModeLabel(agentStatus.agentMode) + : 'LLM'; return [ - `LLM:${agentStatus.configured ? '已配置' : '未就绪'}`, - agentStatus.model ?? '未命名模型', - agentStatus.apiKind, - ...(agentStatus.reasoningEffort - ? [`推理 ${agentStatus.reasoningEffort}`] - : []), - `流式${agentStatus.stream ? '开' : '关'}`, - `联网检索${agentStatus.webSearchEnabled ? '开' : '关'}`, - `Key${agentStatus.apiKeyPresent ? '已读' : '未读'}`, + `${routeLabel}:${ + agentStatus.configured ? (codexMode ? '已检测到' : '已配置') : '未就绪' + }`, + ...(codexMode + ? [] + : [ + agentStatus.model ?? '未命名模型', + agentStatus.apiKind, + ...(agentStatus.reasoningEffort + ? [`推理 ${agentStatus.reasoningEffort}`] + : []), + `流式${agentStatus.stream ? '开' : '关'}`, + `联网检索${agentStatus.webSearchEnabled ? '开' : '关'}`, + `Key${agentStatus.apiKeyPresent ? '已读' : '未读'}`, + ]), ].join(' · '); } diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index 3079ffa4d..a2777c8b1 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -10,6 +10,7 @@ import { import { resolveTauriInvoke } from '../../app/tauri'; import { type GameCreatorAgentLlmConfig, + type GameCreatorAgentMode, type GameCreatorAppConfig, type GameCreatorAppConfigView, type GameCreatorLlmApiKind, @@ -51,6 +52,7 @@ const runtimeAgentReasoningEffortDefaults = { } as const satisfies Record; const defaultRuntimeConfigDraft: GameCreatorAppConfig = { + agentMode: 'codex_app_server', llm: { apiKey: '', baseUrl: 'https://api.openai.com/v1', @@ -74,6 +76,12 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = { mcpServers: {}, }; +const runtimeAgentModes = new Set([ + 'codex_app_server', + 'codex_cli', + 'provider', +]); + const defaultRuntimeMcpServerConfig: GameCreatorMcpServerConfig = { enabled: true, required: false, @@ -473,6 +481,7 @@ function normalizeRuntimeConfigDraft( ); return { ...config, + agentMode: config.agentMode, llm: { ...config.llm, apiKind, @@ -555,6 +564,10 @@ export function RuntimeConfigDialog({ })); } + function updateRuntimeAgentMode(agentMode: GameCreatorAgentMode) { + setRuntimeConfigDraft((current) => ({ ...current, agentMode })); + } + function updateRuntimeLlmProviderPreset( presetId: RuntimeLlmProviderPresetId, ) { @@ -889,440 +902,483 @@ export function RuntimeConfigDialog({ ) : null}
- - - - - - - - - - - - - - {runtimeAgentLlmRows.map((agent) => { - const agentLlm = runtimeConfigDraft.agentLlm?.[agent.id] ?? {}; - const defaultReasoningEffort = - runtimeAgentReasoningEffortDefaults[ - agent.id as keyof typeof runtimeAgentReasoningEffortDefaults - ]; - return ( - - - - - - - - - - - - - - ); - })} + ))} + + + + + + + + + + + + + + + + {runtimeAgentLlmRows.map((agent) => { + const agentLlm = runtimeConfigDraft.agentLlm?.[agent.id] ?? {}; + const defaultReasoningEffort = + runtimeAgentReasoningEffortDefaults[ + agent.id as keyof typeof runtimeAgentReasoningEffortDefaults + ]; + return ( + + + + + + + + + + + + + + ); + })} + + ) : null}