Compare commits

..

9 Commits

Author SHA1 Message Date
kdletters fd904ea144 修复 DirectProject 写入锁竞争并开启 Cocos 编辑器执行
DirectProject 历史落盘与 Codex 返回记录改用项目写锁有界等待,不再零等待失败拖垮整轮

Codex 返回记录取锁改到阻塞线程池执行,避免占用 runtime worker

agc_write_file 增加取锁/写入/revision 分段计时日志,用于定位长时间持锁

Windows dev 构建默认启用 cocos-editor-execute,使 Agent 具备 agc_cocos_execute

补充 start-tauri-dev 参数单测与历史落盘等待回归用例
2026-09-11 23:25:37 +08:00
kdletters 299877b197 修复 Tauri dev 插件资源自触发重建
build.rs 只在内容变化时落盘插件资源,避免构建产物触发下一轮重建

新增应用目录与 src-tauri 的 .taurignore,把 resources/plugins 排除出 dev 监听
2026-09-11 23:25:31 +08:00
kdletters b67cb18b45 接入 Cocos 项目识别与导入
完善 Cocos Creator 项目识别、导入和 Agent 目录初始化

补充打开项目流程、契约、测试与文档
2026-09-11 19:56:34 +08:00
kdletters 8ba9835d10 合并远端 master 最新修改
保留 AGC 插件系统与 Cocos 适配改动
合并远端项目写锁等待与持锁方诊断修复
同步远端项目锁、配置和测试更新
2026-09-11 11:42:23 +08:00
kdletters 02ff914bd3 修复内置插件跨进程开关与不确定执行阻断
让 Runner 和 CLI 查询持久化开关并即时感知 GUI 状态更新
让隔离 MCP 经现有工具桥获取可用工具,并将开关纳入会话缓存标识
在执行入口复查禁用状态,固定原生函数缓存构建使用的工具快照
修复损坏开关文件保存成功后仍保持关闭的状态
保留插件执行结果不确定的结构化回执和宿主适配器阻断,拒绝并发积压与自动重发
新增跨进程工具目录、热切换、坏文件恢复及不确定执行回归测试并同步文档
2026-09-10 21:37:51 +08:00
kdletters dd36de3aa6 新增 AGC 内置插件模式与插件工作区
- 新增 plugins/ 插件工作区及 agc-cocos-editor 插件包,Cocos 直连模块从宿主源码树迁入插件包
- 新增 server-rs/crates/editor-adapter-api 通用编辑器适配器契约,宿主只保留适配器注册与 RPC 路由
- 新增内置插件登记表与可用开关,状态持久化在 AppData extensions/builtin-plugins.json,状态文件损坏时失败关闭
- 禁用内置插件时先停止进程并拒绝启动,同时从 Runtime 工具目录、工具策略快照、原生函数目录与 DirectProject 工具面移除对应工具
- 插件宿主新增 plugins/ 工作区扫描,内置插件优先于同名 AppData 导入插件,前端只提供可用开关而不提供重命名和卸载入口
- 编辑器操作统一经 host.rpc 路由到插件包自带的 native 适配器,并删除 Cocos 专属 Tauri 命令
- 修正 Windows 打包与开发态 payload 路径,随包映射 plugins 工作区资源
- 同步插件、Cocos 桥接、实施计划文档与 decision log
2026-09-10 20:00:02 +08:00
kdletters 9d2ad69a75 新增 AGC Cocos 直连模块
新增独立 crate server-rs/crates/cocos-editor-bridge:Creator 主进程发现与项目/PID 身份校验、named pipe 命令协议、可选 Windows DLL 注入,默认 feature 全部关闭。
新增 Creator 主进程内 payload:在 \\.\pipe\genarrative-cocos-editor-{pid} 上串行提供 ping/status/execute,execute 只接受有界 JavaScript 函数体,结果不确定时返回 ExecutionUncertain 并禁止自动重放。
AGC 新增 cocos_editor 模块与 prepare_cocos_editor_injection、inject_cocos_editor、ping_cocos_editor、status_cocos_editor、execute_cocos_editor_code 五条命令,注入只加载随包资源目录中的 DLL。
AGC 新增 cocos-editor、cocos-editor-execute、cocos-editor-injection 三层 feature 与资源映射,Windows 构建把 bridge DLL 暂存到 resources/cocos-editor-bridge,并改为按 Cargo profile 目录查找产物 DLL。
Runtime 新增 cocos.editor.execute:同步工具策略快照、并行账本、隔离 Agent 拒绝表、工具描述与动作审计,审计只记录代码长度与 SHA-256。
DirectProject 的 agc_tools 新增 agc_cocos_execute,只接收 code,复用项目写锁和当前项目权限,结果不确定时阻断后续调用。
共享契约新增 cocos.editor.execute(confirm 权限),当前分支命令契约从 64 条更新为 65 条。
新增技术方案文档与决策记录,并在文档索引登记;resources/cocos-editor-bridge 仅提交占位与 .gitignore,生成的 DLL 不入库。
移植到 codex/agc-agent-plugins 的适配:命令契约长度、Tauri 未调用命令 allowlist 与 Windows 资源清单校验随当前分支基线同步更新。
2026-09-10 16:50:49 +08:00
kdletters 7df526868c 合并远端 master 到 codex/agc-agent-plugins
合并远端 master 的 AGC 启动、进程复用与项目写锁回收修复
合并远端 master 的开发脚本、运维文档与项目记忆更新
保留本分支的通用插件宿主、插件宿主服务与 AGC Plugin SDK 改动
2026-09-10 16:33:42 +08:00
kdletters 3c1711c7df 新增 AGC 通用插件宿主与 SDK
统一 Agent Plugin、Skill、MCP 的来源索引和启停状态
新增通用进程宿主、JSON-RPC、能力注册、权限审计与面板挂载
新增 AGC Plugin SDK 与标准 plugin.json 解析支持
保留 EditorAdapter 扩展点,不内置具体编辑器适配器
补充运行时设置界面、项目上下文同步和定向测试
同步更新插件方案、DirectProject、workspace 与项目记忆文档
2026-09-10 13:37:27 +08:00
98 changed files with 9203 additions and 354 deletions
-31
View File
@@ -232,37 +232,6 @@ jobs:
done
done
- name: Prepare standalone Rust crate dependencies
shell: bash
run: |
set -euo pipefail
# agent-runtime-core / agent-runtime-orchestration 被 server-rs/Cargo.toml 的
# exclude 排除,不参与上面的 workspace 锁文件,因此上面那次锁定 fetch 覆盖不到它们;
# 而 check:native-shells 会经 agent-runtime-*:check 用 `cargo test --manifest-path`
# 单独跑这两个 crate。不在这里预热的话,这两条测试会在测试阶段自己
# `Updating crates.io index`crates.io 一抖动整条 native shell 作业就红
# (见 #327 / PR #316 run 1950)。
# 两个 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch:
# 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内,
# 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本
# 解析,不再触碰 registry index。
for manifest_path in \
server-rs/crates/agent-runtime-core/Cargo.toml \
server-rs/crates/agent-runtime-orchestration/Cargo.toml; do
for attempt in $(seq 1 5); do
if cargo fetch \
--target x86_64-unknown-linux-gnu \
--manifest-path "${manifest_path}"; then
break
fi
if [[ "${attempt}" -eq 5 ]]; then
echo "standalone crate dependency fetch failed after 5 attempts: ${manifest_path}" >&2
exit 1
fi
sleep $((attempt * 2))
done
done
- name: Run native shell gates
run: npm run check:native-shells
+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-resources/
/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/.llm-drafts/
/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/
@@ -117,6 +117,16 @@ const allowedUncalledTauriCommands = [
'stop_local_game_preview_if_matches',
'start_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([
'.json',
@@ -1285,7 +1295,7 @@ if (tauriConfig.identifier !== 'world.genarrative.ai-game-creator') {
throw new Error('AI game creator shell identifier drifted');
}
const expectedBundledCodexResources = {
const expectedBundledWindowsResources = {
'resources/codex/win-x64/bin/codex.exe': 'codex/win-x64/bin/codex.exe',
'resources/codex/win-x64/bin/codex-code-mode-host.exe':
'codex/win-x64/bin/codex-code-mode-host.exe',
@@ -1299,6 +1309,7 @@ const expectedBundledCodexResources = {
'codex/win-x64/codex-package.json',
'resources/codex/win-x64/NOTICE.md': 'codex/win-x64/NOTICE.md',
'resources/codex/win-x64/manifest.json': 'codex/win-x64/manifest.json',
'resources/plugins': 'plugins',
};
if (tauriConfig.bundle?.resources !== undefined) {
throw new Error(
@@ -1307,8 +1318,8 @@ if (tauriConfig.bundle?.resources !== undefined) {
}
assert.deepEqual(
windowsTauriConfig.bundle?.resources,
expectedBundledCodexResources,
'AI game creator shell Windows Tauri config must bundle the complete pinned Codex resource set',
expectedBundledWindowsResources,
'AI game creator shell Windows Tauri config must bundle the complete pinned Codex resource set and Cocos bridge payload directory',
);
if (windowsTauriConfig.bundle?.useLocalToolsDir !== true) {
throw new Error(
@@ -436,6 +436,10 @@ function spawnChild(command, args, options, spawnImpl = spawn) {
const child = spawnImpl(command, args, {
...options,
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、
// Node、Cargo 及其子进程一起清理,避免残留订阅任务持续刷日志。
detached: isPosix,
@@ -43,6 +43,28 @@ 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()) {
if (features.length === 0) return argv;
if (argv.some((value) => value === '--features' || value === '-f')) {
return argv;
}
return [`--features=${features.join(',')}`, ...argv];
}
function spawnTauriCli(argv, { env = process.env } = {}) {
return spawnChild(process.execPath, [tauriCliPath, ...argv], {
cwd: appRoot,
@@ -106,7 +128,10 @@ async function runTauriDev(
shutdownRequested.then(() => false),
]);
if (!prepared || shutdownSignal) return 1;
const tauriArguments = buildTauriArguments(argv, endpoint.url);
const tauriArguments = buildTauriArguments(
withDevCargoFeatures(argv),
endpoint.url,
);
child = spawnCli(tauriArguments, {
env: withAgcDevEndpointEnv(endpoint),
});
@@ -193,6 +218,7 @@ export {
isDirectModuleExecution,
runTauriDev,
spawnTauriCli,
withDevCargoFeatures,
};
if (isDirectModuleExecution()) {
@@ -0,0 +1,4 @@
# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。
# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发
# “构建 -> 监听 -> 再构建”的自触发循环。
resources/plugins/
+22
View File
@@ -733,6 +733,18 @@ dependencies = [
"error-code",
]
[[package]]
name = "cocos-editor-bridge"
version = "0.1.0"
dependencies = [
"cc",
"editor-adapter-api",
"serde",
"serde_json",
"sha2",
"windows-sys 0.61.2",
]
[[package]]
name = "combine"
version = "4.6.7"
@@ -1205,6 +1217,14 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "editor-adapter-api"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "either"
version = "1.16.0"
@@ -1709,6 +1729,8 @@ dependencies = [
"axum",
"base64 0.22.1",
"chromiumoxide",
"cocos-editor-bridge",
"editor-adapter-api",
"futures",
"getrandom 0.3.4",
"http",
@@ -6,6 +6,9 @@ publish = false
[features]
default = []
cocos-editor = ["cocos-editor-bridge/process-discovery"]
cocos-editor-execute = ["cocos-editor", "cocos-editor-bridge/windows-transport"]
cocos-editor-injection = ["cocos-editor-execute", "cocos-editor-bridge/windows-injection"]
[build-dependencies]
serde = { version = "1", features = ["derive"] }
@@ -19,6 +22,8 @@ ts-rs = "12.0.1"
typed_floats = { version = "1.0.7", features = ["serde"] }
nalgebra = { version = "0.35.0", features = ["serde-serialize"] }
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"
axum = "0.8"
chromiumoxide = "0.9.1"
@@ -182,6 +182,8 @@ fn main() {
);
let manifest_path = manifest_dir.join("prompts/runtime/manifest.json");
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)
.unwrap_or_else(|error| panic!("Prompt Bundle 编译失败:{error}"));
validate_seed_task_catalog(&compiled);
@@ -203,3 +205,146 @@ fn main() {
}
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) {}
@@ -1070,6 +1070,7 @@ fn game_creator_codex_app_server_pool_key(
"skillPackIdentity": skill_pack_identity,
"clientSkillIdentity": client_skill_identity,
"clientMcpIdentity": client_mcp_identity,
"builtinPluginTools": crate::builtin_plugins::available_agent_tools(),
"controlledWebSearch": llm.web_search_enabled,
"directToolBridgeProtocol": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { DIRECT_TOOL_BRIDGE_PROTOCOL } else { "disabled" },
"providerProxyProtocol": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { CODEX_PROVIDER_PROXY_PROTOCOL } else { "disabled" },
@@ -5050,6 +5051,42 @@ while IFS= read -r line; do :; done
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]
fn direct_file_change_approval_is_limited_to_workspace() {
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::project::{
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) {
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 history_exists =
prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")?;
@@ -429,6 +438,50 @@ mod tests {
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]
fn codex_user_echo_is_filtered_but_agc_user_message_is_persisted() {
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<()>,
resource_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)]
@@ -687,6 +689,8 @@ fn direct_tool_bridge_state_with_search(
regeneration_gate: tokio::sync::Mutex::new(()),
resource_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),
})
}
@@ -1477,12 +1481,28 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value {
// 而 `file.write / file.patch / file.delete` 等写入口用的是约 10 秒有界等待。
// 这是用户直接触发、失败即整轮无法落盘的项目写入通道,必须和其它写入口同语义:
// 短暂重叠排队等成功,只有预算耗尽才报出带持锁方身份的错误。
let acquire_started = std::time::Instant::now();
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"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 write_ms = write_started.elapsed().as_millis();
let revision_started = std::time::Instant::now();
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!({
"status": "completed",
"path": written.path,
@@ -2323,11 +2343,120 @@ 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 {
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 = (|| {
bridge_reject_unknown_fields(arguments, &["code"])?;
enforce_project_permission_policy(&state.root, "cocos.editor.execute")?;
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 mut uncertain = state.cocos_execute_uncertain.lock().await;
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 result = tokio::task::spawn_blocking(move || {
let _lock = acquire_project_write_lock(&root, "direct-cocos.execute")
.map_err(cocos_editor_bridge::BridgeError::InvalidInput)?;
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,
cocos_editor_bridge::DEFAULT_COMMAND_TIMEOUT_MS,
)
})
.await;
match result {
Ok(Ok(response)) => {
let is_error = !response.ok;
let text = json!({
"status": if response.ok { "completed" } else { "failed" },
"requestId": response.request_id,
"result": response.result,
"error": response.error,
})
.to_string();
bridge_tool_result(
redact_agent_runtime_project_paths(&state.root, &text, 32_000),
Vec::new(),
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(
State(state): State<Arc<DirectToolBridgeState>>,
Json(request): Json<DirectToolBridgeRequest>,
) -> Json<Value> {
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,
"agc_generate_image" => bridge_generate_image(&state, &request.arguments).await,
"agc_edit_image" => bridge_edit_image(&state, &request.arguments).await,
@@ -2335,6 +2464,8 @@ async fn handle_direct_tool_bridge(
bridge_list_registered_assets(&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,
"agc_write_file" => {
bridge_write_file_in_blocking_pool(state.root.clone(), request.arguments).await
}
@@ -74,11 +74,36 @@ pub(crate) fn run_direct_tools_mcp_if_requested(args: &[String]) -> Option<i32>
})
}
fn direct_tools_mcp_specs() -> Value {
direct_tools_mcp_specs_for(controlled_web_search_enabled())
async fn direct_tools_mcp_specs() -> Value {
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![
json!({
"name": "client.session.info",
@@ -431,6 +456,19 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value {
}),
];
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
}
}));
}
if controlled_web_search {
tools.push(json!({
"name": "agc_web_search",
@@ -503,6 +541,21 @@ fn validate_write_file_arguments(arguments: &Value) -> Result<(), String> {
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 {
json!({ "jsonrpc": "2.0", "id": id, "result": result })
}
@@ -1335,7 +1388,12 @@ fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value {
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,
Err(error) => {
return mcp_tool_result(format!("项目对话锁不可用:{error}"), Vec::new(), true)
@@ -1556,7 +1614,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" => {
let tool = request
.pointer("/params/name")
@@ -1569,12 +1627,29 @@ async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option<
let result = match tool {
"client.session.info" => external_mcp_session_info(root),
"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.read" => external_mcp_conversation_read(root, &arguments),
"agc_read_skill_resource" => call_agc_read_skill_resource(&arguments),
"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,
"taonier_prepare_game_art" => call_taonier_prepare_game_art(&arguments).await,
"agc_generate_image" => call_agc_generate_image(&arguments).await,
"agc_edit_image" => call_agc_edit_image(&arguments).await,
@@ -1767,6 +1842,119 @@ pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> {
#[cfg(test)]
mod tests {
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"
);
}
#[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 {
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};
#[tokio::test]
@@ -1842,7 +2030,7 @@ mod tests {
DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES > DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES + 1024,
"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"]
.as_array()
.expect("tool array")
@@ -1869,6 +2057,11 @@ mod tests {
"agc_remove_background",
"agc_browser_playtest",
]
.into_iter()
.chain(
cfg!(all(windows, feature = "cocos-editor-execute")).then_some("agc_cocos_execute")
)
.collect::<Vec<_>>()
);
let serialized = specs.to_string();
assert!(!serialized.contains("agc_web_search"));
@@ -1975,7 +2168,7 @@ mod tests {
#[test]
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"]
.as_array()
.expect("tool array")
@@ -1606,6 +1606,17 @@ pub(crate) fn agent_runtime_tool_action_input_summary(
.unwrap_or(160)
),
"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" => {
let viewports = input
.get("viewports")
@@ -362,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)
}
"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" => {
observe_agent_runtime_preview_validate(
root,
@@ -95,6 +95,8 @@ pub(in crate::agent) fn game_creator_agent_runtime_tool_command_id(
"command.stdin" => Some("command.stdin"),
"command.terminate" => Some("command.terminate"),
"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.validate" => Some("preview.validate"),
"image.inspect" => Some("image.inspect"),
@@ -17,7 +17,7 @@ mod canvas_asset_kind_contract_tests {
}
pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> {
vec![
let mut tools = vec![
GAME_CREATOR_USER_INPUT_REQUEST_TOOL,
"memory.read",
"memory.write",
@@ -63,7 +63,13 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> {
"agent.schedule_ready",
"agent.action_history",
"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> {
@@ -1,6 +1,7 @@
use super::*;
mod action_history;
mod cocos_editor;
mod command_ops;
mod context;
mod delegation;
@@ -20,6 +21,7 @@ mod task_ops;
mod ui_workflow;
pub(in crate::agent) use action_history::*;
pub(in crate::agent) use cocos_editor::*;
pub(in crate::agent) use command_ops::*;
pub(in crate::agent) use context::*;
pub(in crate::agent) use delegation::*;
@@ -0,0 +1,117 @@
use super::*;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct CocosEditorExecuteInput {
code: String,
}
pub(in crate::agent) fn observe_agent_runtime_cocos_editor_execute(
root: &Path,
action: &AgentRuntimeToolAction,
pending_action: Option<&AgentRuntimePendingToolAction>,
) -> AgentRuntimeToolObservation {
let input = match serde_json::from_value::<CocosEditorExecuteInput>(action.input.clone()) {
Ok(input) => input,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "cocos.editor.execute".to_string(),
status: "failed".to_string(),
summary: sanitize_agent_runtime_text(
&format!("cocos.editor.execute 输入无效:{error}"),
240,
),
detail: None,
};
}
};
if pending_action.is_none() {
return AgentRuntimeToolObservation {
tool: "cocos.editor.execute".to_string(),
status: "failed".to_string(),
summary: "cocos.editor.execute 必须绑定 durable pending action".to_string(),
detail: None,
};
}
if !crate::builtin_plugins::cocos_editor_agent_tool_available() {
return AgentRuntimeToolObservation {
tool: "cocos.editor.execute".to_string(),
status: "failed".to_string(),
summary: "Cocos Creator 插件已禁用".to_string(),
detail: None,
};
}
let response = match cocos_editor_bridge::execute_cocos_editor_code_for_project(
root.to_string_lossy().as_ref(),
&input.code,
cocos_editor_bridge::DEFAULT_COMMAND_TIMEOUT_MS,
) {
Ok(response) => response,
Err(error) => {
let uncertain = matches!(
&error,
cocos_editor_bridge::BridgeError::ExecutionUncertain(_)
);
return AgentRuntimeToolObservation {
tool: "cocos.editor.execute".to_string(),
status: if uncertain {
AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION
} else {
"failed"
}
.to_string(),
summary: redact_agent_runtime_project_paths(root, &error.to_string(), 240),
detail: Some(
serde_json::json!({
"codeChars": input.code.chars().count(),
"error": redact_agent_runtime_project_paths(root, &error.to_string(), 500),
})
.to_string(),
),
};
}
};
let ok = response.ok;
let detail = serde_json::json!({
"processId": response.process_id,
"requestId": response.request_id,
"ok": ok,
"result": response.result.clone(),
"error": response.error.clone(),
"codeChars": input.code.chars().count(),
})
.to_string();
AgentRuntimeToolObservation {
tool: "cocos.editor.execute".to_string(),
status: if ok { "ok" } else { "failed" }.to_string(),
summary: if ok {
format!(
"已在 Cocos Creator 执行 {} 字符代码",
input.code.chars().count()
)
} else {
"Cocos Creator execute 返回失败".to_string()
},
detail: Some(detail),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn execute_requires_durable_pending_action_before_touching_editor() {
let action = AgentRuntimeToolAction {
tool: "cocos.editor.execute".to_string(),
reason: Some("test".to_string()),
input: serde_json::json!({"code": "return 1 + 1;"}),
};
let observation =
observe_agent_runtime_cocos_editor_execute(Path::new("C:\\cocos"), &action, None);
assert_eq!(observation.status, "failed");
assert!(observation
.summary
.contains("必须绑定 durable pending action"));
}
}
@@ -231,8 +231,10 @@ fn native_runtime_function_name_for_tool(tool: &str) -> String {
)
}
fn build_agent_runtime_native_capability_registry() -> Result<CapabilityRegistry<String>, String> {
let definitions = agent_runtime_native_executable_tools()
fn build_agent_runtime_native_capability_registry(
tools: Vec<&'static str>,
) -> Result<CapabilityRegistry<String>, String> {
let definitions = tools
.into_iter()
.map(|tool| {
CapabilityDefinition::try_new(
@@ -251,9 +253,20 @@ fn build_agent_runtime_native_capability_registry() -> Result<CapabilityRegistry
fn agent_runtime_native_capability_registry() -> Result<&'static CapabilityRegistry<String>, String>
{
static REGISTRY: OnceLock<Result<CapabilityRegistry<String>, String>> = OnceLock::new();
REGISTRY
.get_or_init(build_agent_runtime_native_capability_registry)
// 内置插件开关会改变工具目录,因此按“可用 / 不可用”各缓存一份:切换后立即
// 生效,又不需要每次调用都重建 registry。
static ENABLED_REGISTRY: OnceLock<Result<CapabilityRegistry<String>, String>> = OnceLock::new();
static DISABLED_REGISTRY: OnceLock<Result<CapabilityRegistry<String>, String>> =
OnceLock::new();
// 缓存选择与构建消费同一份快照,避免开关变化污染另一份永久缓存。
let tools = agent_runtime_native_executable_tools();
let cache = if tools.contains(&crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME) {
&ENABLED_REGISTRY
} else {
&DISABLED_REGISTRY
};
cache
.get_or_init(|| build_agent_runtime_native_capability_registry(tools))
.as_ref()
.map_err(Clone::clone)
}
@@ -1027,6 +1040,9 @@ fn runtime_tool_description(tool: &str) -> &'static str {
"ui.workflow.run" => {
"先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-prototype 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。"
}
"cocos.editor.execute" => {
"在当前项目对应的已打开 Cocos Creator 编辑器中执行一段有界代码;Runtime 自动绑定唯一匹配的 Creator 主进程,代码与结果都通过注入 payload 的本机 bridge 返回。"
}
"blackboard.write" => "向项目级共享黑板追加稳定结论。",
"agent.message" => "向一个目标 Agent 写入定向上下文消息。",
"agent.delegate" => {
@@ -1216,6 +1232,14 @@ fn runtime_tool_input_schema(tool: &str) -> Value {
}
}),
"command.exec" | "command.start" => command_start_input_schema(),
"cocos.editor.execute" => json!({
"type": "object",
"required": ["code"],
"additionalProperties": false,
"properties": {
"code": { "type": "string", "minLength": 1, "maxLength": 131072 }
}
}),
"command.output_read" => json!({
"type": "object", "required": ["actionId", "startLine", "maxLines"], "additionalProperties": false,
"properties": {
@@ -0,0 +1,432 @@
//! 内置插件登记表与可用开关。
//!
//! 内置插件随 `plugins/` 工作区随包分发,用户不能卸载,只能控制是否可用。
//! 开关状态持久化在 AppData `extensions/builtin-plugins.json`,同时被两处消费:
//! 插件宿主(禁用后不能启动,状态显示为 disabled)和 Agent 工具目录(禁用后
//! 不出现在工具列表与 Agent 上下文里)。
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use serde::{Deserialize, Serialize};
/// Cocos Creator 编辑器插件的插件 id,与 `plugins/agc-cocos-editor/plugin.json` 一致。
pub(crate) const AGC_COCOS_EDITOR_PLUGIN_ID: &str = "agc-cocos-editor";
/// 该插件在 Agent 侧对应的 Runtime 工具名。
pub(crate) const AGC_COCOS_EDITOR_TOOL_NAME: &str = "cocos.editor.execute";
const STATE_FILE_NAME: &str = "builtin-plugins.json";
const STATE_SCHEMA_VERSION: &str = "agc.builtin-plugins.v1";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum BuiltinPlugin {
CocosEditor,
}
impl BuiltinPlugin {
pub(crate) fn id(self) -> &'static str {
match self {
Self::CocosEditor => AGC_COCOS_EDITOR_PLUGIN_ID,
}
}
/// 未持久化任何开关时的默认状态。
fn default_enabled(self) -> bool {
match self {
Self::CocosEditor => true,
}
}
/// 该插件是否向 Agent 暴露 Runtime 工具。
fn exposes_agent_tools(self) -> bool {
match self {
Self::CocosEditor => true,
}
}
}
pub(crate) const BUILTIN_PLUGINS: &[BuiltinPlugin] = &[BuiltinPlugin::CocosEditor];
pub(crate) fn builtin_plugin(id: &str) -> Option<BuiltinPlugin> {
BUILTIN_PLUGINS
.iter()
.copied()
.find(|plugin| plugin.id() == id.trim())
}
pub(crate) fn is_builtin(id: &str) -> bool {
builtin_plugin(id).is_some()
}
#[derive(Debug, Default)]
struct BuiltinPluginState {
path: Option<PathBuf>,
enabled: BTreeMap<String, bool>,
/// 开关文件不可读或格式不受支持时,内置插件全部按不可用处理。
fail_closed: bool,
}
static STATE: OnceLock<Mutex<BuiltinPluginState>> = OnceLock::new();
fn state() -> &'static Mutex<BuiltinPluginState> {
STATE.get_or_init(|| Mutex::new(BuiltinPluginState::default()))
}
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct BuiltinPluginStateFile {
#[serde(default)]
schema_version: Option<String>,
#[serde(default)]
enabled: BTreeMap<String, bool>,
}
/// 读取 AppData 里的开关状态;文件缺失按默认状态处理,坏文件失败关闭。
pub(crate) fn initialize(config_dir: &Path) -> Result<(), String> {
let root = config_dir.join("extensions");
let path = root.join(STATE_FILE_NAME);
if let Err(error) = fs::create_dir_all(&root) {
mark_fail_closed(Some(path));
return Err(format!("准备内置插件目录失败:{error}"));
}
let mut guard = state()
.lock()
.map_err(|_| "内置插件状态锁已损坏".to_string())?;
guard.path = Some(path.clone());
reload_state(&mut guard, &path)
}
fn read_state_file(path: &Path) -> Result<BuiltinPluginStateFile, String> {
match fs::read(path) {
Ok(bytes) => match serde_json::from_slice::<BuiltinPluginStateFile>(&bytes) {
Ok(file) if file.schema_version.as_deref() == Some(STATE_SCHEMA_VERSION) => Ok(file),
Ok(_) => Err(format!(
"内置插件开关文件版本不受支持,需要 {STATE_SCHEMA_VERSION}"
)),
Err(error) => Err(format!("内置插件开关文件无效:{error}")),
},
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
Ok(BuiltinPluginStateFile::default())
}
Err(error) => Err(format!("读取内置插件开关失败:{error}")),
}
}
fn reload_state(guard: &mut BuiltinPluginState, path: &Path) -> Result<(), String> {
let loaded = match read_state_file(path) {
Ok(loaded) => loaded,
Err(error) => {
guard.fail_closed = true;
guard.enabled = BUILTIN_PLUGINS
.iter()
.map(|plugin| (plugin.id().to_string(), false))
.collect();
return Err(error);
}
};
guard.fail_closed = false;
// 只接受登记表里的 id,避免坏文件把未知对象带进运行时。
guard.enabled = loaded
.enabled
.into_iter()
.filter(|(id, _)| is_builtin(id))
.collect();
Ok(())
}
fn mark_fail_closed(path: Option<PathBuf>) {
if let Ok(mut guard) = state().lock() {
guard.path = path;
guard.fail_closed = true;
guard.enabled = BUILTIN_PLUGINS
.iter()
.map(|plugin| (plugin.id().to_string(), false))
.collect();
}
}
pub(crate) fn is_enabled(id: &str) -> bool {
let Some(plugin) = builtin_plugin(id) else {
return false;
};
// Runner/CLI 已绑定配置根,但不会执行 GUI setup;不推断隔离子进程的 AppData。
let config_dir = crate::game_creator_runtime_config_dir_lock()
.lock()
.ok()
.and_then(|path| path.clone());
state()
.lock()
.map(|mut guard| {
let Some(path) = guard
.path
.clone()
.or_else(|| config_dir.map(|root| root.join("extensions").join(STATE_FILE_NAME)))
else {
return false;
};
// 每次查询读取持久化权威,使运行中的其它进程立即感知开关变化。
let _ = reload_state(&mut guard, &path);
if guard.fail_closed {
return false;
}
guard
.enabled
.get(plugin.id())
.copied()
.unwrap_or_else(|| plugin.default_enabled())
})
// 状态锁损坏时同样 fail-closed,避免异常状态重新放开内置能力。
.unwrap_or(false)
}
/// 内置插件的用户开关;`None` 表示该 id 不是内置插件,由来源自己决定启用状态。
pub(crate) fn toggle_state(id: &str) -> Option<bool> {
builtin_plugin(id).map(|_| is_enabled(id))
}
pub(crate) fn set_enabled(id: &str, enabled: bool) -> Result<bool, String> {
let Some(plugin) = builtin_plugin(id) else {
return Err(format!("{id} 不是内置插件,不能使用内置插件开关"));
};
let mut guard = state()
.lock()
.map_err(|_| "内置插件状态锁已损坏".to_string())?;
let path = guard
.path
.clone()
.ok_or_else(|| "内置插件开关尚未初始化".to_string())?;
// 保留其它进程刚写入的开关;损坏文件按全部禁用起步,允许用户显式修复。
let _ = reload_state(&mut guard, &path);
let previous = guard.enabled.get(plugin.id()).copied();
guard.enabled.insert(plugin.id().to_string(), enabled);
if let Err(error) = persist(&guard) {
match previous {
Some(value) => {
guard.enabled.insert(plugin.id().to_string(), value);
}
None => {
guard.enabled.remove(plugin.id());
}
}
return Err(error);
}
guard.fail_closed = false;
Ok(enabled)
}
fn persist(guard: &BuiltinPluginState) -> Result<(), String> {
let path = guard
.path
.clone()
.ok_or_else(|| "内置插件开关尚未初始化".to_string())?;
let file = BuiltinPluginStateFile {
schema_version: Some(STATE_SCHEMA_VERSION.to_string()),
enabled: guard.enabled.clone(),
};
let bytes = serde_json::to_vec_pretty(&file)
.map_err(|error| format!("序列化内置插件开关失败:{error}"))?;
let temporary = path.with_extension("json.tmp");
fs::write(&temporary, bytes).map_err(|error| format!("写入内置插件开关失败:{error}"))?;
fs::rename(&temporary, &path).map_err(|error| format!("提交内置插件开关失败:{error}"))?;
Ok(())
}
/// Agent 工具面是否可用:编译期 feature 打开且用户没有禁用该内置插件。
pub(crate) fn agent_tool_available(plugin: BuiltinPlugin) -> bool {
plugin.exposes_agent_tools()
&& cfg!(feature = "cocos-editor-execute")
&& is_enabled(plugin.id())
}
pub(crate) fn cocos_editor_agent_tool_available() -> bool {
agent_tool_available(BuiltinPlugin::CocosEditor)
}
pub(crate) fn available_agent_tools() -> Vec<&'static str> {
if cocos_editor_agent_tool_available() {
vec![AGC_COCOS_EDITOR_TOOL_NAME]
} else {
Vec::new()
}
}
#[cfg(test)]
pub(crate) use tests::test_lock;
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
pub(crate) fn test_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|error| error.into_inner())
}
#[test]
fn builtin_plugins_default_to_enabled_and_reject_unknown_ids() {
let _guard = test_lock();
let directory = tempdir().expect("temp config");
initialize(directory.path()).expect("initialize");
assert!(is_builtin(AGC_COCOS_EDITOR_PLUGIN_ID));
assert!(is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
assert_eq!(toggle_state(AGC_COCOS_EDITOR_PLUGIN_ID), Some(true));
assert_eq!(toggle_state("imported-plugin"), None);
assert!(!is_enabled("imported-plugin"));
assert!(set_enabled("imported-plugin", false).is_err());
}
#[test]
fn toggle_state_round_trips_through_appdata_file() {
let _guard = test_lock();
let directory = tempdir().expect("temp config");
initialize(directory.path()).expect("initialize");
assert_eq!(
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, false).expect("disable"),
false
);
let path = directory.path().join("extensions").join(STATE_FILE_NAME);
let written = fs::read_to_string(&path).expect("state file");
assert!(written.contains(STATE_SCHEMA_VERSION));
assert!(written.contains(AGC_COCOS_EDITOR_PLUGIN_ID));
// 重新初始化模拟下次启动读取持久化结果。
initialize(directory.path()).expect("re-initialize");
assert!(!is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).expect("enable");
assert!(is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
}
#[test]
fn corrupt_state_file_fails_closed() {
let _guard = test_lock();
let directory = tempdir().expect("temp config");
let root = directory.path().join("extensions");
fs::create_dir_all(&root).expect("extensions dir");
fs::write(root.join(STATE_FILE_NAME), "{ not json").expect("write corrupt state");
assert!(initialize(directory.path()).is_err());
assert!(!is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
assert_eq!(toggle_state(AGC_COCOS_EDITOR_PLUGIN_ID), Some(false));
}
#[test]
fn enabling_after_corruption_recovers_without_reinitializing() {
let _guard = test_lock();
let directory = tempdir().unwrap();
fs::create_dir_all(directory.path().join("extensions")).unwrap();
fs::write(
directory.path().join("extensions/builtin-plugins.json"),
"{",
)
.unwrap();
assert!(initialize(directory.path()).is_err());
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).unwrap();
assert!(is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
}
#[test]
fn availability_reloads_changes_written_by_another_process() {
let _guard = test_lock();
let directory = tempdir().unwrap();
initialize(directory.path()).unwrap();
let path = directory.path().join("extensions/builtin-plugins.json");
for enabled in [false, true, false] {
fs::write(
&path,
serde_json::json!({
"schemaVersion": STATE_SCHEMA_VERSION,
"enabled": {AGC_COCOS_EDITOR_PLUGIN_ID: enabled}
})
.to_string(),
)
.unwrap();
assert_eq!(is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID), enabled);
}
fs::write(&path, "{").unwrap();
assert!(!is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
}
#[test]
fn runner_process_reads_disabled_state_without_gui_setup() {
let _guard = test_lock();
let directory = tempdir().unwrap();
initialize(directory.path()).unwrap();
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, false).unwrap();
let result = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"builtin_plugins::tests::runner_process_probe",
"--nocapture",
])
.env("AGC_BUILTIN_TEST_CONFIG_DIR", directory.path())
.output()
.unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stdout)
);
assert!(String::from_utf8_lossy(&result.stdout).contains("1 passed"));
}
#[test]
fn runner_process_probe() {
let Some(config_dir) = std::env::var_os("AGC_BUILTIN_TEST_CONFIG_DIR") else {
return;
};
// Runner/CLI 只绑定配置根目录,不进入 Tauri GUI setup。
crate::set_game_creator_runtime_config_dir(PathBuf::from(config_dir));
assert!(!is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
}
#[test]
fn unsupported_schema_fails_closed() {
let _guard = test_lock();
let directory = tempdir().expect("temp config");
let root = directory.path().join("extensions");
fs::create_dir_all(&root).expect("extensions dir");
fs::write(
root.join(STATE_FILE_NAME),
serde_json::json!({"schemaVersion": "agc.builtin-plugins.v0", "enabled": {}})
.to_string(),
)
.expect("write unsupported state");
assert!(initialize(directory.path()).is_err());
assert!(!is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
}
#[test]
fn agent_tool_visibility_follows_the_toggle() {
let _guard = test_lock();
let directory = tempdir().expect("temp config");
initialize(directory.path()).expect("initialize");
let tool_visible_when_enabled = cfg!(feature = "cocos-editor-execute");
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).expect("enable");
assert_eq!(
cocos_editor_agent_tool_available(),
tool_visible_when_enabled
);
assert_eq!(
crate::agent::agent_runtime_executable_tools().contains(&AGC_COCOS_EDITOR_TOOL_NAME),
tool_visible_when_enabled
);
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, false).expect("disable");
assert!(!cocos_editor_agent_tool_available());
assert!(
!crate::agent::agent_runtime_executable_tools().contains(&AGC_COCOS_EDITOR_TOOL_NAME)
);
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).expect("re-enable");
assert_eq!(
crate::agent::agent_runtime_executable_tools().contains(&AGC_COCOS_EDITOR_TOOL_NAME),
tool_visible_when_enabled
);
}
}

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