接入 AGC 内置插件宿主并补齐 Cocos 编辑器能力 (#338)
Project CI / Repository checks (push) Successful in 2m45s
Project CI / Frontend tests (push) Successful in 3m27s
Project CI / Backend tests (push) Successful in 6m18s
Project CI / Native shell tests (push) Failing after 13m52s

客户端新增随包提供的插件宿主和 Cocos Creator 集成:识别并导入 Cocos 项目,通过内置桥接操作已打开的编辑器,无需安装项目 MCP 扩展。DirectProject 现在公开 36 个独立 cocos_* 工具,保留通用 JavaScript 执行入口。

- 通用插件 SDK、命令/能力/面板注册、编辑器适配器和跨进程内置插件开关。
- Cocos 场景、节点、组件、Prefab、UI、Layout/Widget、资源、保存、撤销、日志与预览调试;目录和实现由 JS/native 共用。
- 编辑事务回读、失败回滚、后续手动修改保护及不确定结果禁止重放;预览截图通过 MCP image 返回。
- DirectProject 跳过无关专业 Agent 历史,将项目打开和历史读取中的同步 I/O 移出窗口线程,消除 Cocos 执行与项目文件锁的错误耦合。

验证:
- 合并 master 后:类型/配置检查、编码检查、Rust 格式检查和提交钩子通过。
- 合并 master 后:Cocos 项目打开、插件面板和开发启动定向测试 10 通过、2 跳过;DirectProject MCP 测试 17 通过、1 项真实 Creator opt-in 忽略;插件宿主测试 9/9。
- 插件行为测试 17/17;native 测试 20/20,4 项 opt-in 测试默认忽略。
- 真实 Creator 3.8.8 的 36/36 操作 smoke,以及客户端 MCP tools/list、tools/call、UI/撤销和预览截图,在功能实现阶段已验证通过;本次 master 合并后未重复真实 GUI smoke。

验证边界:发行安装包和远端 CI 尚未验收。

Reviewed-on: #338
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
This commit was merged in pull request #338.
This commit is contained in:
2026-09-13 14:48:55 +08:00
committed by 段舒康
parent b12a81e9c2
commit 4a46f89c9b
117 changed files with 15158 additions and 320 deletions
+2
View File
@@ -40,6 +40,8 @@ temp*build*/
/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-path/ /apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-path/
/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-resources/ /apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-resources/
/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-package.json /apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-package.json
/apps/ai-game-creator-shell/src-tauri/resources/plugins/
/plugins/agc-cocos-editor/native/payload/
/apps/ai-game-creator-shell/logs/ /apps/ai-game-creator-shell/logs/
/apps/ai-game-creator-shell/.llm-drafts/ /apps/ai-game-creator-shell/.llm-drafts/
/apps/ai-game-creator-shell/game-creator.config.local.json /apps/ai-game-creator-shell/game-creator.config.local.json
+4
View File
@@ -0,0 +1,4 @@
# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。
# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发
# “构建 -> 监听 -> 再构建”的自触发循环。
resources/plugins/
@@ -4,6 +4,11 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import {
defaultEditorFeatures,
withDefaultCargoFeatures,
} from './cargo-features.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url)); const appRoot = fileURLToPath(new URL('..', import.meta.url));
const defaultReleaseTarget = 'x86_64-pc-windows-msvc'; const defaultReleaseTarget = 'x86_64-pc-windows-msvc';
const releaseTarget = const releaseTarget =
@@ -159,10 +164,30 @@ export async function prepareReleaseVersion() {
return nextVersion; return nextVersion;
} }
export function runTauriBuild(args = []) { export function buildTauriBuildArguments(
args = [],
target = releaseTarget,
platform = process.platform,
) {
const noBundle = args.includes('--no-bundle'); const noBundle = args.includes('--no-bundle');
const hasTarget = args.includes('--target'); const targetIndex = args.indexOf('--target');
const targetArgs = noBundle || hasTarget ? [] : ['--target', releaseTarget]; const explicitTarget =
targetIndex >= 0
? args[targetIndex + 1]
: args
.find((value) => value.startsWith('--target='))
?.slice('--target='.length);
const targetArgs = noBundle || explicitTarget ? [] : ['--target', target];
const features = defaultEditorFeatures(
explicitTarget || (noBundle ? platform : target),
);
return [
'build',
...withDefaultCargoFeatures([...targetArgs, ...args], features),
];
}
export function runTauriBuild(args = []) {
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const result = spawnSync( const result = spawnSync(
npmCommand, npmCommand,
@@ -172,9 +197,7 @@ export function runTauriBuild(args = []) {
'exec', 'exec',
'tauri', 'tauri',
'--', '--',
'build', ...buildTauriBuildArguments(args),
...targetArgs,
...args,
], ],
{ cwd: appRoot, stdio: 'inherit', shell: process.platform === 'win32' }, { cwd: appRoot, stdio: 'inherit', shell: process.platform === 'win32' },
); );
@@ -0,0 +1,24 @@
/** 默认桌面能力;显式 feature 参数优先,不把应用参数当 Cargo 参数。 */
export function withDefaultCargoFeatures(argv, features) {
const separator = argv.indexOf('--');
const cargoArgs = separator < 0 ? argv : argv.slice(0, separator);
if (
!features.length ||
cargoArgs.some(
(value) =>
value === '--features' ||
value === '-f' ||
value.startsWith('--features=') ||
/^-f.+/u.test(value),
)
) {
return argv;
}
return [`--features=${features.join(',')}`, ...argv];
}
export function defaultEditorFeatures(target) {
return target === 'win32' || target.includes('windows')
? ['cocos-editor-execute']
: [];
}
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { buildTauriBuildArguments } from './build-release.mjs';
import { withDefaultCargoFeatures } from './cargo-features.mjs';
test('Windows release includes the same editor feature as development', () => {
assert.deepEqual(
buildTauriBuildArguments([], 'x86_64-pc-windows-msvc', 'win32'),
[
'build',
'--features=cocos-editor-execute',
'--target',
'x86_64-pc-windows-msvc',
],
);
assert.deepEqual(
buildTauriBuildArguments(
['--no-bundle'],
'x86_64-pc-windows-msvc',
'linux',
),
['build', '--no-bundle'],
);
assert.deepEqual(
buildTauriBuildArguments(['--target=aarch64-apple-darwin']),
['build', '--target=aarch64-apple-darwin'],
);
});
test('explicit Cargo features override defaults in every supported spelling', () => {
for (const args of [
['--features', 'custom'],
['--features=custom'],
['-f', 'custom'],
['-fcustom'],
]) {
assert.deepEqual(
withDefaultCargoFeatures(args, ['cocos-editor-execute']),
args,
);
}
assert.deepEqual(
withDefaultCargoFeatures(
['--', '--features=app'],
['cocos-editor-execute'],
),
['--features=cocos-editor-execute', '--', '--features=app'],
);
});
@@ -118,6 +118,16 @@ const allowedUncalledTauriCommands = [
'stop_local_game_preview_if_matches', 'stop_local_game_preview_if_matches',
'start_game_creator_external_mcp', 'start_game_creator_external_mcp',
'stop_game_creator_external_mcp', 'stop_game_creator_external_mcp',
'list_agc_plugins',
'list_agc_extensions',
'refresh_agc_plugins',
'start_agc_plugin',
'stop_agc_plugin',
'reload_agc_plugin',
'call_agc_plugin',
'read_agc_plugin_panel',
'set_agc_plugin_project_path',
'set_agc_plugin_enabled',
]; ];
const sourceExtensions = new Set([ const sourceExtensions = new Set([
'.json', '.json',
@@ -1289,7 +1299,7 @@ if (tauriConfig.identifier !== 'world.genarrative.ai-game-creator') {
const expectedBundledDesignAgentResources = { const expectedBundledDesignAgentResources = {
'design-agent': 'design-agent', 'design-agent': 'design-agent',
}; };
const expectedBundledCodexResources = { const expectedBundledWindowsResources = {
'resources/codex/win-x64/bin/codex.exe': 'codex/win-x64/bin/codex.exe', 'resources/codex/win-x64/bin/codex.exe': 'codex/win-x64/bin/codex.exe',
'resources/codex/win-x64/bin/codex-code-mode-host.exe': 'resources/codex/win-x64/bin/codex-code-mode-host.exe':
'codex/win-x64/bin/codex-code-mode-host.exe', 'codex/win-x64/bin/codex-code-mode-host.exe',
@@ -1303,6 +1313,7 @@ const expectedBundledCodexResources = {
'codex/win-x64/codex-package.json', 'codex/win-x64/codex-package.json',
'resources/codex/win-x64/NOTICE.md': 'codex/win-x64/NOTICE.md', 'resources/codex/win-x64/NOTICE.md': 'codex/win-x64/NOTICE.md',
'resources/codex/win-x64/manifest.json': 'codex/win-x64/manifest.json', 'resources/codex/win-x64/manifest.json': 'codex/win-x64/manifest.json',
'resources/plugins': 'plugins',
}; };
assert.deepEqual( assert.deepEqual(
tauriConfig.bundle?.resources, tauriConfig.bundle?.resources,
@@ -1318,8 +1329,8 @@ for (const key of Object.keys(tauriConfig.bundle?.resources ?? {})) {
} }
assert.deepEqual( assert.deepEqual(
windowsTauriConfig.bundle?.resources, windowsTauriConfig.bundle?.resources,
expectedBundledCodexResources, expectedBundledWindowsResources,
'AI game creator shell Windows Tauri config must bundle the complete pinned Codex resource set', 'AI game creator shell Windows Tauri config must bundle the complete pinned Codex resource set and Cocos bridge payload directory',
); );
if ( if (
Object.prototype.hasOwnProperty.call( Object.prototype.hasOwnProperty.call(
@@ -436,6 +436,10 @@ function spawnChild(command, args, options, spawnImpl = spawn) {
const child = spawnImpl(command, args, { const child = spawnImpl(command, args, {
...options, ...options,
shell: useShell, shell: useShell,
// npm.cmd and the Windows shell otherwise create a visible console for
// every service in the dev stack. Their stdout/stderr is already inherited
// by the launcher, so no separate terminal window is useful.
windowsHide: process.platform === 'win32' ? true : options.windowsHide,
// POSIX 下让每个长驻服务拥有独立进程组,退出时可以连同 npm、 // POSIX 下让每个长驻服务拥有独立进程组,退出时可以连同 npm、
// Node、Cargo 及其子进程一起清理,避免残留订阅任务持续刷日志。 // Node、Cargo 及其子进程一起清理,避免残留订阅任务持续刷日志。
detached: isPosix, detached: isPosix,
@@ -1,6 +1,7 @@
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { withDefaultCargoFeatures } from './cargo-features.mjs';
import { import {
readAgcDevEndpoint, readAgcDevEndpoint,
resolveAgcDevEndpoint, resolveAgcDevEndpoint,
@@ -47,6 +48,24 @@ function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) {
]; ];
} }
// `agc_cocos_execute` 与 Cocos 编辑器适配器只在 `cocos-editor-execute` feature 下
// 注册。开发构建默认在 Windows 打开它,否则 Agent 的工具清单里根本没有该工具,
// 只能退化成改写脚本。可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭。
function readDevCargoFeatures(env = process.env) {
const override = env.AGC_DEV_CARGO_FEATURES;
if (override !== undefined) {
return override
.split(',')
.map((value) => value.trim())
.filter(Boolean);
}
return process.platform === 'win32' ? ['cocos-editor-execute'] : [];
}
function withDevCargoFeatures(argv, features = readDevCargoFeatures()) {
return withDefaultCargoFeatures(argv, features);
}
function spawnTauriCli(argv, { env = process.env } = {}) { function spawnTauriCli(argv, { env = process.env } = {}) {
return spawnChild(process.execPath, [tauriCliPath, ...argv], { return spawnChild(process.execPath, [tauriCliPath, ...argv], {
cwd: appRoot, cwd: appRoot,
@@ -110,7 +129,10 @@ async function runTauriDev(
shutdownRequested.then(() => false), shutdownRequested.then(() => false),
]); ]);
if (!prepared || shutdownSignal) return 1; if (!prepared || shutdownSignal) return 1;
const tauriArguments = buildTauriArguments(argv, endpoint.url); const tauriArguments = buildTauriArguments(
withDevCargoFeatures(argv),
endpoint.url,
);
child = spawnCli(tauriArguments, { child = spawnCli(tauriArguments, {
env: { env: {
...withAgcDevEndpointEnv(endpoint), ...withAgcDevEndpointEnv(endpoint),
@@ -207,6 +229,7 @@ export {
isDirectModuleExecution, isDirectModuleExecution,
runTauriDev, runTauriDev,
spawnTauriCli, spawnTauriCli,
withDevCargoFeatures,
}; };
if (isDirectModuleExecution()) { if (isDirectModuleExecution()) {
@@ -0,0 +1,4 @@
# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。
# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发
# “构建 -> 监听 -> 再构建”的自触发循环。
resources/plugins/
+25
View File
@@ -733,6 +733,20 @@ dependencies = [
"error-code", "error-code",
] ]
[[package]]
name = "cocos-editor-bridge"
version = "0.1.0"
dependencies = [
"cc",
"editor-adapter-api",
"reqwest 0.12.28",
"serde",
"serde_json",
"sha2",
"tungstenite",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "combine" name = "combine"
version = "4.6.7" version = "4.6.7"
@@ -1205,6 +1219,14 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "editor-adapter-api"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]] [[package]]
name = "either" name = "either"
version = "1.16.0" version = "1.16.0"
@@ -1709,6 +1731,8 @@ dependencies = [
"axum", "axum",
"base64 0.22.1", "base64 0.22.1",
"chromiumoxide", "chromiumoxide",
"cocos-editor-bridge",
"editor-adapter-api",
"futures", "futures",
"getrandom 0.3.4", "getrandom 0.3.4",
"http", "http",
@@ -4281,6 +4305,7 @@ dependencies = [
"cookie", "cookie",
"cookie_store", "cookie_store",
"encoding_rs", "encoding_rs",
"futures-channel",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2", "h2",
@@ -6,6 +6,9 @@ publish = false
[features] [features]
default = [] default = []
cocos-editor = ["cocos-editor-bridge/process-discovery"]
cocos-editor-execute = ["cocos-editor", "cocos-editor-bridge/windows-bootstrap"]
cocos-editor-injection = ["cocos-editor-execute", "cocos-editor-bridge/windows-injection"]
[build-dependencies] [build-dependencies]
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
@@ -19,6 +22,8 @@ ts-rs = "12.0.1"
typed_floats = { version = "1.0.7", features = ["serde"] } typed_floats = { version = "1.0.7", features = ["serde"] }
nalgebra = { version = "0.35.0", features = ["serde-serialize"] } nalgebra = { version = "0.35.0", features = ["serde-serialize"] }
agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" } agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" }
cocos-editor-bridge = { path = "../../../plugins/agc-cocos-editor/native/cocos-editor-bridge", default-features = false }
editor-adapter-api = { path = "../../../server-rs/crates/editor-adapter-api" }
base64 = "0.22" base64 = "0.22"
axum = "0.8" axum = "0.8"
chromiumoxide = "0.9.1" chromiumoxide = "0.9.1"
@@ -182,6 +182,8 @@ fn main() {
); );
let manifest_path = manifest_dir.join("prompts/runtime/manifest.json"); let manifest_path = manifest_dir.join("prompts/runtime/manifest.json");
stage_bundled_codex_cli(&manifest_dir); stage_bundled_codex_cli(&manifest_dir);
stage_plugin_workspace(&manifest_dir);
stage_cocos_editor_payload(&manifest_dir);
let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path) let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path)
.unwrap_or_else(|error| panic!("Prompt Bundle 编译失败:{error}")); .unwrap_or_else(|error| panic!("Prompt Bundle 编译失败:{error}"));
validate_seed_task_catalog(&compiled); validate_seed_task_catalog(&compiled);
@@ -203,3 +205,146 @@ fn main() {
} }
tauri_build::build() tauri_build::build()
} }
#[cfg(windows)]
fn stage_cocos_editor_payload(manifest_dir: &std::path::Path) {
if std::env::var_os("CARGO_FEATURE_COCOS_EDITOR_INJECTION").is_none() {
return;
}
let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR"));
let profile_dir = out_dir
.ancestors()
.find(|path| path.file_name().is_some_and(|name| name == "build"))
.and_then(|build_dir| build_dir.parent())
.expect("AGC Cargo profile directory not found");
let candidates = [
profile_dir.join("deps/cocos_editor_bridge.dll"),
profile_dir.join("cocos_editor_bridge.dll"),
];
let source = candidates
.iter()
.find(|path| path.is_file())
.unwrap_or_else(|| {
panic!(
"Cocos bridge native payload 未构建:{}",
candidates
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join("")
)
});
for destination in [
// 插件工作区里的 payload 是开发态与打包态的唯一真源。
manifest_dir
.join("../../../plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"),
// 随包资源目录与 tauri.windows.conf.json 的 `resources/plugins` 映射保持一致。
manifest_dir
.join("resources/plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"),
] {
std::fs::create_dir_all(destination.parent().expect("payload resource parent"))
.expect("创建 Cocos bridge payload 目录失败");
std::fs::copy(source, &destination).expect("复制 Cocos bridge native payload 失败");
}
println!("cargo:rerun-if-changed={}", source.display());
}
#[cfg(not(windows))]
fn stage_cocos_editor_payload(_manifest_dir: &std::path::Path) {}
/// 把 `plugins/` 工作区里的插件包随包映射到应用资源目录。
///
/// 只复制插件运行需要的清单、入口、面板和 native payload,不复制 native 源码、
/// Cargo target 目录或 node_modules。
#[cfg(windows)]
fn stage_plugin_workspace(manifest_dir: &std::path::Path) {
let repo_root = manifest_dir
.parent()
.and_then(|app_root| app_root.parent())
.and_then(|apps_dir| apps_dir.parent())
.expect("AGC 应用必须位于仓库 apps 目录下")
.to_path_buf();
let workspace = repo_root.join("plugins");
let destination_root = manifest_dir.join("resources/plugins");
std::fs::create_dir_all(&destination_root).expect("创建插件资源目录失败");
let entries = match std::fs::read_dir(&workspace) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let plugin_root = entry.path();
if !plugin_root.is_dir() || !plugin_root.join("plugin.json").is_file() {
continue;
}
let name = entry.file_name();
let destination = destination_root.join(&name);
copy_plugin_file(
&plugin_root.join("plugin.json"),
&destination.join("plugin.json"),
);
for relative in [
std::path::PathBuf::from("src"),
std::path::PathBuf::from("panels"),
std::path::PathBuf::from("native/payload"),
] {
copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative));
}
println!("cargo:rerun-if-changed={}", plugin_root.display());
}
}
#[cfg(windows)]
fn stage_plugin_file(source: &std::path::Path, destination: &std::path::Path) {
let Ok(bytes) = std::fs::read(source) else {
return;
};
if std::fs::read(destination).is_ok_and(|existing| existing == bytes) {
return;
}
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent).expect("创建插件资源目录失败");
}
std::fs::write(destination, bytes).expect("复制插件资源失败");
}
#[cfg(windows)]
fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) {
let entries = match std::fs::read_dir(source) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let target = destination.join(entry.file_name());
let path = entry.path();
if path.is_dir() {
let name = entry.file_name();
let name = name.to_string_lossy();
if matches!(name.as_ref(), "target" | "node_modules" | ".git") {
continue;
}
std::fs::create_dir_all(&target).expect("创建插件资源目录失败");
copy_plugin_tree(&path, &target);
} else {
// 测试文件不随包分发。
let name = entry.file_name();
let name = name.to_string_lossy();
if name.contains(".test.") {
continue;
}
stage_plugin_file(&path, &target);
}
}
}
#[cfg(windows)]
fn copy_plugin_file(source: &std::path::Path, destination: &std::path::Path) {
if !source.is_file() {
return;
}
std::fs::create_dir_all(destination.parent().expect("插件资源父目录"))
.expect("创建插件资源目录失败");
std::fs::copy(source, destination).expect("复制插件资源失败");
}
#[cfg(not(windows))]
fn stage_plugin_workspace(_manifest_dir: &std::path::Path) {}
@@ -305,6 +305,35 @@ fn game_creator_codex_app_server_error_detail_indicates_auth_failure(
|| detail.contains("http 403") || detail.contains("http 403")
} }
fn game_creator_codex_app_server_error_detail(error: &serde_json::Value) -> String {
let Some(error) = error.as_object() else {
return String::new();
};
["message", "additionalDetails", "code"]
.into_iter()
.filter_map(|field| error.get(field).and_then(serde_json::Value::as_str))
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase()
}
fn game_creator_codex_app_server_error_detail_indicates_stream_requirement(
error: &serde_json::Value,
) -> bool {
let detail = game_creator_codex_app_server_error_detail(error);
detail.contains("stream must be set to true")
|| detail.contains("stream=true")
|| detail.contains("stream is required")
}
fn game_creator_codex_app_server_error_detail_indicates_timeout(error: &serde_json::Value) -> bool {
let detail = game_creator_codex_app_server_error_detail(error);
detail.contains("timed out")
|| detail.contains("timeout")
|| detail.contains("request deadline exceeded")
|| detail.contains("deadline exceeded")
}
fn game_creator_codex_app_server_error_detail_indicates_request_too_large( fn game_creator_codex_app_server_error_detail_indicates_request_too_large(
error: &serde_json::Value, error: &serde_json::Value,
) -> bool { ) -> bool {
@@ -362,6 +391,15 @@ fn game_creator_codex_app_server_failed_turn_error(
if game_creator_codex_app_server_error_detail_indicates_request_too_large(error) { if game_creator_codex_app_server_error_detail_indicates_request_too_large(error) {
return game_creator_codex_app_server_error_kind("request-too-large"); return game_creator_codex_app_server_error_kind("request-too-large");
} }
if game_creator_codex_app_server_error_detail_indicates_stream_requirement(error) {
return game_creator_codex_app_server_error_kind("stream-required");
}
if game_creator_codex_app_server_error_detail_indicates_timeout(error) {
return platform_llm::LlmError::Connectivity {
attempts: 1,
message: "Codex app-server 上游请求超时".to_string(),
};
}
if game_creator_codex_app_server_error_detail_indicates_auth_failure(error) { if game_creator_codex_app_server_error_detail_indicates_auth_failure(error) {
return game_creator_codex_app_server_error_kind("unauthorized"); return game_creator_codex_app_server_error_kind("unauthorized");
} }
@@ -1070,6 +1108,7 @@ fn game_creator_codex_app_server_pool_key(
"skillPackIdentity": skill_pack_identity, "skillPackIdentity": skill_pack_identity,
"clientSkillIdentity": client_skill_identity, "clientSkillIdentity": client_skill_identity,
"clientMcpIdentity": client_mcp_identity, "clientMcpIdentity": client_mcp_identity,
"builtinPluginTools": crate::builtin_plugins::available_agent_tools(),
"controlledWebSearch": llm.web_search_enabled, "controlledWebSearch": llm.web_search_enabled,
"directToolBridgeProtocol": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { DIRECT_TOOL_BRIDGE_PROTOCOL } else { "disabled" }, "directToolBridgeProtocol": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { DIRECT_TOOL_BRIDGE_PROTOCOL } else { "disabled" },
"providerProxyProtocol": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { CODEX_PROVIDER_PROXY_PROTOCOL } else { "disabled" }, "providerProxyProtocol": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { CODEX_PROVIDER_PROXY_PROTOCOL } else { "disabled" },
@@ -2463,10 +2502,12 @@ impl CodexAppServerConnection {
// Codex's default provider in Debug builds. // Codex's default provider in Debug builds.
self.inner._provider_proxy.is_some() || !llm.api_key.trim().is_empty(), self.inner._provider_proxy.is_some() || !llm.api_key.trim().is_empty(),
); );
let result = self let result = match self.request("thread/start", params).await {
.request("thread/start", params) Ok(result) => result,
.await Err(error) => {
.map_err(platform_llm::LlmError::Transport)?; return Err(platform_llm::LlmError::Transport(error));
}
};
let thread_id = result let thread_id = result
.pointer("/thread/id") .pointer("/thread/id")
.and_then(serde_json::Value::as_str) .and_then(serde_json::Value::as_str)
@@ -4810,6 +4851,37 @@ mod tests {
} }
} }
#[test]
fn codex_app_server_failed_turn_maps_stream_and_timeout_details() {
let stream_error = game_creator_codex_app_server_failed_turn_error(&serde_json::json!({
"status": "failed",
"error": {
"message": "Stream must be set to true",
"codexErrorInfo": "other"
}
}));
assert_eq!(
stream_error,
platform_llm::LlmError::InvalidRequest(
"codex-app-server-error:stream-required".to_string()
)
);
let timeout_error = game_creator_codex_app_server_failed_turn_error(&serde_json::json!({
"status": "failed",
"error": {
"message": "provider request timed out",
"codexErrorInfo": "other"
}
}));
assert_eq!(
timeout_error,
platform_llm::LlmError::Connectivity {
attempts: 1,
message: "Codex app-server 上游请求超时".to_string()
}
);
}
#[test] #[test]
fn codex_app_server_failed_turn_maps_insufficient_mud_points_to_stable_upstream_error() { fn codex_app_server_failed_turn_maps_insufficient_mud_points_to_stable_upstream_error() {
for detail in [ for detail in [
@@ -5051,6 +5123,42 @@ while IFS= read -r line; do :; done
assert_ne!(disabled, enabled); assert_ne!(disabled, enabled);
} }
#[cfg(feature = "cocos-editor-execute")]
#[test]
fn codex_app_server_pool_key_tracks_builtin_plugin_switch() {
let _guard = crate::builtin_plugins::test_lock();
let config = tempfile::tempdir().unwrap();
crate::builtin_plugins::initialize(config.path()).unwrap();
let key = || {
game_creator_codex_app_server_pool_key(
&test_llm(),
"codex-cli 0.147.0",
&test_snapshot(),
"credential",
CodexAppServerWorkspaceMode::DirectProject,
)
};
crate::builtin_plugins::set_enabled(
crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID,
false,
)
.unwrap();
let disabled = key();
crate::builtin_plugins::set_enabled(
crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID,
true,
)
.unwrap();
let enabled = key();
assert_ne!(disabled, enabled);
crate::builtin_plugins::set_enabled(
crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID,
false,
)
.unwrap();
assert_eq!(disabled, key());
}
#[test] #[test]
fn direct_file_change_approval_is_limited_to_workspace() { fn direct_file_change_approval_is_limited_to_workspace() {
let temp = tempfile::tempdir().expect("temp dir"); let temp = tempfile::tempdir().expect("temp dir");
@@ -1,3 +1,4 @@
use super::runtime_actions::acquire_game_creator_agent_runtime_project_write_lock_with_wait;
use crate::config::prepare_game_creator_private_path_for_read; use crate::config::prepare_game_creator_private_path_for_read;
use crate::project::{ use crate::project::{
append_jsonl_line_unlocked, enforce_project_permission_policy, project_append_lock_for, append_jsonl_line_unlocked, enforce_project_permission_policy, project_append_lock_for,
@@ -204,7 +205,15 @@ fn append_direct_project_history_item_at_with_user_policy(
if !allow_user_item && is_direct_project_codex_user_item(item) { if !allow_user_item && is_direct_project_codex_user_item(item) {
return Ok(()); return Ok(());
} }
let _project_lock = crate::project::acquire_project_write_lock(root, "conversation.write")?; // DirectProject 历史与 `agc_write_file` 共用项目写锁。文件写入在锁内要跑 Windows
// 私有路径准备与原子替换,现场实测一次 2.6KB 写入占锁 5.5 秒;零等待取锁会让
// 流式历史落盘在写文件期间直接失败,并把整轮判成“项目正在被其他写操作占用”
// (持锁方 commandId=direct-codex.file.write、ownerIsSelf=true)。这里与其它写入口
// 保持同一档有界等待;主路径已在阻塞线程池中执行。
let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"conversation.write",
)?;
let path = history_path(root); let path = history_path(root);
let history_exists = let history_exists =
prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")?; prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")?;
@@ -429,6 +438,50 @@ mod tests {
assert_eq!(items, vec![item]); assert_eq!(items, vec![item]);
} }
#[test]
fn append_waits_for_a_same_process_project_writer() {
// 用 canonical 临时根:Windows 上 `%TEMP%` 的 8.3 短路径会让私有路径所有者
// 校验把测试目录判成“不属于当前用户”。
let temp_root = std::env::temp_dir()
.canonicalize()
.unwrap_or_else(|_| std::env::temp_dir());
let root = temp_root.join(format!(
"genarrative-agc-history-wait-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|value| value.as_millis())
.unwrap_or_default()
));
std::fs::create_dir_all(&root).expect("create project root");
crate::init_local_game_project_at(&root, "history-wait", "历史等待").expect("init project");
// 模拟 `agc_write_file`:它持锁期间历史落盘必须排队等待,而不是零等待失败后
// 把整轮判成“项目正在被其他写操作占用”。
let holder = crate::project::acquire_project_write_lock(&root, "direct-codex.file.write")
.expect("hold project write lock");
let worker_root = root.clone();
let worker = std::thread::spawn(move || {
append_direct_project_history_item_at(
&worker_root,
&serde_json::json!({
"type": "message",
"role": "assistant",
"id": "waits-for-writer",
"content": [{"type": "output_text", "text": "排队等待"}]
}),
)
});
std::thread::sleep(std::time::Duration::from_millis(300));
drop(holder);
worker
.join()
.expect("append worker")
.expect("history append must wait for the writer");
let items = read_direct_project_history_items_at(&root).expect("read history");
assert_eq!(items.len(), 1);
let _ = std::fs::remove_dir_all(&root);
}
#[test] #[test]
fn codex_user_echo_is_filtered_but_agc_user_message_is_persisted() { fn codex_user_echo_is_filtered_but_agc_user_message_is_persisted() {
let root = tempfile::tempdir().expect("temp project"); let root = tempfile::tempdir().expect("temp project");
File diff suppressed because one or more lines are too long
@@ -54,6 +54,8 @@ struct DirectToolBridgeState {
regeneration_gate: tokio::sync::Mutex<()>, regeneration_gate: tokio::sync::Mutex<()>,
resource_generation_gate: tokio::sync::Mutex<()>, resource_generation_gate: tokio::sync::Mutex<()>,
image_generation_gate: tokio::sync::Mutex<()>, image_generation_gate: tokio::sync::Mutex<()>,
#[cfg(all(windows, feature = "cocos-editor-execute"))]
cocos_execute_uncertain: tokio::sync::Mutex<bool>,
} }
#[derive(Default)] #[derive(Default)]
@@ -687,6 +689,8 @@ fn direct_tool_bridge_state_with_search(
regeneration_gate: tokio::sync::Mutex::new(()), regeneration_gate: tokio::sync::Mutex::new(()),
resource_generation_gate: tokio::sync::Mutex::new(()), resource_generation_gate: tokio::sync::Mutex::new(()),
image_generation_gate: tokio::sync::Mutex::new(()), image_generation_gate: tokio::sync::Mutex::new(()),
#[cfg(all(windows, feature = "cocos-editor-execute"))]
cocos_execute_uncertain: tokio::sync::Mutex::new(false),
}) })
} }
@@ -1475,12 +1479,28 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value {
reject_command_output_wrapper(content)?; reject_command_output_wrapper(content)?;
// 这是用户直接触发、失败即整轮无法落盘的项目写入通道: // 这是用户直接触发、失败即整轮无法落盘的项目写入通道:
// 短暂重叠排队等成功,只有预算耗尽才报出带持锁方身份的错误。 // 短暂重叠排队等成功,只有预算耗尽才报出带持锁方身份的错误。
let acquire_started = std::time::Instant::now();
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root, root,
"direct-codex.file.write", "direct-codex.file.write",
)?; )?;
let lock_wait_ms = acquire_started.elapsed().as_millis();
let write_started = std::time::Instant::now();
let written = write_local_project_file_at(root, &path, content)?; let written = write_local_project_file_at(root, &path, content)?;
let write_ms = write_started.elapsed().as_millis();
let revision_started = std::time::Instant::now();
let revision = advance_agent_runtime_project_revision_locked(root)?; let revision = advance_agent_runtime_project_revision_locked(root)?;
// 现场一次 2.6KB 写入实测 5.5 秒。只在明显偏慢时记账,正常写入不刷日志。
if lock_wait_ms + write_ms > 200 {
app_log!(
"direct.file.write.timing path={} bytes={} lockWaitMs={} writeMs={} revisionMs={}",
written.path,
content.len(),
lock_wait_ms,
write_ms,
revision_started.elapsed().as_millis()
);
}
Ok::<_, String>(json!({ Ok::<_, String>(json!({
"status": "completed", "status": "completed",
"path": written.path, "path": written.path,
@@ -2318,11 +2338,193 @@ async fn bridge_web_search_at(root: &Path, arguments: &Value, search_url: &str)
} }
} }
#[cfg(all(windows, feature = "cocos-editor-execute"))]
async fn bridge_cocos_execute(state: &DirectToolBridgeState, arguments: &Value) -> Value {
bridge_cocos_call(state, arguments, None).await
}
#[cfg(all(windows, feature = "cocos-editor-execute"))]
async fn bridge_cocos_call(
state: &DirectToolBridgeState,
arguments: &Value,
operation: Option<&str>,
) -> Value {
if !crate::builtin_plugins::cocos_editor_agent_tool_available() {
return bridge_tool_result(
"Cocos Creator 插件已禁用,agc_cocos_execute 不可用".to_string(),
Vec::new(),
true,
);
}
let prepared = (|| {
enforce_project_permission_policy(&state.root, "cocos.editor.execute")?;
if let Some(operation) = operation {
let tool = cocos_editor_bridge::cocos_operation_catalog()
.iter()
.find(|tool| tool["name"] == operation)
.ok_or_else(|| "未知 Cocos 操作".to_string())?;
let validator = jsonschema::validator_for(&tool["inputSchema"])
.map_err(|e| format!("Cocos schema 错误:{e}"))?;
if let Err(error) = validator.validate(arguments) {
return Err(format!("Cocos 参数无效:{error}"));
}
return cocos_editor_bridge::build_cocos_operation_code(operation, arguments)
.map_err(|e| e.to_string());
}
bridge_reject_unknown_fields(arguments, &["code"])?;
let code = arguments
.get("code")
.and_then(Value::as_str)
.ok_or_else(|| "code 必须是 JavaScript 函数体".to_string())?;
cocos_editor_bridge::validate_execute_code(code).map_err(|error| error.to_string())?;
Ok::<_, String>(code.to_string())
})();
let code = match prepared {
Ok(code) => code,
Err(error) => {
return bridge_tool_result(
redact_agent_runtime_error(&state.root, &error, 480),
Vec::new(),
true,
)
}
};
let Ok(mut uncertain) = state.cocos_execute_uncertain.try_lock() else {
return bridge_tool_result(
json!({"status":"failed","retryAllowed":false,"message":"已有 Cocos 操作执行中,请等待回执"}).to_string(),
Vec::new(), true);
};
if *uncertain {
return bridge_tool_result(
json!({
"status": "needs-reconciliation", "retryAllowed": false,
"message": "先前 Cocos execute 结果待核对,当前 bridge 不再发送执行命令"
})
.to_string(),
Vec::new(),
true,
);
}
let root = state.root.clone();
let timeout_ms = if operation.is_some() {
60_000
} else {
cocos_editor_bridge::DEFAULT_COMMAND_TIMEOUT_MS
};
let result = tokio::task::spawn_blocking(move || {
// Cocos execute talks to the already-open Creator process through its
// validated Inspector/pipe bridge. It does not mutate AGC's project
// files or manifest, so it must not wait on `.agent/project.lock`.
// File-writing tools keep their own project lock separately.
if !crate::builtin_plugins::cocos_editor_agent_tool_available() {
return Err(cocos_editor_bridge::BridgeError::InvalidInput(
"Cocos Creator 插件已禁用".to_string(),
));
}
cocos_editor_bridge::execute_cocos_editor_code_for_project(
root.to_string_lossy().as_ref(),
&code,
timeout_ms,
)
})
.await;
match result {
Ok(Ok(response)) => {
let mut result = response.result;
let status = if operation.is_some() {
result
.as_ref()
.and_then(|v| v["status"].as_str())
.unwrap_or(if response.ok { "completed" } else { "failed" })
.to_string()
} else if response.ok {
"completed".to_string()
} else {
"failed".to_string()
};
let is_error = !response.ok || status != "completed";
if status == "needs-reconciliation" {
*uncertain = true;
}
// 截图以 MCP image block 返回,不能被文本截断破坏 base64。
let mut images = Vec::new();
if operation == Some("cocos_preview_debug_capture") {
if let Some(value) = result
.as_mut()
.and_then(|v| v.get_mut("result"))
.and_then(Value::as_object_mut)
{
if let Some(image) = value.remove("__image") {
if image["mimeType"] == "image/png" {
if let Some(data) = image["data"].as_str() {
images.push(data.to_string());
}
}
}
}
}
let text = json!({
"status": status,
"requestId": response.request_id,
"result": result,
"error": response.error,
})
.to_string();
bridge_tool_result(
redact_agent_runtime_project_paths(
&state.root,
&text,
if operation.is_some() {
2 * 1024 * 1024
} else {
32_000
},
),
images,
is_error,
)
}
failed => {
let (is_uncertain, error) = match failed {
Ok(Err(error)) => (
matches!(
&error,
cocos_editor_bridge::BridgeError::ExecutionUncertain(_)
),
error.to_string(),
),
Err(_) => (
true,
"Cocos execute worker 退出,执行结果需要核对".to_string(),
),
Ok(Ok(_)) => unreachable!(),
};
*uncertain = is_uncertain;
bridge_tool_result(
json!({
"status": if is_uncertain { "needs-reconciliation" } else { "failed" },
"retryAllowed": !is_uncertain,
"message": redact_agent_runtime_error(&state.root, &error, 480),
})
.to_string(),
Vec::new(),
true,
)
}
}
}
async fn handle_direct_tool_bridge( async fn handle_direct_tool_bridge(
State(state): State<Arc<DirectToolBridgeState>>, State(state): State<Arc<DirectToolBridgeState>>,
Json(request): Json<DirectToolBridgeRequest>, Json(request): Json<DirectToolBridgeRequest>,
) -> Json<Value> { ) -> Json<Value> {
let result = match request.tool.as_str() { let result = match request.tool.as_str() {
// 隔离 MCP 只取工具名,不接触真实 AppData 或读取权限。
"builtin.plugins.tools" => bridge_tool_result(
json!({"tools": crate::builtin_plugins::available_agent_tools()}).to_string(),
Vec::new(),
false,
),
"taonier_prepare_game_art" => bridge_prepare_game_art(&state, &request.arguments).await, "taonier_prepare_game_art" => bridge_prepare_game_art(&state, &request.arguments).await,
"agc_generate_image" => bridge_generate_image(&state, &request.arguments).await, "agc_generate_image" => bridge_generate_image(&state, &request.arguments).await,
"agc_edit_image" => bridge_edit_image(&state, &request.arguments).await, "agc_edit_image" => bridge_edit_image(&state, &request.arguments).await,
@@ -2330,6 +2532,12 @@ async fn handle_direct_tool_bridge(
bridge_list_registered_assets(&state.root, &request.arguments) bridge_list_registered_assets(&state.root, &request.arguments)
} }
"agc_list_project_files" => bridge_list_project_files(&state.root, &request.arguments), "agc_list_project_files" => bridge_list_project_files(&state.root, &request.arguments),
#[cfg(all(windows, feature = "cocos-editor-execute"))]
"agc_cocos_execute" => bridge_cocos_execute(&state, &request.arguments).await,
#[cfg(all(windows, feature = "cocos-editor-execute"))]
operation if cocos_editor_bridge::is_cocos_operation(operation) => {
bridge_cocos_call(&state, &request.arguments, Some(operation)).await
}
"agc_write_file" => { "agc_write_file" => {
bridge_write_file_in_blocking_pool(state.root.clone(), request.arguments).await bridge_write_file_in_blocking_pool(state.root.clone(), request.arguments).await
} }
@@ -2375,14 +2583,11 @@ pub(crate) async fn start_direct_tool_bridge(
.route(&route, post(handle_direct_tool_bridge)) .route(&route, post(handle_direct_tool_bridge))
.layer(DefaultBodyLimit::max(DIRECT_TOOL_BRIDGE_MAX_REQUEST_BYTES)) .layer(DefaultBodyLimit::max(DIRECT_TOOL_BRIDGE_MAX_REQUEST_BYTES))
.with_state(Arc::clone(&state)); .with_state(Arc::clone(&state));
let url = format!("http://127.0.0.1:{}{route}", address.port());
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
let _ = axum::serve(listener, app).await; let _ = axum::serve(listener, app).await;
}); });
Ok(DirectToolBridge { Ok(DirectToolBridge { url, state, task })
url: format!("http://127.0.0.1:{}{route}", address.port()),
state,
task,
})
} }
#[cfg(test)] #[cfg(test)]
@@ -74,11 +74,36 @@ pub(crate) fn run_direct_tools_mcp_if_requested(args: &[String]) -> Option<i32>
}) })
} }
fn direct_tools_mcp_specs() -> Value { async fn direct_tools_mcp_specs() -> Value {
direct_tools_mcp_specs_for(controlled_web_search_enabled()) let mut cocos_editor_available = false;
if cfg!(all(windows, feature = "cocos-editor-execute")) {
// 每次 tools/list 询问绑定的宿主;失败时不广告可选插件工具。
if let Ok(result) = tokio::time::timeout(
std::time::Duration::from_secs(5),
call_client_tool_bridge("builtin.plugins.tools", &json!({})),
)
.await
{
if result["isError"] == false {
let availability = result
.pointer("/content/0/text")
.and_then(Value::as_str)
.and_then(|text| serde_json::from_str::<Value>(text).ok());
cocos_editor_available = availability
.as_ref()
.and_then(|v| v["tools"].as_array())
.is_some_and(|tools| {
tools
.iter()
.any(|tool| tool == crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME)
});
}
}
}
direct_tools_mcp_specs_for(controlled_web_search_enabled(), cocos_editor_available)
} }
fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_available: bool) -> Value {
let tools = vec![ let tools = vec![
json!({ json!({
"name": "client.session.info", "name": "client.session.info",
@@ -431,6 +456,24 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value {
}), }),
]; ];
let mut tools = tools; let mut tools = tools;
#[cfg(all(windows, feature = "cocos-editor-execute"))]
if _cocos_editor_available {
tools.push(json!({
"name": "agc_cocos_execute",
"description": "在当前项目已连接的 Cocos Creator 主进程执行 JavaScript 函数体,支持 await 和 return。宿主绑定项目和目标进程,只提交 code。结果待核对或超时后禁止自动重发;使用 Editor.Message 调用 Creator API。",
"inputSchema": {
"type": "object",
"properties": { "code": { "type": "string", "minLength": 1, "maxLength": cocos_editor_bridge::MAX_EXECUTE_CODE_BYTES } },
"required": ["code"],
"additionalProperties": false
}
}));
tools.extend(
cocos_editor_bridge::cocos_operation_catalog()
.iter()
.cloned(),
);
}
if controlled_web_search { if controlled_web_search {
tools.push(json!({ tools.push(json!({
"name": "agc_web_search", "name": "agc_web_search",
@@ -503,6 +546,21 @@ fn validate_write_file_arguments(arguments: &Value) -> Result<(), String> {
normalize_relative_path(&path).map(|_| ()) normalize_relative_path(&path).map(|_| ())
} }
#[cfg(all(windows, feature = "cocos-editor-execute"))]
async fn call_agc_cocos_execute(arguments: &Value) -> Value {
let validated = validate_tool_object_fields(arguments, &["code"]).and_then(|()| {
let code = arguments
.get("code")
.and_then(Value::as_str)
.ok_or_else(|| "code 必须是 JavaScript 函数体".to_string())?;
cocos_editor_bridge::validate_execute_code(code).map_err(|error| error.to_string())
});
if let Err(error) = validated {
return mcp_tool_result(error, Vec::new(), true);
}
call_client_tool_bridge("agc_cocos_execute", arguments).await
}
fn mcp_success(id: Value, result: Value) -> Value { fn mcp_success(id: Value, result: Value) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "result": result }) json!({ "jsonrpc": "2.0", "id": id, "result": result })
} }
@@ -1335,7 +1393,12 @@ fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value {
true, true,
); );
} }
let _project_lock = match acquire_project_write_lock(root, "conversation.write") { // 与 DirectProject 历史落盘同一档有界等待:`agc_write_file` 持锁期间可能持续数秒,
// 零等待取锁会让 Codex 返回记录直接丢失。调用方已把本函数放进阻塞线程池。
let _project_lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"conversation.write",
) {
Ok(lock) => lock, Ok(lock) => lock,
Err(error) => { Err(error) => {
return mcp_tool_result(format!("项目对话锁不可用:{error}"), Vec::new(), true) return mcp_tool_result(format!("项目对话锁不可用:{error}"), Vec::new(), true)
@@ -1556,7 +1619,7 @@ async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option<
)) ))
} }
} }
"tools/list" => Some(mcp_success(id, direct_tools_mcp_specs())), "tools/list" => Some(mcp_success(id, direct_tools_mcp_specs().await)),
"tools/call" => { "tools/call" => {
let tool = request let tool = request
.pointer("/params/name") .pointer("/params/name")
@@ -1569,12 +1632,33 @@ async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option<
let result = match tool { let result = match tool {
"client.session.info" => external_mcp_session_info(root), "client.session.info" => external_mcp_session_info(root),
"conversation.record_codex_response" => { "conversation.record_codex_response" => {
external_mcp_record_response(root, &arguments) // 取锁等待是同步轮询(最多约 10 秒),必须放到阻塞线程池,
// 否则会占住 runtime worker。
let journal_root = root.to_path_buf();
let journal_arguments = arguments.clone();
match tokio::task::spawn_blocking(move || {
external_mcp_record_response(&journal_root, &journal_arguments)
})
.await
{
Ok(result) => result,
Err(error) => mcp_tool_result(
format!("Codex 返回记录任务未返回:{error}"),
Vec::new(),
true,
),
}
} }
"conversation.list" => external_mcp_conversation_list(root, &arguments), "conversation.list" => external_mcp_conversation_list(root, &arguments),
"conversation.read" => external_mcp_conversation_read(root, &arguments), "conversation.read" => external_mcp_conversation_read(root, &arguments),
"agc_read_skill_resource" => call_agc_read_skill_resource(&arguments), "agc_read_skill_resource" => call_agc_read_skill_resource(&arguments),
"agc_write_file" => call_agc_write_file(&arguments).await, "agc_write_file" => call_agc_write_file(&arguments).await,
#[cfg(all(windows, feature = "cocos-editor-execute"))]
"agc_cocos_execute" => call_agc_cocos_execute(&arguments).await,
#[cfg(all(windows, feature = "cocos-editor-execute"))]
operation if cocos_editor_bridge::is_cocos_operation(operation) => {
call_client_tool_bridge(operation, &arguments).await
}
"taonier_prepare_game_art" => call_taonier_prepare_game_art(&arguments).await, "taonier_prepare_game_art" => call_taonier_prepare_game_art(&arguments).await,
"agc_generate_image" => call_agc_generate_image(&arguments).await, "agc_generate_image" => call_agc_generate_image(&arguments).await,
"agc_edit_image" => call_agc_edit_image(&arguments).await, "agc_edit_image" => call_agc_edit_image(&arguments).await,
@@ -1767,8 +1851,250 @@ pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[cfg(all(windows, feature = "cocos-editor-execute"))]
#[test]
fn builtin_mcp_process_probe() {
let Ok(expected) = std::env::var("AGC_MCP_TEST_COCOS_EXPECTED") else {
return;
};
assert!(crate::game_creator_runtime_config_dir().is_none());
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let specs = runtime.block_on(direct_tools_mcp_specs());
assert_eq!(
specs["tools"]
.as_array()
.unwrap()
.iter()
.any(|tool| tool["name"] == "agc_cocos_execute"),
expected == "true"
);
assert_eq!(
specs["tools"]
.as_array()
.unwrap()
.iter()
.filter(|tool| tool["name"]
.as_str()
.is_some_and(cocos_editor_bridge::is_cocos_operation))
.count(),
if expected == "true" { 36 } else { 0 }
);
}
#[cfg(all(windows, feature = "cocos-editor-execute"))]
#[tokio::test]
async fn builtin_tools_follow_host_switch_in_isolated_mcp_processes() {
let _guard = crate::builtin_plugins::test_lock();
let config = tempfile::tempdir().unwrap();
crate::builtin_plugins::initialize(config.path()).unwrap();
let project = crate::tests::canonical_test_tempdir("builtin-mcp-project-");
// 本用例只访问可用工具摘要和禁用入口,无需初始化完整游戏项目。
std::fs::create_dir_all(project.path().join(".agent")).unwrap();
std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap();
let bridge =
super::super::direct_tool_bridge::start_direct_tool_bridge(project.path(), false)
.await
.unwrap();
for enabled in [false, true, false, true] {
crate::builtin_plugins::set_enabled(
crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID,
enabled,
)
.unwrap();
// 同一个 MCP 服务重复 tools/list,同时覆盖原生函数永久缓存的切换。
let specs = EXTERNAL_MCP_BRIDGE_URL
.scope(bridge.url().to_string(), direct_tools_mcp_specs())
.await;
assert_eq!(
specs["tools"]
.as_array()
.unwrap()
.iter()
.any(|tool| tool["name"] == "agc_cocos_execute"),
enabled
);
assert_eq!(
crate::agent_native_tools::native_runtime_function_name(
crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME
)
.is_some(),
enabled
);
if !enabled {
for tool in cocos_editor_bridge::cocos_operation_catalog() {
let response = EXTERNAL_MCP_BRIDGE_URL
.scope(
bridge.url().to_string(),
call_client_tool_bridge(tool["name"].as_str().unwrap(), &json!({})),
)
.await;
assert_eq!(response["isError"], true);
assert!(response.to_string().contains("插件已禁用"));
}
let response = EXTERNAL_MCP_BRIDGE_URL
.scope(
bridge.url().to_string(),
call_agc_cocos_execute(&json!({"code":"return 1;"})),
)
.await;
assert_eq!(response["isError"], true);
assert!(response.to_string().contains("插件已禁用"));
}
// 子进程没有真实 AppData,必须只从绑定宿主获取可用性。
let url = bridge.url().to_string();
let result = tokio::task::spawn_blocking(move || {
std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"agent::direct_tools_mcp::tests::builtin_mcp_process_probe",
"--nocapture",
])
.env(DIRECT_TOOL_BRIDGE_URL_ENV, url)
.env("AGC_MCP_TEST_COCOS_EXPECTED", enabled.to_string())
.output()
.unwrap()
})
.await
.unwrap();
assert!(
result.status.success(),
"{} {}",
String::from_utf8_lossy(&result.stdout),
String::from_utf8_lossy(&result.stderr)
);
assert!(
String::from_utf8_lossy(&result.stdout).contains("1 passed"),
"child probe must run"
);
}
std::fs::write(config.path().join("extensions/builtin-plugins.json"), "{").unwrap();
let specs = EXTERNAL_MCP_BRIDGE_URL
.scope(bridge.url().to_string(), direct_tools_mcp_specs())
.await;
assert!(!specs.to_string().contains("agc_cocos_execute"));
drop(bridge);
let specs = EXTERNAL_MCP_BRIDGE_URL
.scope(
"http://127.0.0.1:1/tool-unavailable".to_string(),
direct_tools_mcp_specs(),
)
.await;
assert!(!specs.to_string().contains("agc_cocos_execute"));
}
use std::io::{Read, Write}; use std::io::{Read, Write};
#[cfg(all(windows, feature = "cocos-editor-execute"))]
#[tokio::test]
#[ignore = "显式指定已打开的自有 Cocos smoke 工程后运行"]
async fn cocos_real_tools_list_and_call() {
let _guard = crate::builtin_plugins::test_lock();
let root =
PathBuf::from(std::env::var("AGC_COCOS_TEST_PROJECT").expect("explicit smoke root"))
.canonicalize()
.unwrap();
let package: Value =
serde_json::from_slice(&std::fs::read(root.join("package.json")).unwrap()).unwrap();
assert_eq!(
package["name"], "agc-cocos-capability-smoke",
"只操作自有测试工程"
);
let config = tempfile::tempdir().unwrap();
crate::builtin_plugins::initialize(config.path()).unwrap();
std::fs::create_dir_all(root.join(".agent")).unwrap();
if !root.join(".agent/manifest.json").exists() {
std::fs::write(root.join(".agent/manifest.json"), "{}").unwrap();
}
let bridge = super::super::direct_tool_bridge::start_direct_tool_bridge(&root, false)
.await
.unwrap();
let tools = EXTERNAL_MCP_BRIDGE_URL
.scope(bridge.url().to_string(), direct_tools_mcp_specs())
.await;
assert_eq!(
tools["tools"]
.as_array()
.unwrap()
.iter()
.filter(|t| t["name"]
.as_str()
.is_some_and(cocos_editor_bridge::is_cocos_operation))
.count(),
36
);
async fn invoke(root: &Path, url: &str, name: &str, args: Value) -> Value {
let reply = EXTERNAL_MCP_BRIDGE_URL.scope(url.to_string(), handle_direct_tools_mcp_request(root, json!({
"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":name,"arguments":args}
}))).await.unwrap();
assert_eq!(reply["result"]["isError"], false, "{name}: {reply}");
reply["result"].clone()
}
let ping = invoke(&root, bridge.url(), "cocos_ping", json!({})).await;
assert!(ping.to_string().contains("ready"));
let hierarchy = invoke(&root, bridge.url(), "cocos_get_hierarchy", json!({})).await;
let parsed: Value =
serde_json::from_str(hierarchy["content"][0]["text"].as_str().unwrap()).unwrap();
let tree = &parsed["result"]["result"]["tree"];
let canvas = tree["children"]
.as_array()
.unwrap()
.iter()
.find(|n| n["name"] == "Canvas")
.unwrap();
invoke(
&root,
bridge.url(),
"cocos_create_ui_label",
json!({
"parentNid":canvas["uuid"], "name":"AGC_MCP_SMOKE", "text":"MCP", "save":false
}),
)
.await;
invoke(&root, bridge.url(), "cocos_mcp_undo_last", json!({})).await;
if let Ok(url) = std::env::var("AGC_COCOS_TEST_PREVIEW_URL") {
invoke(
&root,
bridge.url(),
"cocos_preview_debug_start",
json!({"url":url}),
)
.await;
let capture = invoke(
&root,
bridge.url(),
"cocos_preview_debug_capture",
json!({}),
)
.await;
assert!(capture["content"]
.as_array()
.unwrap()
.iter()
.any(|b| b["type"] == "image" && b["mimeType"] == "image/png"));
invoke(&root, bridge.url(), "cocos_preview_debug_stop", json!({})).await;
}
let final_tree = invoke(&root, bridge.url(), "cocos_get_hierarchy", json!({})).await;
assert!(!final_tree.to_string().contains("AGC_MCP_SMOKE"));
}
#[test]
fn cocos_mcp_schemas_accept_real_arguments_and_reject_unknown_fields() {
for tool in cocos_editor_bridge::cocos_operation_catalog() {
let validator = jsonschema::validator_for(&tool["inputSchema"]).unwrap();
assert!(!validator.is_valid(&json!({"unregistered-field":1})));
}
let ui = cocos_editor_bridge::cocos_operation_catalog()
.iter()
.find(|t| t["name"] == "cocos_apply_ui_spec")
.unwrap();
let validator = jsonschema::validator_for(&ui["inputSchema"]).unwrap();
assert!(validator.is_valid(&json!({"parentNid":1,"nodes":[{"kind":"container","children":[{"kind":"label","text":"测试"}]}],"save":false})));
assert!(!validator.is_valid(&json!({"parentNid":1,"nodes":[{"kind":"shell-command"}]})));
}
#[tokio::test] #[tokio::test]
async fn loopback_tool_bridge_client_omits_agc_marker() { async fn loopback_tool_bridge_client_omits_agc_marker() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind loopback fixture"); let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind loopback fixture");
@@ -1842,7 +2168,7 @@ mod tests {
DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES > DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES + 1024, DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES > DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES + 1024,
"MCP request envelope must fit the advertised file-write payload" "MCP request envelope must fit the advertised file-write payload"
); );
let specs = direct_tools_mcp_specs_for(false); let specs = direct_tools_mcp_specs_for(false, true);
let names = specs["tools"] let names = specs["tools"]
.as_array() .as_array()
.expect("tool array") .expect("tool array")
@@ -1869,6 +2195,22 @@ mod tests {
"agc_remove_background", "agc_remove_background",
"agc_browser_playtest", "agc_browser_playtest",
] ]
.into_iter()
.chain(
cfg!(all(windows, feature = "cocos-editor-execute")).then_some("agc_cocos_execute")
)
.chain(
cocos_editor_bridge::cocos_operation_catalog()
.iter()
.filter_map(|tool| {
if cfg!(all(windows, feature = "cocos-editor-execute")) {
tool["name"].as_str()
} else {
None
}
})
)
.collect::<Vec<_>>()
); );
let serialized = specs.to_string(); let serialized = specs.to_string();
assert!(!serialized.contains("agc_web_search")); assert!(!serialized.contains("agc_web_search"));
@@ -1975,7 +2317,7 @@ mod tests {
#[test] #[test]
fn tool_catalog_adds_controlled_web_search_only_when_enabled() { fn tool_catalog_adds_controlled_web_search_only_when_enabled() {
let specs = direct_tools_mcp_specs_for(true); let specs = direct_tools_mcp_specs_for(true, true);
let search = specs["tools"] let search = specs["tools"]
.as_array() .as_array()
.expect("tool array") .expect("tool array")
@@ -1606,6 +1606,17 @@ pub(crate) fn agent_runtime_tool_action_input_summary(
.unwrap_or(160) .unwrap_or(160)
), ),
"command.run_limited" => format!("commandId={}", text(&["commandId", "command_id", "id"])), "command.run_limited" => format!("commandId={}", text(&["commandId", "command_id", "id"])),
"cocos.editor.execute" => format!(
"codeChars={} · codeSha256={:x}",
chars(&["code"]),
Sha256::digest(
input
.get("code")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.as_bytes()
)
),
"preview.validate" => { "preview.validate" => {
let viewports = input let viewports = input
.get("viewports") .get("viewports")
@@ -66,12 +66,10 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
return blocker; return blocker;
} }
} }
if !relaxed_autonomous { if let Some(blocker) =
if let Some(blocker) = agent_runtime_autonomous_art_director_canvas_only_action_block(agent_id, task, tool)
agent_runtime_autonomous_art_director_canvas_only_action_block(agent_id, task, tool) {
{ return blocker;
return blocker;
}
} }
let command_id = game_creator_agent_runtime_tool_command_id(tool); let command_id = game_creator_agent_runtime_tool_command_id(tool);
if let Some(command_id) = command_id { if let Some(command_id) = command_id {
@@ -364,6 +362,16 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
observe_agent_runtime_limited_command(root, agent_id, run_id, &action.input) observe_agent_runtime_limited_command(root, agent_id, run_id, &action.input)
} }
"preview.start" => observe_agent_runtime_preview_start(root, agent_id, run_id), "preview.start" => observe_agent_runtime_preview_start(root, agent_id, run_id),
"cocos.editor.execute" => observe_agent_runtime_project_snapshot_with_lock(
root,
agent_id,
run_id,
action,
&action_fingerprint,
pending_action,
true,
|| observe_agent_runtime_cocos_editor_execute(root, action, pending_action),
),
"preview.validate" => { "preview.validate" => {
observe_agent_runtime_preview_validate( observe_agent_runtime_preview_validate(
root, root,
@@ -95,6 +95,8 @@ pub(in crate::agent) fn game_creator_agent_runtime_tool_command_id(
"command.stdin" => Some("command.stdin"), "command.stdin" => Some("command.stdin"),
"command.terminate" => Some("command.terminate"), "command.terminate" => Some("command.terminate"),
"command.run_limited" => Some("command.run_limited"), "command.run_limited" => Some("command.run_limited"),
#[cfg(feature = "cocos-editor-execute")]
"cocos.editor.execute" => Some("cocos.editor.execute"),
"preview.start" => Some("preview.start"), "preview.start" => Some("preview.start"),
"preview.validate" => Some("preview.validate"), "preview.validate" => Some("preview.validate"),
"image.inspect" => Some("image.inspect"), "image.inspect" => Some("image.inspect"),
@@ -138,12 +138,16 @@ pub(crate) fn ensure_current_autonomous_ready_child_mutation_at_locked(
if game_creator_agent_runtime_cancel_requested_for(root, &normalized_agent_id, run_id) { if game_creator_agent_runtime_cancel_requested_for(root, &normalized_agent_id, run_id) {
return Err("当前 Run 已收到取消请求,禁止继续修改项目".to_string()); return Err("当前 Run 已收到取消请求,禁止继续修改项目".to_string());
} }
if autonomous_relaxed_run_at(root, &normalized_agent_id, run_id)? { let relaxed_autonomous = autonomous_relaxed_run_at(root, &normalized_agent_id, run_id)?;
return Ok(());
}
let binding = let binding =
read_game_creator_agent_runtime_run_profile_binding(root, &normalized_agent_id, run_id)?; read_game_creator_agent_runtime_run_profile_binding(root, &normalized_agent_id, run_id)?;
let Some(binding) = binding else { let Some(binding) = binding else {
if relaxed_autonomous {
// Keep the relaxed lane compatible with legacy runs that predate
// durable Run Profile sidecars; bound runs still receive the
// current-root and lineage checks below.
return Ok(());
}
let task = read_latest_game_creator_agent_runtime_task_by_run_id( let task = read_latest_game_creator_agent_runtime_task_by_run_id(
root, root,
&normalized_agent_id, &normalized_agent_id,
@@ -17,7 +17,7 @@ mod canvas_asset_kind_contract_tests {
} }
pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> { pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> {
vec![ let mut tools = vec![
GAME_CREATOR_USER_INPUT_REQUEST_TOOL, GAME_CREATOR_USER_INPUT_REQUEST_TOOL,
"memory.read", "memory.read",
"memory.write", "memory.write",
@@ -63,7 +63,13 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> {
"agent.schedule_ready", "agent.schedule_ready",
"agent.action_history", "agent.action_history",
"agent.run_status", "agent.run_status",
] ];
// 内置插件被用户禁用后,对应 Runtime 工具不再进入工具目录、Agent 上下文
// 和工具策略快照。
if crate::builtin_plugins::cocos_editor_agent_tool_available() {
tools.push(crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME);
}
tools
} }
pub(crate) fn agent_runtime_native_executable_tools() -> Vec<&'static str> { pub(crate) fn agent_runtime_native_executable_tools() -> Vec<&'static str> {
@@ -173,6 +179,16 @@ pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at(
auto_tools.push(tool.to_string()); auto_tools.push(tool.to_string());
} }
} }
if isolated {
// Keep the isolated-child contract explicit even when a denied tool
// is unavailable on the current platform and therefore absent from
// the executable catalog (for example, the Windows-only Cocos bridge).
for tool in ISOLATED_AGENT_UNSCOPED_DENIED_TOOLS {
if !denied_tools.iter().any(|candidate| candidate == tool) {
denied_tools.push((*tool).to_string());
}
}
}
Ok(AgentRuntimeToolPolicySnapshot { Ok(AgentRuntimeToolPolicySnapshot {
run_profile: default_agent_runtime_run_profile(), run_profile: default_agent_runtime_run_profile(),
run_profile_binding_fingerprint: String::new(), run_profile_binding_fingerprint: String::new(),
@@ -1908,7 +1908,7 @@ mod tests {
) )
.expect("first persist"); .expect("first persist");
rewind_session_keep_gdd_file(&root, &session); rewind_session_keep_gdd_file(&root, &session);
let hydrated = hydrate_planning_session_v2( let hydrated = hydrate_planning_session_v2_sync(
root.to_string_lossy().to_string(), root.to_string_lossy().to_string(),
Some(session.session_id.clone()), Some(session.session_id.clone()),
) )

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