接入 DotCraft Unity 编辑器插件
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 6m41s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 7m8s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m16s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 7m38s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m11s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m11s
Project CI / Repository checks (pull_request) Successful in 4m22s
Project CI / Frontend tests (pull_request) Successful in 5m52s
Project CI / Native shell tests (pull_request) Successful in 9m52s
Project CI / Backend tests (pull_request) Successful in 11m3s
Project CI / AI game creator shell web tests (pull_request) Successful in 6m41s

新增内置 Unity 插件与自包含 Attach helper,固定上游版本并保留许可证
统一 Runner 执行归属、项目身份校验、回执确认和不确定结果阻断
接入 Unity 工程导入、Agent 工具目录及 Windows 构建分发
补齐插件测试、CI 执行入口和 Windows .NET 10 工具链检查
记录真实 Unity 连接、执行、重载恢复及 Runner 边界验证
This commit is contained in:
kdletters
2026-09-18 19:39:10 +08:00
parent 9a21690fe9
commit 3fc3399032
110 changed files with 8610 additions and 214 deletions
+22 -4
View File
@@ -221,8 +221,7 @@ jobs:
- name: Run AI game creator shell agent-run smoke
run: npm run check:native-shells:agc-rust-smoke
# AGC 壳依赖的共享 / 平台 crate 测试用的是 server-rs workspace 与两个无锁独立 crate
# 的 manifest,属另一套依赖图,因此单独一个 job 预热、单独跑。
# AGC 壳依赖的共享 / 平台和编辑器插件 crate 各自预热独立 manifest,再运行对应测试。
ai-game-creator-shell-rust-crates:
name: AI game creator shell Rust crates
runs-on: genarrative-ci
@@ -263,13 +262,15 @@ jobs:
# `cargo test --manifest-path` 单独跑这两个 crate。不在这里预热的话,这两条测试
# 会在测试阶段自己 `Updating crates.io index`crates.io 一抖动整条 job 就红
# (见 #327 / PR #316 run 1950)。
# 两个 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch:
# Cocos 插件也使用独立且未提交的锁文件,一并预热。
# 这些 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
server-rs/crates/agent-runtime-orchestration/Cargo.toml \
plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml; do
for attempt in $(seq 1 5); do
if cargo fetch \
--target x86_64-unknown-linux-gnu \
@@ -284,6 +285,23 @@ jobs:
done
done
- name: Prepare Unity plugin Rust dependencies
shell: bash
run: |
set -euo pipefail
for attempt in $(seq 1 5); do
if cargo fetch --locked \
--target x86_64-unknown-linux-gnu \
--manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml; then
break
fi
if [[ "${attempt}" -eq 5 ]]; then
echo 'Unity plugin Cargo dependency fetch failed after 5 attempts.' >&2
exit 1
fi
sleep $((attempt * 2))
done
- name: Run AI game creator shell shared crate gates
run: npm run check:native-shells:agc-rust-crates
+4
View File
@@ -48,6 +48,10 @@ temp*build*/
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/manifest.json
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/NOTICE.md
/plugins/agc-cocos-editor/native/payload/
/plugins/agc-unity-editor/dotnet/**/bin/
/plugins/agc-unity-editor/dotnet/**/obj/
/plugins/agc-unity-editor/dotnet/publish/
/plugins/agc-unity-editor/dotnet/native-build/
/apps/ai-game-creator-shell/logs/
/apps/ai-game-creator-shell/.llm-drafts/
/apps/ai-game-creator-shell/game-creator.config.local.json
@@ -105,6 +105,7 @@ export const agcReleasePathPatterns = [
'packages/',
'server-rs/crates/',
'plugins/agc-cocos-editor/',
'plugins/agc-unity-editor/',
'apps/desktop-shell/src-tauri/icons/',
'package.json',
'package-lock.json',
@@ -344,7 +344,11 @@ test('Windows remains the default and explicit Windows overrides macOS environme
context,
{
spawn: (_binary, command) => {
assert.ok(command.includes('--features=cocos-editor-execute'));
assert.ok(
command.includes(
'--features=cocos-editor-execute,unity-editor-execute',
),
);
assert.ok(command.includes('user-config.json'));
const configIndex = command.lastIndexOf('--config');
const config = JSON.parse(
@@ -19,6 +19,6 @@ export function withDefaultCargoFeatures(argv, features) {
export function defaultEditorFeatures(target) {
return target === 'win32' || target.includes('windows')
? ['cocos-editor-execute']
? ['cocos-editor-execute', 'unity-editor-execute']
: [];
}
@@ -9,7 +9,7 @@ test('Windows release includes the same editor feature as development', () => {
buildTauriBuildArguments([], 'x86_64-pc-windows-msvc', 'win32'),
[
'build',
'--features=cocos-editor-execute',
'--features=cocos-editor-execute,unity-editor-execute',
'--target',
'x86_64-pc-windows-msvc',
],
@@ -143,7 +143,6 @@ const allowedUncalledTauriCommands = [
'reload_agc_plugin',
'call_agc_plugin',
'read_agc_plugin_panel',
'set_agc_plugin_project_path',
'set_agc_plugin_enabled',
];
const sourceExtensions = new Set([
@@ -1,7 +1,10 @@
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { withDefaultCargoFeatures } from './cargo-features.mjs';
import {
defaultEditorFeatures,
withDefaultCargoFeatures,
} from './cargo-features.mjs';
import {
readAgcDevEndpoint,
resolveAgcDevEndpoint,
@@ -47,9 +50,8 @@ function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) {
];
}
// `agc_cocos_execute` 与 Cocos 编辑器适配器只在 `cocos-editor-execute` feature
// 注册。开发构建默认在 Windows 打开它,否则 Agent 的工具清单里根本没有该工具,
// 只能退化成改写脚本。可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭。
// 开发和发行构建使用同一平台编辑器 feature 集合。
// 可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭。
function readDevCargoFeatures(env = process.env) {
const override = env.AGC_DEV_CARGO_FEATURES;
if (override !== undefined) {
@@ -58,7 +60,7 @@ function readDevCargoFeatures(env = process.env) {
.map((value) => value.trim())
.filter(Boolean);
}
return process.platform === 'win32' ? ['cocos-editor-execute'] : [];
return defaultEditorFeatures(process.platform);
}
function withDevCargoFeatures(argv, features = readDevCargoFeatures()) {
+11
View File
@@ -1793,6 +1793,7 @@ dependencies = [
"ttf-parser",
"typed_floats",
"unicode-normalization",
"unity-editor-bridge",
"url",
"uuid",
"windows-sys 0.61.2",
@@ -6407,6 +6408,16 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unity-editor-bridge"
version = "0.1.0"
dependencies = [
"editor-adapter-api",
"serde",
"serde_json",
"windows-sys 0.61.2",
]
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
@@ -12,6 +12,7 @@ template-library-fixtures = []
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"]
unity-editor-execute = []
[build-dependencies]
serde = { version = "1", features = ["derive"] }
@@ -27,6 +28,7 @@ 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" }
unity-editor-bridge = { path = "../../../plugins/agc-unity-editor/native/unity-editor-bridge" }
base64 = "0.22"
axum = "0.8"
chromiumoxide = "0.9.1"
+109 -1
View File
@@ -196,6 +196,7 @@ fn main() {
);
let manifest_path = manifest_dir.join("prompts/runtime/manifest.json");
stage_bundled_codex_cli(&manifest_dir);
prepare_unity_editor_helper(&manifest_dir);
stage_plugin_workspace(&manifest_dir);
stage_cocos_editor_payload(&manifest_dir);
let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path)
@@ -266,6 +267,107 @@ fn stage_cocos_editor_payload(manifest_dir: &std::path::Path) {
#[cfg(not(windows))]
fn stage_cocos_editor_payload(_manifest_dir: &std::path::Path) {}
/// Unity helper 是插件的随包运行文件。内容指纹避免每次 Cargo 检查都重新发布 .NET。
fn prepare_unity_editor_helper(manifest_dir: &std::path::Path) {
println!("cargo:rerun-if-env-changed=CARGO_FEATURE_UNITY_EDITOR_EXECUTE");
let target = env::var("TARGET").expect("Cargo TARGET");
if env::var_os("CARGO_FEATURE_UNITY_EDITOR_EXECUTE").is_none()
|| target != "x86_64-pc-windows-msvc"
{
return;
}
let root = manifest_dir.join("../../../plugins/agc-unity-editor/dotnet");
let mut sources = Vec::new();
collect_unity_helper_sources(&root, &mut sources);
sources.sort();
let mut fingerprint = Sha256::new();
for source in &sources {
println!("cargo:rerun-if-changed={}", source.display());
fingerprint.update(
source
.strip_prefix(&root)
.expect("helper source")
.to_string_lossy()
.as_bytes(),
);
fingerprint.update([0]);
fingerprint.update(fs::read(source).expect("读取 Unity helper 源文件失败"));
}
let fingerprint = format!("{:x}", fingerprint.finalize());
let publish = root.join("publish/win-x64");
let executable = publish.join("Agc.Unity.Attach.exe");
let stamp = publish.join(".agc-source.sha256");
println!("cargo:rerun-if-changed={}", executable.display());
if unity_helper_publish_complete(&publish)
&& fs::read_to_string(&stamp).ok().as_deref() == Some(&fingerprint)
{
return;
}
assert!(
cfg!(windows),
"构建 Unity 插件 helper 需要 Windows .NET 10 与 x64 C++ 工具链"
);
let status = std::process::Command::new("powershell.exe")
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
])
.arg(root.join("build.ps1"))
.current_dir(&root)
.status()
.expect("无法启动 Unity helper 构建脚本");
assert!(
status.success() && unity_helper_publish_complete(&publish),
"Unity helper 构建失败或缺少运行文件/许可"
);
fs::write(stamp, fingerprint).expect("写入 Unity helper 构建指纹失败");
}
fn unity_helper_publish_complete(publish: &std::path::Path) -> bool {
[
"Agc.Unity.Attach.exe",
"NOTICE",
"THIRD-PARTY-NOTICES.txt",
"licenses/DotCraft-Apache-2.0.txt",
"licenses/Roslyn-MIT.txt",
"licenses/upstream.json",
"licenses/dotnet-LICENSE.TXT",
"licenses/dotnet-THIRD-PARTY-NOTICES.TXT",
"licenses/microsoft.codeanalysis.common-ThirdPartyNotices.rtf",
"licenses/microsoft.codeanalysis.csharp-ThirdPartyNotices.rtf",
]
.iter()
.all(|name| {
fs::symlink_metadata(publish.join(name)).is_ok_and(|metadata| {
metadata.is_file() && !metadata.file_type().is_symlink() && metadata.len() > 0
})
})
}
fn collect_unity_helper_sources(root: &std::path::Path, sources: &mut Vec<PathBuf>) {
for entry in fs::read_dir(root)
.expect("Unity helper 源码目录缺失")
.flatten()
{
let kind = entry.file_type().expect("读取 Unity helper 源文件类型失败");
assert!(!kind.is_symlink(), "Unity helper 源码不允许符号链接");
let name = entry.file_name();
if kind.is_dir() {
if !matches!(
name.to_str(),
Some("bin" | "obj" | "publish" | "native-build")
) {
collect_unity_helper_sources(&entry.path(), sources);
}
} else if kind.is_file() {
sources.push(entry.path());
}
}
}
/// 把 `plugins/` 工作区里的插件包随包映射到应用资源目录。
///
/// 只复制插件运行需要的清单、入口、面板和 native payload,不复制 native 源码、
@@ -313,9 +415,15 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) {
for relative in [
std::path::PathBuf::from("src"),
std::path::PathBuf::from("panels"),
std::path::PathBuf::from("skills"),
std::path::PathBuf::from("native/payload"),
std::path::PathBuf::from("dotnet/publish/win-x64"),
] {
if relative == std::path::Path::new("native/payload") && !target.contains("windows") {
if (relative == std::path::Path::new("native/payload") && !target.contains("windows"))
|| (relative == std::path::Path::new("dotnet/publish/win-x64")
&& (target != "x86_64-pc-windows-msvc"
|| env::var_os("CARGO_FEATURE_UNITY_EDITOR_EXECUTE").is_none()))
{
continue;
}
copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative));
File diff suppressed because one or more lines are too long
@@ -2592,6 +2592,51 @@ async fn bridge_web_search_at(root: &Path, arguments: &Value, search_url: &str)
}
}
#[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))]
async fn bridge_unity_execute(state: &DirectToolBridgeState, arguments: &Value) -> Value {
let prepared = (|| {
if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(&state.root) {
return Err("当前项目不是 Unity 项目或 Unity 插件不可用".to_string());
}
enforce_project_permission_policy(&state.root, "unity.editor.execute")?;
bridge_reject_unknown_fields(arguments, &["code"])?;
let code = arguments
.get("code")
.and_then(Value::as_str)
.ok_or_else(|| "code 必须是 C# 代码".to_string())?;
if code.trim().is_empty() || code.len() > 131072 || code.contains('\0') {
return Err("code 不能为空、包含 NUL 或超过 128 KiB".to_string());
}
Ok(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 root = state.root.clone();
let result = tokio::task::spawn_blocking(move || {
if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(&root) {
return Err("当前 Unity 插件不可用".to_string());
}
crate::editor_adapters::execute_unity_editor_code(&root, &code)
})
.await;
match result {
Ok(Ok(response)) => {
let failed = response["ok"] != true || response["status"] != "completed";
bridge_tool_result(redact_agent_runtime_error(&state.root, &response.to_string(), 32_000), Vec::new(), failed)
}
Ok(Err(error)) => bridge_tool_result(redact_agent_runtime_error(&state.root, &error, 480), Vec::new(), true),
Err(_) => bridge_tool_result(json!({"ok":false,"status":"needs-reconciliation","retryAllowed":false,"error":"Unity 执行任务异常,请人工核对结果"}).to_string(), Vec::new(), true),
}
}
#[cfg(all(windows, feature = "cocos-editor-execute"))]
async fn bridge_cocos_execute(state: &DirectToolBridgeState, arguments: &Value) -> Value {
bridge_cocos_call(state, arguments, None).await
@@ -2603,6 +2648,9 @@ async fn bridge_cocos_call(
arguments: &Value,
operation: Option<&str>,
) -> Value {
if !crate::builtin_plugins::is_enabled(crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID) {
return bridge_tool_result("Cocos 编辑器插件已禁用".to_string(), Vec::new(), true);
}
if !crate::builtin_plugins::cocos_editor_agent_tool_available_for_project(&state.root) {
return bridge_tool_result(
"当前项目不是 Cocos Creator 项目或 Cocos 插件不可用,agc_cocos_execute 不可用"
@@ -2789,6 +2837,8 @@ async fn handle_direct_tool_bridge(
"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, target_arch = "x86_64", feature = "unity-editor-execute"))]
"agc_unity_execute" => bridge_unity_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
@@ -75,7 +75,14 @@ pub(crate) fn run_direct_tools_mcp_if_requested(args: &[String]) -> Option<i32>
async fn direct_tools_mcp_specs() -> Value {
let mut cocos_editor_available = false;
if cfg!(all(windows, feature = "cocos-editor-execute")) {
let mut unity_editor_available = false;
if cfg!(all(windows, feature = "cocos-editor-execute"))
|| cfg!(all(
windows,
target_arch = "x86_64",
feature = "unity-editor-execute"
))
{
// 每次 tools/list 询问绑定的宿主;失败时不广告可选插件工具。
if let Ok(result) = tokio::time::timeout(
std::time::Duration::from_secs(5),
@@ -96,10 +103,22 @@ async fn direct_tools_mcp_specs() -> Value {
.iter()
.any(|tool| tool == crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME)
});
unity_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_UNITY_EDITOR_TOOL_NAME)
});
}
}
}
direct_tools_mcp_specs_for(controlled_web_search_enabled(), cocos_editor_available)
direct_tools_mcp_specs_for_plugins(
controlled_web_search_enabled(),
cocos_editor_available,
unity_editor_available,
)
}
/// 工具 kind(wire 值)对应的提示词上限。
@@ -128,7 +147,16 @@ fn resource_tool_prompt_schema_max_chars() -> usize {
.unwrap_or(0)
}
fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_available: bool) -> Value {
#[cfg(test)]
fn direct_tools_mcp_specs_for(controlled_web_search: bool, cocos_editor_available: bool) -> Value {
direct_tools_mcp_specs_for_plugins(controlled_web_search, cocos_editor_available, false)
}
fn direct_tools_mcp_specs_for_plugins(
controlled_web_search: bool,
_cocos_editor_available: bool,
_unity_editor_available: bool,
) -> Value {
let tools = vec![
json!({
"name": "client.session.info",
@@ -568,6 +596,14 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
.cloned(),
);
}
#[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))]
if _unity_editor_available {
tools.push(json!({
"name": "agc_unity_execute",
"description": "在当前项目已打开的 Windows x64 Unity Mono Editor 执行 C#,可使用 return 返回值。仅提交 code;宿主绑定项目及进程。needs-reconciliation 或超时后禁止自动重发。",
"inputSchema": {"type":"object", "properties":{"code":{"type":"string", "minLength":1, "maxLength":131072}}, "required":["code"], "additionalProperties":false}
}));
}
if controlled_web_search {
tools.push(json!({
"name": "agc_web_search",
@@ -655,6 +691,24 @@ async fn call_agc_cocos_execute(arguments: &Value) -> Value {
call_client_tool_bridge("agc_cocos_execute", arguments).await
}
#[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))]
async fn call_agc_unity_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 必须是 C# 代码".to_string())?;
if code.trim().is_empty() || code.len() > 131072 || code.contains('\0') {
return Err("code 不能为空、包含 NUL 或超过 128 KiB".to_string());
}
Ok(())
});
if let Err(error) = validated {
return mcp_tool_result(error, Vec::new(), true);
}
call_client_tool_bridge("agc_unity_execute", arguments).await
}
fn mcp_success(id: Value, result: Value) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "result": result })
}
@@ -1757,6 +1811,8 @@ async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option<
"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, target_arch = "x86_64", feature = "unity-editor-execute"))]
"agc_unity_execute" => call_agc_unity_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
@@ -1620,7 +1620,7 @@ 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!(
"cocos.editor.execute" | "unity.editor.execute" => format!(
"codeChars={} · codeSha256={:x}",
chars(&["code"]),
Sha256::digest(
@@ -383,6 +383,16 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
true,
|| observe_agent_runtime_cocos_editor_execute(root, action, pending_action),
),
"unity.editor.execute" => observe_agent_runtime_project_snapshot_with_lock(
root,
agent_id,
run_id,
action,
&action_fingerprint,
pending_action,
true,
|| observe_agent_runtime_unity_editor_execute(root, action, pending_action),
),
"preview.validate" => {
observe_agent_runtime_preview_validate(
root,
@@ -97,6 +97,7 @@ pub(in crate::agent) fn game_creator_agent_runtime_tool_command_id(
"command.run_limited" => Some("command.run_limited"),
#[cfg(feature = "cocos-editor-execute")]
"cocos.editor.execute" => Some("cocos.editor.execute"),
"unity.editor.execute" => Some("unity.editor.execute"),
"preview.start" => Some("preview.start"),
"preview.validate" => Some("preview.validate"),
"image.inspect" => Some("image.inspect"),
@@ -247,7 +247,10 @@ fn build_game_creator_agent_background_tool_plan_request_at(
let relaxed_prompt = format!(
"你正在执行一个自主游戏构建任务。请按自己的判断规划并直接调用当前广告的原生工具完成目标;任务可以与其它 Agent 并行,依赖只作为参考,不要等待或索要平台资产/验收回执。已有观察只代表已发生的事实,完成后直接调用 respond_to_user。\n\n运行上下文:\n{context}\n\n任务:\n{effective_task}\n\n已有观察:\n{observations_json}"
);
let mut function_tools = build_agent_runtime_native_function_tools_for_agent(agent_id)?;
let mut function_tools =
crate::agent_native_tools::build_agent_runtime_native_function_tools_for_project(
root, agent_id,
)?;
remove_relaxed_autonomous_platform_validation_tools(&mut function_tools)?;
// Platform-backed generation remains an optional capability. A
// relaxed run may proceed with all ordinary project tools when no
@@ -479,9 +482,11 @@ fn build_game_creator_agent_background_tool_plan_request_at(
.with_api_kind(api_kind)
.with_max_output_tokens(AGENT_RUNTIME_TOOL_PLAN_MAX_OUTPUT_TOKENS)
.with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low)
.with_function_tools(build_agent_runtime_native_function_tools_for_agent(
agent_id,
)?)
.with_function_tools(
crate::agent_native_tools::build_agent_runtime_native_function_tools_for_project(
root, agent_id,
)?,
)
.with_tool_choice(platform_llm::LlmToolChoice::Required);
if runtime_owner_artifact_validation_available {
remove_autonomous_owner_manual_verification_tools(&mut request.function_tools)?;
@@ -969,7 +969,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
|| force_autonomous_pre_mutation
{
request.function_tools =
build_agent_runtime_native_function_tools_for_agent(agent_id)?;
crate::agent_native_tools::build_agent_runtime_native_function_tools_for_project(root, agent_id)?;
if runtime_owner_artifact_validation_available {
remove_autonomous_owner_manual_verification_tools(
&mut request.function_tools,
@@ -70,6 +70,9 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> {
if crate::builtin_plugins::cocos_editor_agent_tool_available() {
tools.push(crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME);
}
if crate::builtin_plugins::unity_editor_agent_tool_available() {
tools.push(crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME);
}
tools
}
@@ -154,6 +157,11 @@ pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at(
let mut confirm_tools = Vec::new();
let mut denied_tools = Vec::new();
for tool in agent_runtime_executable_tools() {
if tool == crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME
&& !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(root)
{
continue;
}
if isolated && ISOLATED_AGENT_UNSCOPED_DENIED_TOOLS.contains(&tool) {
denied_tools.push(tool.to_string());
continue;
@@ -196,6 +204,10 @@ pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at(
run_profile_binding_fingerprint: String::new(),
allowed_tools: agent_runtime_executable_tools()
.into_iter()
.filter(|tool| {
*tool != crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME
|| crate::builtin_plugins::unity_editor_agent_tool_available_for_project(root)
})
.map(str::to_string)
.collect(),
auto_tools,
@@ -11,6 +11,7 @@ mod goal_contract;
mod helpers;
mod isolated_joins;
mod media;
mod unity_editor;
pub(in crate::agent) use media::design_foundation_ui_page_output_path_is_valid;
mod policy;
mod preview;
@@ -38,6 +39,7 @@ pub(in crate::agent) use project_ops::*;
pub(in crate::agent) use run_status::*;
pub(in crate::agent) use task_ops::*;
pub(in crate::agent) use ui_workflow::*;
pub(in crate::agent) use unity_editor::*;
#[cfg(test)]
pub(crate) use delegation::observe_agent_runtime_agent_delegate_at_locked;
@@ -0,0 +1,79 @@
use super::*;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct UnityEditorExecuteInput {
code: String,
}
pub(in crate::agent) fn observe_agent_runtime_unity_editor_execute(
root: &Path,
action: &AgentRuntimeToolAction,
pending_action: Option<&AgentRuntimePendingToolAction>,
) -> AgentRuntimeToolObservation {
let execution = (|| {
let input: UnityEditorExecuteInput = serde_json::from_value(action.input.clone())
.map_err(|error| format!("unity.editor.execute 输入无效:{error}"))?;
if pending_action.is_none() {
return Err("unity.editor.execute 必须绑定 durable pending action".to_string());
}
if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(root) {
return Err("当前项目不是 Unity 项目或 Unity 插件不可用".to_string());
}
crate::editor_adapters::execute_unity_editor_code(root, &input.code)
})();
match execution {
Ok(response) => {
let status = match response["status"].as_str() {
Some("completed") if response["ok"] == true => "ok",
Some("needs-reconciliation") => {
AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION
}
_ => "failed",
};
AgentRuntimeToolObservation {
tool: "unity.editor.execute".to_string(),
status: status.to_string(),
summary: match status {
"ok" => "Unity 编辑器已返回执行成功回执",
"needs-reconciliation" => "Unity 执行结果待人工核对,禁止自动重发",
_ => "Unity 编辑器执行失败",
}
.to_string(),
detail: Some(redact_agent_runtime_project_paths(
root,
&response.to_string(),
32_000,
)),
}
}
Err(error) => AgentRuntimeToolObservation {
tool: "unity.editor.execute".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 480),
detail: None,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unity_execute_requires_pending_action_and_rejects_project_override() {
for input in [
serde_json::json!({"code":"return 2;"}),
serde_json::json!({"code":"return 2;", "projectPath":"C:/other"}),
] {
let action = AgentRuntimeToolAction {
tool: "unity.editor.execute".to_string(),
reason: None,
input,
};
let observation =
observe_agent_runtime_unity_editor_execute(Path::new("C:/unity"), &action, None);
assert_eq!(observation.status, "failed");
}
}
}
@@ -253,18 +253,13 @@ fn build_agent_runtime_native_capability_registry(
fn agent_runtime_native_capability_registry() -> Result<&'static CapabilityRegistry<String>, String>
{
// 内置插件开关会改变工具目录,因此按“可用 / 不可用”各缓存一份:切换后立即
// 生效,又不需要每次调用都重建 registry。
static ENABLED_REGISTRY: OnceLock<Result<CapabilityRegistry<String>, String>> = OnceLock::new();
static DISABLED_REGISTRY: OnceLock<Result<CapabilityRegistry<String>, String>> =
OnceLock::new();
// 缓存选择与构建消费同一份快照,避免开关变化污染另一份永久缓存。
// 两个独立开关产生四份目录,使用同一快照选缓存并构建。
static REGISTRIES: [OnceLock<Result<CapabilityRegistry<String>, String>>; 4] =
[const { OnceLock::new() }; 4];
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
};
let index = usize::from(tools.contains(&crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME))
| (usize::from(tools.contains(&crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME)) << 1);
let cache = &REGISTRIES[index];
cache
.get_or_init(|| build_agent_runtime_native_capability_registry(tools))
.as_ref()
@@ -311,6 +306,18 @@ pub(crate) fn build_agent_runtime_native_function_tools_for_agent(
Ok(functions)
}
pub(crate) fn build_agent_runtime_native_function_tools_for_project(
root: &std::path::Path,
agent_id: &str,
) -> Result<Vec<LlmFunctionTool>, String> {
let mut tools = build_agent_runtime_native_function_tools_for_agent(agent_id)?;
if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(root) {
let name = native_runtime_function_name_for_tool("unity.editor.execute");
tools.retain(|tool| tool.name != name);
}
Ok(tools)
}
pub(crate) fn agent_runtime_native_tool_allowed_for_agent(tool: &str) -> bool {
agent_runtime_native_capability_registry()
.ok()
@@ -1044,6 +1051,7 @@ fn runtime_tool_description(tool: &str) -> &'static str {
"cocos.editor.execute" => {
"在当前项目对应的已打开 Cocos Creator 编辑器中执行一段有界代码;Runtime 自动绑定唯一匹配的 Creator 主进程,代码与结果都通过注入 payload 的本机 bridge 返回。"
}
"unity.editor.execute" => "在当前 Unity 项目已打开的 Windows x64 Mono Editor 中执行 C#。仅提交 code,宿主绑定项目身份;结果待核对时禁止自动重发。",
"blackboard.write" => "向项目级共享黑板追加稳定结论。",
"agent.message" => "向一个目标 Agent 写入定向上下文消息。",
"agent.delegate" => {
@@ -1241,7 +1249,7 @@ fn runtime_tool_input_schema(tool: &str) -> Value {
}
}),
"command.exec" | "command.start" => command_start_input_schema(),
"cocos.editor.execute" => json!({
"cocos.editor.execute" | "unity.editor.execute" => json!({
"type": "object",
"required": ["code"],
"additionalProperties": false,
@@ -17,6 +17,8 @@ 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";
pub(crate) const AGC_UNITY_EDITOR_PLUGIN_ID: &str = "agc-unity-editor";
pub(crate) const AGC_UNITY_EDITOR_TOOL_NAME: &str = "unity.editor.execute";
const STATE_FILE_NAME: &str = "builtin-plugins.json";
const STATE_SCHEMA_VERSION: &str = "agc.builtin-plugins.v1";
@@ -24,31 +26,34 @@ const STATE_SCHEMA_VERSION: &str = "agc.builtin-plugins.v1";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum BuiltinPlugin {
CocosEditor,
UnityEditor,
}
impl BuiltinPlugin {
pub(crate) fn id(self) -> &'static str {
match self {
Self::CocosEditor => AGC_COCOS_EDITOR_PLUGIN_ID,
Self::UnityEditor => AGC_UNITY_EDITOR_PLUGIN_ID,
}
}
/// 未持久化任何开关时的默认状态。
fn default_enabled(self) -> bool {
match self {
Self::CocosEditor => true,
Self::CocosEditor | Self::UnityEditor => true,
}
}
/// 该插件是否向 Agent 暴露 Runtime 工具。
fn exposes_agent_tools(self) -> bool {
match self {
Self::CocosEditor => true,
Self::CocosEditor | Self::UnityEditor => true,
}
}
}
pub(crate) const BUILTIN_PLUGINS: &[BuiltinPlugin] = &[BuiltinPlugin::CocosEditor];
pub(crate) const BUILTIN_PLUGINS: &[BuiltinPlugin] =
&[BuiltinPlugin::CocosEditor, BuiltinPlugin::UnityEditor];
pub(crate) fn builtin_plugin(id: &str) -> Option<BuiltinPlugin> {
BUILTIN_PLUGINS
@@ -237,7 +242,16 @@ fn persist(guard: &BuiltinPluginState) -> Result<(), String> {
/// Agent 工具面是否可用:编译期 feature 打开且用户没有禁用该内置插件。
pub(crate) fn agent_tool_available(plugin: BuiltinPlugin) -> bool {
plugin.exposes_agent_tools()
&& cfg!(all(windows, feature = "cocos-editor-execute"))
&& match plugin {
BuiltinPlugin::CocosEditor => cfg!(all(windows, feature = "cocos-editor-execute")),
BuiltinPlugin::UnityEditor => {
cfg!(all(
windows,
target_arch = "x86_64",
feature = "unity-editor-execute"
)) && unity_editor_bridge::is_supported_platform()
}
}
&& is_enabled(plugin.id())
}
@@ -257,6 +271,7 @@ pub(crate) fn cocos_editor_agent_tool_available_for_project(root: &Path) -> bool
}
pub(crate) fn available_agent_tools() -> Vec<&'static str> {
let mut available = Vec::new();
if cocos_editor_agent_tool_available() {
let mut tools = vec![AGC_COCOS_EDITOR_TOOL_NAME];
tools.extend(
@@ -264,19 +279,39 @@ pub(crate) fn available_agent_tools() -> Vec<&'static str> {
.iter()
.filter_map(|tool| tool["name"].as_str()),
);
tools
} else {
Vec::new()
available.extend(tools);
}
if unity_editor_agent_tool_available() {
available.push(AGC_UNITY_EDITOR_TOOL_NAME);
}
available
}
/// Project-scoped variant used by the isolated DirectProject MCP bridge.
/// Without a project root the safe result is an empty Cocos tool set.
pub(crate) fn available_agent_tools_for_project(root: &Path) -> Vec<&'static str> {
if !cocos_editor_agent_tool_available_for_project(root) {
return Vec::new();
}
available_agent_tools()
.into_iter()
.filter(|tool| {
if *tool == AGC_UNITY_EDITOR_TOOL_NAME {
unity_editor_agent_tool_available_for_project(root)
} else {
cocos_editor_agent_tool_available_for_project(root)
}
})
.collect()
}
pub(crate) fn unity_editor_agent_tool_available() -> bool {
agent_tool_available(BuiltinPlugin::UnityEditor)
}
pub(crate) fn unity_editor_agent_tool_available_for_project(root: &Path) -> bool {
unity_editor_agent_tool_available()
&& crate::project::discover_local_unity_project_root(root)
.ok()
.flatten()
.is_some()
}
#[cfg(test)]
@@ -294,6 +329,47 @@ mod tests {
.unwrap_or_else(|error| error.into_inner())
}
#[test]
fn unity_tool_visibility_requires_project_platform_and_independent_toggle() {
let _guard = test_lock();
let config = tempdir().unwrap();
initialize(config.path()).unwrap();
let project = tempdir().unwrap();
for directory in ["Assets", "Packages", "ProjectSettings"] {
fs::create_dir(project.path().join(directory)).unwrap();
}
fs::write(
project.path().join("ProjectSettings/ProjectVersion.txt"),
"m_EditorVersion: 6000.0.1f1",
)
.unwrap();
let supported = cfg!(all(
windows,
target_arch = "x86_64",
feature = "unity-editor-execute"
));
assert_eq!(
available_agent_tools_for_project(project.path()).contains(&AGC_UNITY_EDITOR_TOOL_NAME),
supported
);
assert!(
!available_agent_tools_for_project(config.path()).contains(&AGC_UNITY_EDITOR_TOOL_NAME)
);
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, false).unwrap();
assert_eq!(
unity_editor_agent_tool_available_for_project(project.path()),
supported
);
set_enabled(AGC_UNITY_EDITOR_PLUGIN_ID, false).unwrap();
assert!(!unity_editor_agent_tool_available_for_project(
project.path()
));
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).unwrap();
assert!(!unity_editor_agent_tool_available_for_project(
project.path()
));
}
#[test]
fn builtin_plugins_default_to_enabled_and_reject_unknown_ids() {
let _guard = test_lock();
@@ -676,6 +676,21 @@ pub(crate) fn import_local_cocos_project(
import_local_cocos_project_at(root, project_id.trim(), name.trim())
}
#[tauri::command]
pub(crate) fn import_local_unity_project(
project_path: String,
project_id: String,
name: String,
) -> Result<InitLocalProjectResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.create")?;
if discover_local_unity_project_root(root)?.is_none() {
return Err("所选目录不是有效的 Unity 项目".to_string());
}
let _lock = acquire_project_write_lock(root, "project.create")?;
import_local_unity_project_at(root, project_id.trim(), name.trim())
}
#[tauri::command]
pub(crate) fn is_local_project_directory_non_empty(project_path: String) -> Result<bool, String> {
let root = Path::new(project_path.trim());
@@ -737,6 +752,7 @@ pub(crate) fn inspect_local_project_directory_sync(
let recent_run_trace = recent_game_creator_run_trace(root);
let godot_project_root = discover_local_godot_project_root(root)?;
let cocos_project_root = discover_local_cocos_project_root(root)?;
let unity_project_root = discover_local_unity_project_root(root)?;
Ok(LocalProjectDirectoryStatus {
project_path: root.to_string_lossy().into_owned(),
exists: root.exists(),
@@ -746,6 +762,8 @@ pub(crate) fn inspect_local_project_directory_sync(
godot_project_root,
is_cocos_project: cocos_project_root.is_some(),
cocos_project_root,
is_unity_project: unity_project_root.is_some(),
unity_project_root,
project_name: game_creator_project_name(root),
modified_at: project_directory_modified_at(root),
manifest_error: game_creator_project_manifest_error(root),

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