合并最新游戏创作目标分支并解决文档冲突
Project CI / Repository checks (pull_request) Successful in 1m36s
Project CI / Backend tests (pull_request) Successful in 3m25s
Project CI / Frontend tests (pull_request) Successful in 3m37s
Project CI / Native shell tests (pull_request) Failing after 9m54s

同步通用多智能体 Runtime 与可扩展 Provider 最新实现
完整保留资源画布与 Runtime 双方 pitfalls 长期经验
通过编码、前端类型、资源布局及 Runtime Provider 定向测试
保持本地环境文件不进入提交
This commit is contained in:
2026-07-30 18:34:29 +08:00
54 changed files with 8723 additions and 167 deletions
@@ -1271,7 +1271,8 @@ for (const requiredSnippet of [
'fn apply_game_chat_initial_window_url(',
'.find(|window| window.label == "client")',
'client.url = game_chat_window_url(',
'.run(tauri_context)',
'.build(tauri_context)',
'app.run(|_, event| handle_game_creator_gui_run_event(&event))',
]) {
if (
!`${tauriHandlerSource}\n${tauriWindowSource}`.includes(requiredSnippet)
+10
View File
@@ -8,6 +8,14 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "agent-runtime-core"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -1472,6 +1480,7 @@ dependencies = [
name = "genarrative-ai-game-creator-shell"
version = "0.1.0"
dependencies = [
"agent-runtime-core",
"base64 0.22.1",
"chromiumoxide",
"futures",
@@ -2968,6 +2977,7 @@ dependencies = [
name = "platform-llm"
version = "0.1.0"
dependencies = [
"agent-runtime-core",
"log",
"reqwest 0.12.28",
"serde",
@@ -8,6 +8,7 @@ publish = false
tauri-build = { version = "2.6.2", features = [] }
[dependencies]
agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" }
base64 = "0.22"
chromiumoxide = "0.9.1"
futures = "0.3"
@@ -13,6 +13,7 @@ mod generation;
mod interaction;
mod prompt;
mod runtime_actions;
mod runtime_adapter;
mod runtime_driver;
mod runtime_protocol;
mod runtime_state;
@@ -21,6 +22,7 @@ pub(crate) use generation::*;
pub(crate) use interaction::*;
pub(crate) use prompt::*;
pub(crate) use runtime_actions::*;
pub(crate) use runtime_adapter::*;
pub(crate) use runtime_driver::*;
pub(crate) use runtime_protocol::*;
pub(crate) use runtime_state::*;
@@ -1,9 +1,11 @@
use super::*;
use agent_runtime_core::{CapabilityDefinition, CapabilityRegistry};
const AGENT_INTERACTION_EXECUTE_TOOL: &str = "runtime_execute";
const AGENT_INTERACTION_RESUME_TOOL: &str = "runtime_resume";
const AGENT_INTERACTION_PROJECT_LOCATION_TOOL: &str = "project_location";
const AGENT_INTERACTION_MAX_OUTPUT_TOKENS: u32 = 1_200;
const AGENT_INTERACTION_PROVIDER_INSTANCE_ID: &str = "agc-interaction";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum AgentInteractionToolKind {
@@ -12,31 +14,44 @@ enum AgentInteractionToolKind {
ProjectLocation,
}
#[derive(Clone, Copy)]
struct AgentInteractionToolDefinition {
kind: AgentInteractionToolKind,
name: &'static str,
description: &'static str,
fn agent_interaction_tool_registry() -> Result<CapabilityRegistry<AgentInteractionToolKind>, String>
{
let empty_input_schema = || {
serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": false
})
};
CapabilityRegistry::try_new([
CapabilityDefinition::try_new(
AGENT_INTERACTION_EXECUTE_TOOL,
AGENT_INTERACTION_EXECUTE_TOOL,
"仅当用户明确要求执行需要读取、修改、生成、测试或调用项目工具的工作时,提交用户原始消息给持久 Runtime。否定、假设、解释、咨询或需求仍不明确时不要调用。",
empty_input_schema(),
AgentInteractionToolKind::Execute,
)
.map_err(|error| format!("Agent interaction capability 无效:{error}"))?,
CapabilityDefinition::try_new(
AGENT_INTERACTION_RESUME_TOOL,
AGENT_INTERACTION_RESUME_TOOL,
"仅当用户明确要求继续或恢复当前 Session 中未完成的持久 Runtime 时调用。",
empty_input_schema(),
AgentInteractionToolKind::Resume,
)
.map_err(|error| format!("Agent interaction capability 无效:{error}"))?,
CapabilityDefinition::try_new(
AGENT_INTERACTION_PROJECT_LOCATION_TOOL,
AGENT_INTERACTION_PROJECT_LOCATION_TOOL,
"当用户询问当前项目目录或项目在哪里时调用;宿主会直接返回真实本地目录,不要猜测路径。",
empty_input_schema(),
AgentInteractionToolKind::ProjectLocation,
)
.map_err(|error| format!("Agent interaction capability 无效:{error}"))?,
])
.map_err(|error| format!("Agent interaction registry 无效:{error}"))
}
const AGENT_INTERACTION_TOOL_DEFINITIONS: &[AgentInteractionToolDefinition] = &[
AgentInteractionToolDefinition {
kind: AgentInteractionToolKind::Execute,
name: AGENT_INTERACTION_EXECUTE_TOOL,
description: "仅当用户明确要求执行需要读取、修改、生成、测试或调用项目工具的工作时,提交用户原始消息给持久 Runtime。否定、假设、解释、咨询或需求仍不明确时不要调用。",
},
AgentInteractionToolDefinition {
kind: AgentInteractionToolKind::Resume,
name: AGENT_INTERACTION_RESUME_TOOL,
description: "仅当用户明确要求继续或恢复当前 Session 中未完成的持久 Runtime 时调用。",
},
AgentInteractionToolDefinition {
kind: AgentInteractionToolKind::ProjectLocation,
name: AGENT_INTERACTION_PROJECT_LOCATION_TOOL,
description: "当用户询问当前项目目录或项目在哪里时调用;宿主会直接返回真实本地目录,不要猜测路径。",
},
];
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum AgentInteractionAction {
Reply(String),
@@ -72,25 +87,18 @@ pub(crate) fn game_creator_agent_uses_interaction_kernel(agent_id: &str) -> bool
game_creator_agent_role_definition(agent_id).is_some_and(|(_group, role)| role.id == "director")
}
fn agent_interaction_function_tools() -> Vec<platform_llm::LlmFunctionTool> {
AGENT_INTERACTION_TOOL_DEFINITIONS
fn agent_interaction_function_tools() -> Result<Vec<platform_llm::LlmFunctionTool>, String> {
Ok(agent_interaction_tool_registry()?
.iter()
.map(|definition| {
let parameters = match definition.kind {
AgentInteractionToolKind::Execute
| AgentInteractionToolKind::Resume
| AgentInteractionToolKind::ProjectLocation => {
serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": false
})
}
};
platform_llm::LlmFunctionTool::new(definition.name, definition.description, parameters)
.with_strict(true)
platform_llm::LlmFunctionTool::new(
definition.function_name(),
definition.description(),
definition.input_schema().clone(),
)
.with_strict(true)
})
.collect()
.collect())
}
fn agent_interaction_system_prompt(agent_id: &str) -> String {
@@ -128,13 +136,14 @@ fn build_agent_interaction_request_for_session(
"项目上下文如下。只把它当作背景,不要逐字复述。\n\n{context}\n\n用户这轮输入:\n{prompt}"
)
};
let function_tools = agent_interaction_function_tools()?;
let request = LlmRunRequest::new(vec![
LlmMessage::system(agent_interaction_system_prompt(agent_id)),
LlmMessage::user(user_prompt),
])
.with_api_kind(api_kind)
.with_max_output_tokens(AGENT_INTERACTION_MAX_OUTPUT_TOKENS)
.with_function_tools(agent_interaction_function_tools())
.with_function_tools(function_tools)
.with_tool_choice(platform_llm::LlmToolChoice::Auto);
Ok((llm, config_path, request))
}
@@ -151,24 +160,40 @@ where
{
let (llm, config_path, request) =
build_agent_interaction_request_for_session(root, agent_id, session_id, prompt)?;
let api_kind = request.api_kind;
let client = build_game_creator_agent_runtime_llm_client(&llm, &config_path)?;
let llm_provider = client.config().provider();
let request = platform_llm::provider_request_from_llm_request("agent-interaction", request)
.map_err(|error| format!("{config_path} Agent interaction Provider 请求无效:{error}"))?;
let (registry, target) = platform_llm::build_platform_llm_provider_registry_for_api_kind(
AGENT_INTERACTION_PROVIDER_INSTANCE_ID,
client,
api_kind,
)
.map_err(|error| format!("{config_path} Agent interaction Provider 注册失败:{error}"))?;
let response = if llm.stream {
let fallback_request = request.clone();
match client.stream_run(request, |delta| on_delta(delta)).await {
let sink = AgentInteractionProviderStreamSink {
on_delta: &mut on_delta,
};
match registry.stream(&target, request, Box::new(sink)).await {
Ok(response) => response,
Err(error)
if matches!(
error.kind(),
platform_llm::LlmErrorKind::StreamUnavailable
| platform_llm::LlmErrorKind::EmptyResponse
| platform_llm::LlmErrorKind::Deserialize
agent_runtime_core::ProviderErrorKind::StreamUnavailable
| agent_runtime_core::ProviderErrorKind::EmptyResponse
| agent_runtime_core::ProviderErrorKind::Deserialize
) =>
{
client.run(fallback_request).await.map_err(|fallback_error| {
format!(
registry
.invoke(&target, fallback_request)
.await
.map_err(|fallback_error| {
format!(
"{config_path} Agent interaction 流式协议不可用且普通请求回退失败:流式错误:{error};普通请求错误:{fallback_error}"
)
})?
})?
}
Err(error) => {
return Err(format!(
@@ -177,14 +202,44 @@ where
}
}
} else {
client
.run(request)
registry
.invoke(&target, request)
.await
.map_err(|error| format!("{config_path} Agent interaction 调用 LLM 失败:{error}"))?
};
let response = platform_llm::llm_response_from_provider_response(llm_provider, response)
.map_err(|error| format!("{config_path} Agent interaction Provider 响应无效:{error}"))?;
parse_agent_interaction_response(&response)
}
struct AgentInteractionProviderStreamSink<'a, F> {
on_delta: &'a mut F,
}
impl<F> agent_runtime_core::ProviderStreamSink for AgentInteractionProviderStreamSink<'_, F>
where
F: FnMut(&platform_llm::LlmStreamDelta),
{
fn emit(
&mut self,
event: agent_runtime_core::ProviderStreamEvent,
) -> Result<(), agent_runtime_core::ProviderError> {
if let agent_runtime_core::ProviderStreamEvent::TextDelta {
accumulated_text,
delta_text,
finish_reason,
} = event
{
(self.on_delta)(&platform_llm::LlmStreamDelta {
accumulated_text,
delta_text,
finish_reason,
});
}
Ok(())
}
}
fn parse_agent_interaction_response(
response: &platform_llm::LlmRunResponse,
) -> Result<AgentInteractionAction, String> {
@@ -192,11 +247,11 @@ fn parse_agent_interaction_response(
return Err("Agent interaction 一轮最多只能选择一个宿主工具".to_string());
}
if let Some(call) = response.tool_calls.first() {
let definition = AGENT_INTERACTION_TOOL_DEFINITIONS
.iter()
.find(|definition| definition.name == call.name)
let registry = agent_interaction_tool_registry()?;
let definition = registry
.get_by_function_name(&call.name)
.ok_or_else(|| format!("Agent interaction 返回未知工具:{}", call.name))?;
return match definition.kind {
return match definition.dispatch() {
AgentInteractionToolKind::Execute => {
let arguments = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(
call.arguments.as_str(),
@@ -354,8 +409,9 @@ mod tests {
#[test]
fn interaction_registry_derives_unique_strict_function_tools() {
let tools = agent_interaction_function_tools();
assert_eq!(tools.len(), AGENT_INTERACTION_TOOL_DEFINITIONS.len());
let registry = agent_interaction_tool_registry().expect("interaction registry");
let tools = agent_interaction_function_tools().expect("interaction tools");
assert_eq!(tools.len(), registry.len());
let names = tools
.iter()
.map(|tool| tool.name.as_str())
@@ -368,6 +424,50 @@ mod tests {
}));
}
#[test]
fn interaction_request_crosses_neutral_provider_contract_without_shape_drift() {
let request =
LlmRunRequest::new(vec![LlmMessage::system("system"), LlmMessage::user("user")])
.with_api_kind(LlmApiKind::OpenAiResponses)
.with_max_output_tokens(AGENT_INTERACTION_MAX_OUTPUT_TOKENS)
.with_function_tools(agent_interaction_function_tools().expect("interaction tools"))
.with_tool_choice(platform_llm::LlmToolChoice::Auto);
let request = platform_llm::provider_request_from_llm_request("agent-interaction", request)
.expect("neutral request");
assert_eq!(
request.max_output_tokens(),
Some(AGENT_INTERACTION_MAX_OUTPUT_TOKENS)
);
assert_eq!(request.tools().len(), 3);
assert!(request.tools().iter().all(|tool| tool.strict()));
assert_eq!(
request.tool_choice(),
&agent_runtime_core::ProviderToolChoice::Auto
);
}
#[test]
fn interaction_stream_sink_preserves_accumulation_delta_and_finish_reason() {
let mut observed = Vec::new();
let mut callback = |delta: &platform_llm::LlmStreamDelta| observed.push(delta.clone());
let mut sink = AgentInteractionProviderStreamSink {
on_delta: &mut callback,
};
agent_runtime_core::ProviderStreamSink::emit(
&mut sink,
agent_runtime_core::ProviderStreamEvent::TextDelta {
accumulated_text: "完成".to_string(),
delta_text: "成".to_string(),
finish_reason: Some("stop".to_string()),
},
)
.expect("emit");
assert_eq!(observed.len(), 1);
assert_eq!(observed[0].accumulated_text, "完成");
assert_eq!(observed[0].delta_text, "成");
assert_eq!(observed[0].finish_reason.as_deref(), Some("stop"));
}
#[test]
fn interaction_response_rejects_arguments_for_host_selected_action() {
let error = parse_agent_interaction_response(&response(
@@ -93,7 +93,7 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
"agent.run_status 使用 {\"agentId\":\"可选目标 taskId\",\"scope\":\"self|all\",\"delegationId\":\"可选已认领 delegation id\"},用于读取自己或其他 Agent 的 Runtime 状态摘要;Project Supervisor 传 delegationId 时读取当前父 run 的未截断权威返工合同",
);
let prompt = format!(
"{prompt}\n\n补充协议:project.verify 的 script 除 check、typecheck、test、lint、build 外,还可使用 check:<name>、test:<name>(例如 test:unit)、lint:<name>、typecheck:<name>、build:<name>、verify:<name>、validate:<name> 形式的命名脚本;冒号后的每个非空段必须以字母或数字开头且只能包含字母、数字、连字符、下划线或点,并且 script 与 expectedCommand 都必须原样来自项目根 package.json。command.exec 使用 {{\"program\":\"cargo|npm|node|git|rg\",\"args\":[\"逐项 argv\"],\"cwd\":\"可选项目内相对目录\",\"timeoutSeconds\":120}},不接受 shell 字符串、管道、重定向、环境变量或项目外路径;该工具默认需要精确确认,适合运行定向测试、构建检查和只读诊断。durable command.exec observation 会直接返回可复用的 sourceActionId;短 observation 不足以定位失败时,使用 command.output_read {{\"actionId\":\"该 sourceActionId\",\"startLine\":1,\"maxLines\":160}} 分页读取同一 Agent 的已清洗命令输出,并按 nextLine 继续,不要先猜 actionId 或为取得它额外查询动作历史,也不得仅凭输出尾部猜测。只有 cargo check/test/clippy/fmt/build、npm test 或命名为 check/typecheck/test/lint/build/verify/validate 的验证脚本,以及精确 node --test 测试文件可签发验证凭证;git、rg、cargo metadata 和普通 npm run 只作为诊断结果。每次成功执行 file.write、file.patch、file.delete、project.patchset 或 project.restore,以及每次真正启动 command.exec 或 command.start,都会产生新的项目 revision;最后一次修改后必须成功执行 project.verify、可验证 command.exec,或成功执行 command.run_limited 的 game.static_smoke,才能返回空 actions 收束。文件回读不能替代可执行验证,验证后再次修改必须重新验证。每 {AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT} 轮只是一次进度 checkpoint 与停滞检测,不是上下文压缩或 run 的终止上限;只要 observation 出现新的独立进展,就在同一 run 继续下一窗口,只有窗口没有新进展时才按停滞处理。真正的上下文压缩仅由 token 阈值或显式 compact 触发。"
"{prompt}\n\n补充协议:project.verify 的 script 除 check、typecheck、test、lint、build 外,还可使用 check:<name>、test:<name>(例如 test:unit)、lint:<name>、typecheck:<name>、build:<name>、verify:<name>、validate:<name> 形式的命名脚本;冒号后的每个非空段必须以字母或数字开头且只能包含字母、数字、连字符、下划线或点,并且 script 与 expectedCommand 都必须原样来自项目根 package.json。command.exec 使用 {{\"program\":\"cargo|npm|node|git|rg\",\"args\":[\"逐项 argv\"],\"cwd\":\"可选项目内相对目录\",\"timeoutSeconds\":120}},不接受 shell 字符串、管道、重定向、环境变量或项目外路径;args 中的项目路径必须相对 cwd,禁止绝对路径、file URI、路径加行号以及把绝对路径嵌入脚本或说明文字。该工具默认需要精确确认,适合运行定向测试、构建检查和只读诊断。durable command.exec observation 会直接返回可复用的 sourceActionId;短 observation 不足以定位失败时,使用 command.output_read {{\"actionId\":\"该 sourceActionId\",\"startLine\":1,\"maxLines\":160}} 分页读取同一 Agent 的已清洗命令输出,并按 nextLine 继续,不要先猜 actionId 或为取得它额外查询动作历史,也不得仅凭输出尾部猜测。只有 cargo check/test/clippy/fmt/build、npm test 或命名为 check/typecheck/test/lint/build/verify/validate 的验证脚本,以及精确 node --test 测试文件可签发验证凭证;git、rg、cargo metadata 和普通 npm run 只作为诊断结果。每次成功执行 file.write、file.patch、file.delete、project.patchset 或 project.restore,以及每次真正启动 command.exec 或 command.start,都会产生新的项目 revision;最后一次修改后必须成功执行 project.verify、可验证 command.exec,或成功执行 command.run_limited 的 game.static_smoke,才能返回空 actions 收束。文件回读不能替代可执行验证,验证后再次修改必须重新验证。每 {AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT} 轮只是一次进度 checkpoint 与停滞检测,不是上下文压缩或 run 的终止上限;只要 observation 出现新的独立进展,就在同一 run 继续下一窗口,只有窗口没有新进展时才按停滞处理。真正的上下文压缩仅由 token 阈值或显式 compact 触发。"
);
#[cfg(target_os = "linux")]
let prompt = prompt.replace(
@@ -110,7 +110,7 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
"{prompt}\n\nagent.spawn_isolated 补充约束:expectedArtifacts 只能填写子任务完成时必须存在的项目内相对文件路径或 glob;只读任务填写被检查的现有文件,不能填写报告标题、描述或其他自然语言。writeScopes 必须是互不重叠的项目内非私有相对目录 glob,禁止使用 .agent、敏感路径或项目外路径;只读任务也必须填写且不能留空,只能覆盖其 expectedArtifacts 所在的最小目录/**,不能扩大到 sibling 或共同父目录。"
);
let prompt = format!(
"{prompt}\n\n持久进程协议:command.start 使用 {{\"program\":\"cargo|npm|node|git|rg\",\"args\":[\"逐项 argv\"],\"cwd\":\"项目内相对目录\",\"timeoutSeconds\":300}},默认需要精确确认;它只用于已经从仓库清单确认需要持续交互的长进程,有限诊断、文件探测、构建和测试必须使用 command.exec,不得用 command.start 试错。成功后保存 observation 返回的 processId 和 cursor;同一服务后续只能沿该 processId 继续,不得为探测、重试、交互或停止另起 process session。command.poll 使用 {{\"processId\":\"proc-...\",\"cursor\":\"上一页 nextCursor,可首次省略\",\"maxChars\":8000,\"waitMs\":1000}},必须按 nextCursor 增量读取,不要无等待忙轮询。command.stdin 使用 {{\"processId\":\"proc-...\",\"data\":\"UTF-8 文本\",\"appendNewline\":true,\"eof\":false}},正文会写入 PTY 且默认需要确认;command.terminate 使用 {{\"processId\":\"proc-...\",\"cursor\":\"最后一次 poll 的 nextCursor\"}} 并默认需要确认,terminate 不消费输出,后续继续用它返回的同一 nextCursor poll 终态。command.start 会推进 revision 但永远不能签发验证凭证;当前 run 的进程会话必须 poll 到可信终态,或先 terminate 再 poll,才能返回空 actions 收束;needs-reconciliation 只能等待人工核对,不能重启、按 PID 重连或假装已退出。"
"{prompt}\n\n持久进程协议:command.start 使用 {{\"program\":\"cargo|npm|node|git|rg\",\"args\":[\"逐项 argv\"],\"cwd\":\"项目内相对目录\",\"timeoutSeconds\":300}};args 中的项目路径必须相对 cwd,禁止绝对路径、file URI、路径加行号以及把绝对路径嵌入脚本或说明文字。默认需要精确确认;它只用于已经从仓库清单确认需要持续交互的长进程,有限诊断、文件探测、构建和测试必须使用 command.exec,不得用 command.start 试错。成功后保存 observation 返回的 processId 和 cursor;同一服务后续只能沿该 processId 继续,不得为探测、重试、交互或停止另起 process session。command.poll 使用 {{\"processId\":\"proc-...\",\"cursor\":\"上一页 nextCursor,可首次省略\",\"maxChars\":8000,\"waitMs\":1000}},必须按 nextCursor 增量读取,不要无等待忙轮询。command.stdin 使用 {{\"processId\":\"proc-...\",\"data\":\"UTF-8 文本\",\"appendNewline\":true,\"eof\":false}},正文会写入 PTY 且默认需要确认;command.terminate 使用 {{\"processId\":\"proc-...\",\"cursor\":\"最后一次 poll 的 nextCursor\"}} 并默认需要确认,terminate 不消费输出,后续继续用它返回的同一 nextCursor poll 终态。command.start 会推进 revision 但永远不能签发验证凭证;当前 run 的进程会话必须 poll 到可信终态,或先 terminate 再 poll,才能返回空 actions 收束;needs-reconciliation 只能等待人工核对,不能重启、按 PID 重连或假装已退出。"
);
#[cfg(target_os = "linux")]
let prompt = prompt.replace(
@@ -0,0 +1,136 @@
use super::*;
use agent_runtime_core::{AgentCatalog, AgentDescriptor, RunProfileCatalog, RunProfileDefinition};
fn build_game_creator_runtime_agent_catalog() -> Result<AgentCatalog, String> {
let mut agents = vec![AgentDescriptor::try_new(
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"supervisor",
std::iter::empty::<&str>(),
)
.and_then(|agent| {
agent.with_metadata(serde_json::json!({
"groupId": PROJECT_SUPERVISOR_AGENT_DEFINITION.id,
"roleLabel": PROJECT_SUPERVISOR_AGENT_ROLES[0].role,
"toolId": PROJECT_SUPERVISOR_AGENT_ROLES[0].tool_id,
"capabilityAuthority": "game-creator-tool-policy-snapshot"
}))
})
.map_err(|error| format!("AI 游戏创作 Agent catalog 无效:{error}"))?];
for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS {
for role in group.roles {
agents.push(
AgentDescriptor::try_new(role.task_id, role.id, std::iter::empty::<&str>())
.and_then(|agent| {
agent.with_metadata(serde_json::json!({
"groupId": group.id,
"groupLabel": group.label,
"roleLabel": role.role,
"toolId": role.tool_id,
"capabilityAuthority": "game-creator-tool-policy-snapshot"
}))
})
.map_err(|error| format!("AI 游戏创作 Agent catalog 无效:{error}"))?,
);
}
}
AgentCatalog::try_new(agents)
.map_err(|error| format!("AI 游戏创作 Agent catalog 无效:{error}"))
}
pub(crate) fn game_creator_runtime_agent_catalog() -> Result<&'static AgentCatalog, String> {
static CATALOG: OnceLock<Result<AgentCatalog, String>> = OnceLock::new();
CATALOG
.get_or_init(build_game_creator_runtime_agent_catalog)
.as_ref()
.map_err(Clone::clone)
}
fn build_game_creator_runtime_run_profile_catalog() -> Result<RunProfileCatalog, String> {
let standard = RunProfileDefinition::try_new(
AGENT_RUNTIME_RUN_PROFILE_STANDARD,
std::iter::empty::<&str>(),
"game-creator-standard-completion",
)
.and_then(|profile| {
profile.with_metadata(serde_json::json!({
"capabilityAuthority": "game-creator-tool-policy-snapshot",
"completionAuthority": "game-creator-runtime-finalization"
}))
})
.map_err(|error| format!("AI 游戏创作 Run Profile catalog 无效:{error}"))?;
let autonomous = RunProfileDefinition::try_new(
AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD,
std::iter::empty::<&str>(),
"game-creator-autonomous-completion",
)
.and_then(|profile| {
profile.with_metadata(serde_json::json!({
"rootAgentId": GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"capabilityAuthority": "game-creator-tool-policy-snapshot",
"completionAuthority": AGENT_RUNTIME_AUTONOMOUS_COMPLETION_CONTRACT_SCHEMA_VERSION
}))
})
.map_err(|error| format!("AI 游戏创作 Run Profile catalog 无效:{error}"))?;
RunProfileCatalog::try_new([standard, autonomous])
.map_err(|error| format!("AI 游戏创作 Run Profile catalog 无效:{error}"))
}
pub(crate) fn game_creator_runtime_run_profile_catalog(
) -> Result<&'static RunProfileCatalog, String> {
static CATALOG: OnceLock<Result<RunProfileCatalog, String>> = OnceLock::new();
CATALOG
.get_or_init(build_game_creator_runtime_run_profile_catalog)
.as_ref()
.map_err(Clone::clone)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn game_creator_runtime_agent_catalog_matches_the_existing_role_directory() {
let catalog = game_creator_runtime_agent_catalog().expect("agent catalog");
let mut expected =
std::collections::BTreeSet::from(
[GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()],
);
for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS {
expected.extend(group.roles.iter().map(|role| role.task_id.to_string()));
}
assert_eq!(
catalog
.iter()
.map(|agent| agent.id().to_string())
.collect::<std::collections::BTreeSet<_>>(),
expected
);
assert_eq!(
catalog
.get(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)
.map(AgentDescriptor::role),
Some("supervisor")
);
}
#[test]
fn game_creator_runtime_run_profiles_are_adapter_registered() {
let profiles = game_creator_runtime_run_profile_catalog().expect("profile catalog");
assert_eq!(
profiles
.iter()
.map(|profile| profile.id())
.collect::<Vec<_>>(),
vec![
AGENT_RUNTIME_RUN_PROFILE_STANDARD,
AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
]
);
assert_eq!(
profiles
.get(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD)
.map(RunProfileDefinition::completion_policy_id),
Some("game-creator-autonomous-completion")
);
}
}
@@ -84,8 +84,10 @@ pub(crate) use run_configuration::{
pub(crate) use steering::{
acquire_game_creator_agent_runtime_steer_project_write_lock_with_wait,
consume_game_creator_agent_runtime_steers, game_creator_agent_runtime_accepts_steer,
game_creator_agent_runtime_provider_request_count_for_roots,
game_creator_agent_runtime_steer_ledger_path,
interrupt_game_creator_agent_runtime_provider_request_at,
interrupt_game_creator_agent_runtime_provider_requests_for_roots,
render_game_creator_agent_runtime_steers_for_prompt, steer_game_creator_agent_runtime_task_at,
steer_game_creator_agent_runtime_task_for_profile_at,
validate_game_creator_agent_runtime_steer_notification_at,
@@ -423,6 +423,7 @@ fn mark_game_creator_agent_runtime_provider_request_needs_reconciliation_with_di
failure_kind,
format!("{:x}", Sha256::digest(error.as_bytes())),
error.chars().count(),
tool_plan_handoff::safe_failure_diagnostic(error),
)
});
state.status = "running".to_string();
@@ -438,10 +439,22 @@ fn mark_game_creator_agent_runtime_provider_request_needs_reconciliation_with_di
}
let public_detail = diagnostic
.as_ref()
.map(|(failure_kind, error_sha256, error_chars)| {
format!(
.map(|(failure_kind, error_sha256, error_chars, safe_diagnostic)| {
let mut detail = format!(
"requestId={request_id} · failureKind={failure_kind} · errorSha256={error_sha256} · errorChars={error_chars}"
)
);
if let Some(safe_diagnostic) = safe_diagnostic {
detail.push_str(&format!(
" · functionClass={} · jsonPointer={} · pathShape={} · relationToRoot={} · duplicateSafeJson={} · hitCount={}",
safe_diagnostic.function_class,
safe_diagnostic.json_pointer,
safe_diagnostic.path_shape,
safe_diagnostic.relation_to_root,
safe_diagnostic.duplicate_safe_json,
safe_diagnostic.hit_count,
));
}
detail
})
.unwrap_or_else(|| format!("requestId={request_id}"));
let event_detail = diagnostic
@@ -469,8 +482,9 @@ fn mark_game_creator_agent_runtime_provider_request_needs_reconciliation_with_di
event_summary,
Some(event_detail),
);
let audit = if let Some((failure_kind, error_sha256, error_chars)) = diagnostic {
serde_json::json!({
let audit = if let Some((failure_kind, error_sha256, error_chars, safe_diagnostic)) = diagnostic
{
let mut audit = serde_json::json!({
"recordType": "agent.runtime.provider_request.needs_reconciliation",
"agentId": state.agent_id,
"taskId": state.task_id,
@@ -483,7 +497,34 @@ fn mark_game_creator_agent_runtime_provider_request_needs_reconciliation_with_di
"failureKind": failure_kind,
"errorSha256": error_sha256,
"errorChars": error_chars,
})
});
if let (Some(audit), Some(safe_diagnostic)) = (audit.as_object_mut(), safe_diagnostic) {
audit.insert(
"functionClass".to_string(),
serde_json::Value::String(safe_diagnostic.function_class),
);
audit.insert(
"jsonPointer".to_string(),
serde_json::Value::String(safe_diagnostic.json_pointer),
);
audit.insert(
"pathShape".to_string(),
serde_json::Value::String(safe_diagnostic.path_shape),
);
audit.insert(
"relationToRoot".to_string(),
serde_json::Value::String(safe_diagnostic.relation_to_root),
);
audit.insert(
"duplicateSafeJson".to_string(),
serde_json::Value::Bool(safe_diagnostic.duplicate_safe_json),
);
audit.insert(
"hitCount".to_string(),
serde_json::json!(safe_diagnostic.hit_count),
);
}
audit
} else {
serde_json::json!({
"recordType": "agent.runtime.provider_request.needs_reconciliation",
@@ -11,11 +11,13 @@ pub(in crate::agent) fn normalize_agent_runtime_run_profile(
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(AGENT_RUNTIME_RUN_PROFILE_STANDARD);
match profile {
AGENT_RUNTIME_RUN_PROFILE_STANDARD | AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD => {
Ok(profile.to_string())
}
_ => Err(format!("不支持的 Agent Runtime Run Profile:{profile}")),
if game_creator_runtime_run_profile_catalog()?
.get(profile)
.is_some()
{
Ok(profile.to_string())
} else {
Err(format!("不支持的 Agent Runtime Run Profile:{profile}"))
}
}
@@ -772,6 +772,45 @@ pub(crate) fn interrupt_game_creator_agent_runtime_provider_request_at(
Ok(first)
}
pub(crate) fn interrupt_game_creator_agent_runtime_provider_requests_for_roots(
roots: &[PathBuf],
) -> usize {
let prefixes = roots
.iter()
.map(|root| format!("{}\n", root.to_string_lossy()))
.collect::<Vec<_>>();
let active = {
let registry = game_creator_agent_provider_interrupts()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
registry
.iter()
.filter(|(key, _)| prefixes.iter().any(|prefix| key.starts_with(prefix)))
.map(|(_, active)| active.clone())
.collect::<Vec<_>>()
};
for request in &active {
request.interrupted.store(true, Ordering::Release);
request.notify.notify_one();
}
active.len()
}
pub(crate) fn game_creator_agent_runtime_provider_request_count_for_roots(
roots: &[PathBuf],
) -> usize {
let prefixes = roots
.iter()
.map(|root| format!("{}\n", root.to_string_lossy()))
.collect::<Vec<_>>();
game_creator_agent_provider_interrupts()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.keys()
.filter(|key| prefixes.iter().any(|prefix| key.starts_with(prefix)))
.count()
}
pub(crate) fn validate_game_creator_agent_runtime_steer_notification_at(
root: &Path,
agent_id: &str,
@@ -1137,3 +1176,66 @@ pub(in crate::agent) fn close_game_creator_agent_runtime_steer_ledger_at_locked(
},
)
}
#[cfg(test)]
mod shutdown_tests {
use super::*;
use std::sync::atomic::AtomicU64;
static DIRECTORY_COUNTER: AtomicU64 = AtomicU64::new(0);
struct TestDirectory(PathBuf);
impl Drop for TestDirectory {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn test_directory() -> TestDirectory {
let sequence = DIRECTORY_COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"genarrative-provider-shutdown-test-{}-{}-{sequence}",
std::process::id(),
unix_timestamp()
));
fs::create_dir_all(&path).expect("create Provider shutdown test directory");
TestDirectory(path)
}
#[test]
fn forced_runner_shutdown_interrupts_provider_requests_only_for_known_roots() {
let directory = test_directory();
let known_root = directory.0.join("known");
let other_root = directory.0.join("other");
fs::create_dir_all(&known_root).expect("create known project");
fs::create_dir_all(&other_root).expect("create other project");
let known_root = fs::canonicalize(known_root).expect("canonicalize known project");
let other_root = fs::canonicalize(other_root).expect("canonicalize other project");
let (known_key, known_request) = register_game_creator_agent_runtime_provider_request(
&known_root,
"code-prototype",
"known-run",
)
.expect("register known Provider request");
let (other_key, other_request) = register_game_creator_agent_runtime_provider_request(
&other_root,
"code-prototype",
"other-run",
)
.expect("register other Provider request");
let interrupted =
interrupt_game_creator_agent_runtime_provider_requests_for_roots(&[known_root.clone()]);
assert_eq!(interrupted, 1);
assert!(known_request.interrupted.load(Ordering::Acquire));
assert!(!other_request.interrupted.load(Ordering::Acquire));
assert_eq!(
game_creator_agent_runtime_provider_request_count_for_roots(&[known_root]),
1
);
unregister_game_creator_agent_runtime_provider_request(&known_key, &known_request);
unregister_game_creator_agent_runtime_provider_request(&other_key, &other_request);
}
}
@@ -1206,8 +1206,11 @@ pub(crate) fn normalize_game_creator_runtime_agent_id(agent_id: &str) -> Result<
if agent_id.is_empty() {
return Err("Agent ID 不能为空".to_string());
}
if let Some((_group, role)) = game_creator_agent_role_definition(agent_id) {
return Ok(role.task_id.to_string());
if game_creator_runtime_agent_catalog()?
.get(agent_id)
.is_some()
{
return Ok(agent_id.to_string());
}
for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS {
for role in group.roles {
@@ -3243,24 +3246,28 @@ pub(super) fn read_recoverable_game_creator_agent_runtime_task(
let path = game_creator_agent_runtime_task_path(root, agent_id);
let records =
latest_game_creator_agent_runtime_tasks(read_all_game_creator_agent_runtime_tasks(&path)?);
if let Some(task) = records
let task_states = records
.iter()
.find(|record| record.status == "running")
.cloned()
{
return Ok(Some(task));
.map(|record| match record.status.as_str() {
"pending" => agent_runtime_core::RecoverableTaskState::Pending,
"running" => agent_runtime_core::RecoverableTaskState::Running,
"waiting-for-confirmation" => {
agent_runtime_core::RecoverableTaskState::WaitingForConfirmation
}
"waiting-for-user-input" => {
agent_runtime_core::RecoverableTaskState::WaitingForUserInput
}
_ => agent_runtime_core::RecoverableTaskState::Other,
})
.collect::<Vec<_>>();
match agent_runtime_core::next_recovery_step(&task_states) {
agent_runtime_core::RecoveryStep::ResumeRunning { index }
| agent_runtime_core::RecoveryStep::StartPending { index } => {
Ok(records.get(index).cloned())
}
agent_runtime_core::RecoveryStep::WaitForExternalInput
| agent_runtime_core::RecoveryStep::Idle => Ok(None),
}
if records.iter().any(|record| {
matches!(
record.status.as_str(),
"waiting-for-confirmation" | "waiting-for-user-input"
)
}) {
return Ok(None);
}
Ok(records
.into_iter()
.find(|record| record.status == "pending"))
}
pub(super) fn read_recoverable_runnable_game_creator_agent_runtime_task(
@@ -1,6 +1,8 @@
use std::collections::{BTreeSet, HashSet};
use std::fmt;
use std::sync::OnceLock;
use agent_runtime_core::{CapabilityDefinition, CapabilityRegistry};
use platform_llm::{LlmFunctionTool, LlmToolCall};
use serde::de::{DeserializeOwned, Error as _, MapAccess, SeqAccess, Visitor};
use serde::Deserialize;
@@ -219,17 +221,45 @@ struct NativeResponseArguments {
}
pub(crate) fn native_runtime_function_name(tool: &str) -> Option<String> {
if tool == GAME_CREATOR_MCP_CALL_TOOL
|| !agent_runtime_executable_tools()
.into_iter()
.any(|candidate| candidate == tool)
{
return None;
}
Some(format!(
agent_runtime_native_capability_registry()
.ok()?
.get(tool)
.map(|definition| definition.function_name().to_string())
}
fn native_runtime_function_name_for_tool(tool: &str) -> String {
format!(
"{AGENT_RUNTIME_NATIVE_TOOL_PREFIX}{}",
tool.replace('.', "_")
))
)
}
fn build_agent_runtime_native_capability_registry() -> Result<CapabilityRegistry<String>, String> {
let definitions = agent_runtime_executable_tools()
.into_iter()
.filter(|tool| *tool != GAME_CREATOR_MCP_CALL_TOOL)
.map(|tool| {
CapabilityDefinition::try_new(
tool,
native_runtime_function_name_for_tool(tool),
runtime_tool_description(tool),
runtime_tool_input_schema(tool),
tool.to_string(),
)
.map_err(|error| format!("Runtime capability {tool} 无效:{error}"))
})
.collect::<Result<Vec<_>, _>>()?;
CapabilityRegistry::try_new(definitions)
.map_err(|error| format!("Runtime capability registry 无效:{error}"))
}
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)
.as_ref()
.map_err(Clone::clone)
}
pub(crate) fn native_mcp_function_name(server_id: &str, tool_name: &str) -> String {
@@ -253,20 +283,16 @@ pub(crate) fn build_agent_runtime_native_function_tools(
AGENT_RUNTIME_RESPOND_FUNCTION_NAME.to_string(),
]);
for tool in agent_runtime_executable_tools() {
if tool == GAME_CREATOR_MCP_CALL_TOOL {
continue;
}
let name = native_runtime_function_name(tool)
.ok_or_else(|| format!("无法为 Runtime 工具生成原生函数名:{tool}"))?;
for definition in agent_runtime_native_capability_registry()?.iter() {
let name = definition.function_name().to_string();
if !names.insert(name.clone()) {
return Err(format!("Runtime 原生函数名重复:{name}"));
}
functions.push(
LlmFunctionTool::new(
name,
runtime_tool_description(tool),
action_function_parameters(runtime_tool_input_schema(tool)),
definition.description(),
action_function_parameters(definition.input_schema().clone()),
)
.with_strict(true),
);
@@ -708,11 +734,10 @@ fn validate_native_delegate_string_list(
}
fn runtime_tool_for_native_function(name: &str) -> Option<String> {
agent_runtime_executable_tools()
.into_iter()
.filter(|tool| *tool != GAME_CREATOR_MCP_CALL_TOOL)
.find(|tool| native_runtime_function_name(tool).as_deref() == Some(name))
.map(ToString::to_string)
agent_runtime_native_capability_registry()
.ok()?
.get_by_function_name(name)
.map(|definition| definition.dispatch().clone())
}
fn mcp_tool_for_native_function<'a>(
@@ -1309,6 +1334,30 @@ mod tests {
assert!(description.contains("runId 必须为 null"));
}
#[test]
fn native_runtime_capability_registry_is_the_bidirectional_catalog() {
let registry = agent_runtime_native_capability_registry().expect("native registry");
let executable_tools = agent_runtime_executable_tools()
.into_iter()
.filter(|tool| *tool != GAME_CREATOR_MCP_CALL_TOOL)
.collect::<Vec<_>>();
assert_eq!(registry.len(), executable_tools.len());
for tool in executable_tools {
let definition = registry.get(tool).expect("registered runtime tool");
assert_eq!(definition.id(), tool);
assert_eq!(definition.dispatch(), tool);
assert_eq!(
native_runtime_function_name(tool).as_deref(),
Some(definition.function_name())
);
assert_eq!(
runtime_tool_for_native_function(definition.function_name()).as_deref(),
Some(tool)
);
}
}
#[test]
fn strict_native_function_schemas_match_openai_subset() {
let functions = build_agent_runtime_native_function_tools(&empty_catalog())
+117 -10
View File
@@ -1590,6 +1590,92 @@ struct GameCreatorAgentLoopResult {
steps: Vec<GameCreationAgentRunStep>,
}
fn game_creator_gui_run_event_requests_runner_shutdown(event: &tauri::RunEvent) -> bool {
matches!(event, tauri::RunEvent::Exit)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum GameCreatorGuiRunnerShutdownOutcome {
NotRequested,
Requested,
Failed(GameCreatorGuiRunnerShutdownFailure),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum GameCreatorGuiRunnerShutdownFailure {
EndpointUnavailable,
RunnerUnresponsive,
ProcessIdentity,
PlatformUnsupported,
LockTimeout,
Other,
}
impl GameCreatorGuiRunnerShutdownFailure {
fn code(self) -> &'static str {
match self {
Self::EndpointUnavailable => "endpoint_unavailable",
Self::RunnerUnresponsive => "runner_unresponsive",
Self::ProcessIdentity => "process_identity",
Self::PlatformUnsupported => "platform_unsupported",
Self::LockTimeout => "lock_timeout",
Self::Other => "other",
}
}
}
fn classify_game_creator_gui_runner_shutdown_error(
error: &str,
) -> GameCreatorGuiRunnerShutdownFailure {
if error.contains("进程启动身份")
|| error.contains("pid 已")
|| error.contains("pidfd")
|| error.contains("进程句柄")
{
GameCreatorGuiRunnerShutdownFailure::ProcessIdentity
} else if error.contains("当前平台不支持") || error.contains("macOS 不提供") {
GameCreatorGuiRunnerShutdownFailure::PlatformUnsupported
} else if error.contains("实例锁") || error.contains("owner 锁") {
GameCreatorGuiRunnerShutdownFailure::LockTimeout
} else if error.contains("endpoint") {
GameCreatorGuiRunnerShutdownFailure::EndpointUnavailable
} else if error.contains("响应") || error.contains("连接 Agent Runner") {
GameCreatorGuiRunnerShutdownFailure::RunnerUnresponsive
} else {
GameCreatorGuiRunnerShutdownFailure::Other
}
}
fn resolve_game_creator_gui_runner_shutdown<F>(
event: &tauri::RunEvent,
shutdown: F,
) -> GameCreatorGuiRunnerShutdownOutcome
where
F: FnOnce() -> Result<(), String>,
{
if !game_creator_gui_run_event_requests_runner_shutdown(event) {
return GameCreatorGuiRunnerShutdownOutcome::NotRequested;
}
match shutdown() {
Ok(()) => GameCreatorGuiRunnerShutdownOutcome::Requested,
Err(error) => GameCreatorGuiRunnerShutdownOutcome::Failed(
classify_game_creator_gui_runner_shutdown_error(&error),
),
}
}
fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) {
match resolve_game_creator_gui_runner_shutdown(event, shutdown_external_agent_runner) {
GameCreatorGuiRunnerShutdownOutcome::NotRequested => {}
GameCreatorGuiRunnerShutdownOutcome::Requested => {
eprintln!("agent.runner.gui_exit.shutdown_requested")
}
GameCreatorGuiRunnerShutdownOutcome::Failed(failure) => {
eprintln!("agent.runner.gui_exit.shutdown_failed.{}", failure.code())
}
}
}
fn main() {
let mut args = std::env::args().skip(1).collect::<Vec<_>>();
#[cfg(target_os = "linux")]
@@ -1635,16 +1721,22 @@ fn main() {
}
};
if args.first().map(String::as_str) == Some("--agent-runner") {
if args.len() != 1 {
eprintln!("用法:--agent-runner --config-dir <AppData 绝对路径>");
std::process::exit(1);
}
let gui_owner_required = match args.as_slice() {
[_] => false,
[_, option] if option == "--gui-owner-required" => true,
_ => {
eprintln!(
"用法:--agent-runner [--gui-owner-required] --config-dir <AppData 绝对路径>"
);
std::process::exit(1);
}
};
let Some(config_dir) = runtime_config_dir else {
eprintln!("Agent Runner 必须显式传入 --config-dir <AppData 绝对路径>");
std::process::exit(1);
};
set_game_creator_runtime_config_dir(config_dir.clone());
if let Err(error) = run_external_agent_runner_server(config_dir) {
if let Err(error) = run_external_agent_runner_server(config_dir, gui_owner_required) {
eprintln!("agent.runner.failed: {error}");
std::process::exit(1);
}
@@ -1701,7 +1793,7 @@ fn main() {
}
}
tauri::Builder::default()
let app = tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_clipboard_manager::init())
@@ -1714,18 +1806,32 @@ fn main() {
"客户端 AppData 配置目录未初始化",
)
})?;
configure_external_agent_runner(config_dir).map_err(|error| {
configure_external_agent_runner(&config_dir).map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::Other,
format!("配置 Agent Runner 失败:{error}"),
)
})?;
ensure_external_agent_runner_started().map_err(|error| {
let gui_owner_lock = acquire_external_agent_runner_gui_owner_lock(&config_dir)
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!("获取 GUI owner 锁失败:{error}"),
)
})?;
app.manage(gui_owner_lock);
ensure_external_agent_runner_started_for_gui().map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::Other,
format!("启动 Agent Runner 失败:{error}"),
)
})?;
attach_external_agent_runner_gui_owner().map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::Other,
format!("绑定 Agent Runner GUI owner 失败:{error}"),
)
})?;
set_game_creator_agent_runtime_update_app_handle(app.handle().clone());
#[cfg(all(debug_assertions, not(test)))]
if game_chat_launch.is_none() {
@@ -1817,8 +1923,9 @@ fn main() {
update_local_project_resource_canvas_layout,
get_local_game_manifest
])
.run(tauri_context)
.expect("failed to run Genarrative AI Game Creator shell");
.build(tauri_context)
.expect("failed to build Genarrative AI Game Creator shell");
app.run(|_, event| handle_game_creator_gui_run_event(&event));
}
#[cfg(test)]
@@ -8,19 +8,24 @@ mod state;
#[allow(unused_imports)]
pub(crate) use client::{
cancel_external_agent_runner_goal, compact_external_agent_runner_context,
configure_external_agent_runner, configure_external_agent_runner_read_only,
continue_external_agent_runner_action, ensure_external_agent_runner_started,
attach_external_agent_runner_gui_owner, cancel_external_agent_runner_goal,
compact_external_agent_runner_context, configure_external_agent_runner,
configure_external_agent_runner_read_only, continue_external_agent_runner_action,
ensure_external_agent_runner_started, ensure_external_agent_runner_started_for_gui,
notify_external_agent_runner, pause_external_agent_runner,
read_external_agent_runner_mcp_catalog, read_external_agent_runner_status,
require_external_agent_runner_configured_for_cli_runtime_write,
require_external_agent_runner_for_cli_runtime_write, resume_external_agent_runner,
shutdown_external_agent_runner_if_idle, steer_external_agent_runner,
wake_external_agent_runner_pending, wake_external_agent_runner_pending_for_run,
shutdown_external_agent_runner, shutdown_external_agent_runner_if_idle,
steer_external_agent_runner, wake_external_agent_runner_pending,
wake_external_agent_runner_pending_for_run,
};
#[cfg(windows)]
pub(crate) use endpoint::validate_windows_regular_file_handle;
pub(crate) use endpoint::{external_agent_runner_enabled, external_agent_runner_is_server_process};
pub(crate) use endpoint::{
acquire_external_agent_runner_gui_owner_lock, external_agent_runner_enabled,
external_agent_runner_is_server_process,
};
#[allow(unused_imports)]
pub(crate) use protocol::{ExternalAgentRunnerStatus, EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION};
pub(crate) use server::{
File diff suppressed because it is too large Load Diff
@@ -584,6 +584,39 @@ pub(super) fn dispatch_external_agent_runner_runtime_request(
}
}
}
"runner.attach_gui_owner" => {
match external_agent_runner_gui_owner_is_locked(&state.gui_owner_lock_path) {
Ok(true) => {
state.gui_owner_attached.store(true, Ordering::Release);
ExternalAgentRunnerResponse::success(
&request.request_id,
json!({ "attached": true }),
)
}
Ok(false) => ExternalAgentRunnerResponse::failure(
&request.request_id,
"gui-owner-missing",
"Agent Runner 未检测到活跃 GUI owner 锁",
),
Err(error) => ExternalAgentRunnerResponse::failure(
&request.request_id,
"gui-owner-unreadable",
redact_runner_secret(&error, &token),
),
}
}
"runner.shutdown" | "shutdown" => {
let provider_requests_interrupted =
request_external_agent_runner_forced_shutdown(state);
ExternalAgentRunnerResponse::success(
&request.request_id,
json!({
"accepted": true,
"willShutdown": true,
"providerRequestsInterrupted": provider_requests_interrupted,
}),
)
}
"runner.shutdown_if_idle" | "shutdown_if_idle" => {
if request.params.root.is_some() {
match external_agent_runner_request_root(request) {
@@ -748,6 +781,9 @@ pub(super) fn handle_external_agent_runner_request(
| "runtime.pause"
| "runtime.cancel"
| "runtime.compact"
| "runner.attach_gui_owner"
| "runner.shutdown"
| "shutdown"
| "runner.shutdown_if_idle"
| "shutdown_if_idle" => dispatch_external_agent_runner_runtime_request(&request, state),
_ => ExternalAgentRunnerResponse::failure(
@@ -758,6 +794,21 @@ pub(super) fn handle_external_agent_runner_request(
}
}
pub(super) fn request_external_agent_runner_forced_shutdown(
state: &ExternalAgentRunnerServerState,
) -> usize {
state.draining.store(true, Ordering::Release);
let roots = state.known_roots_snapshot();
let provider_requests_interrupted =
crate::interrupt_game_creator_agent_runtime_provider_requests_for_roots(&roots);
crate::shutdown_all_process_sessions();
state
.force_shutdown_requested
.store(true, Ordering::Release);
state.shutdown_requested.store(true, Ordering::Release);
provider_requests_interrupted
}
pub(super) fn external_agent_runner_runtime_state_is_idle(status: &str, phase: &str) -> bool {
if matches!(phase, "completed" | "cancelled" | "failed" | "paused") {
return true;
@@ -40,6 +40,151 @@ pub(super) fn unix_millis() -> u64 {
.min(u64::MAX as u128) as u64
}
pub(super) fn external_agent_runner_process_start_identity(
pid: u32,
) -> Result<Option<String>, String> {
#[cfg(target_os = "linux")]
{
let stat = fs::read_to_string(format!("/proc/{pid}/stat"))
.map_err(|error| format!("读取 Agent Runner 进程启动身份失败:{error}"))?;
let tail = stat
.rsplit_once(") ")
.map(|(_, tail)| tail)
.ok_or_else(|| "解析 Agent Runner 进程启动身份失败".to_string())?;
let start_time = tail
.split_whitespace()
.nth(19)
.filter(|value| !value.is_empty())
.ok_or_else(|| "Agent Runner 进程启动身份缺失".to_string())?;
return Ok(Some(start_time.to_string()));
}
#[cfg(windows)]
{
use std::ffi::c_void;
#[repr(C)]
struct FileTime {
low_date_time: u32,
high_date_time: u32,
}
#[link(name = "kernel32")]
unsafe extern "system" {
fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> *mut c_void;
fn GetProcessTimes(
process: *mut c_void,
creation_time: *mut FileTime,
exit_time: *mut FileTime,
kernel_time: *mut FileTime,
user_time: *mut FileTime,
) -> i32;
fn CloseHandle(handle: *mut c_void) -> i32;
}
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
// SAFETY: OpenProcess returns an owned kernel handle or null; it is closed below.
let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if process.is_null() {
return Err(format!(
"打开 Agent Runner 进程启动身份失败:{}",
io::Error::last_os_error()
));
}
// SAFETY: FileTime is plain data filled by GetProcessTimes.
let mut creation = unsafe { std::mem::zeroed::<FileTime>() };
let mut exit = unsafe { std::mem::zeroed::<FileTime>() };
let mut kernel = unsafe { std::mem::zeroed::<FileTime>() };
let mut user = unsafe { std::mem::zeroed::<FileTime>() };
// SAFETY: process is live and all output pointers refer to writable FileTime values.
let result =
unsafe { GetProcessTimes(process, &mut creation, &mut exit, &mut kernel, &mut user) };
// SAFETY: process is an owned non-null handle returned by OpenProcess.
unsafe { CloseHandle(process) };
if result == 0 {
return Err(format!(
"读取 Agent Runner 进程启动身份失败:{}",
io::Error::last_os_error()
));
}
return Ok(Some(
((creation.high_date_time as u64) << 32 | creation.low_date_time as u64).to_string(),
));
}
#[cfg(target_os = "macos")]
{
#[repr(C)]
struct ProcBsdInfo {
pbi_flags: u32,
pbi_status: u32,
pbi_xstatus: u32,
pbi_pid: u32,
pbi_ppid: u32,
pbi_uid: u32,
pbi_gid: u32,
pbi_ruid: u32,
pbi_rgid: u32,
pbi_svuid: u32,
pbi_svgid: u32,
rfu_1: u32,
pbi_comm: [u8; 16],
pbi_name: [u8; 32],
pbi_nfiles: u32,
pbi_pgid: u32,
pbi_pjobc: u32,
e_tdev: u32,
e_tpgid: u32,
pbi_nice: i32,
pbi_start_tvsec: u64,
pbi_start_tvusec: u64,
}
#[link(name = "proc")]
unsafe extern "C" {
fn proc_pidinfo(
pid: i32,
flavor: i32,
arg: u64,
buffer: *mut std::ffi::c_void,
buffer_size: i32,
) -> i32;
}
const PROC_PIDTBSDINFO: i32 = 3;
let pid = i32::try_from(pid)
.map_err(|_| "Agent Runner pid 超出 macOS proc_pidinfo 范围".to_string())?;
// SAFETY: ProcBsdInfo is plain data filled by proc_pidinfo.
let mut info = unsafe { std::mem::zeroed::<ProcBsdInfo>() };
let expected_size = std::mem::size_of::<ProcBsdInfo>();
let read = unsafe {
proc_pidinfo(
pid,
PROC_PIDTBSDINFO,
0,
(&mut info as *mut ProcBsdInfo).cast(),
expected_size as i32,
)
};
if read != expected_size as i32 {
return Err(format!(
"读取 Agent Runner macOS 进程启动身份失败:{}",
io::Error::last_os_error()
));
}
return Ok(Some(format!(
"{}:{}",
info.pbi_start_tvsec, info.pbi_start_tvusec
)));
}
#[cfg(not(any(target_os = "linux", windows, target_os = "macos")))]
{
let _ = pid;
Ok(None)
}
}
pub(super) fn fill_secure_random(bytes: &mut [u8]) -> io::Result<()> {
#[cfg(unix)]
{
@@ -160,6 +305,20 @@ pub(super) fn external_agent_runner_lock_path(config_dir: &Path) -> PathBuf {
config_dir.join(EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME)
}
pub(super) fn external_agent_runner_gui_owner_lock_path(config_dir: &Path) -> PathBuf {
config_dir.join(EXTERNAL_AGENT_RUNNER_GUI_OWNER_LOCK_FILE_NAME)
}
pub(super) fn external_agent_runner_gui_owner_is_locked(path: &Path) -> Result<bool, String> {
match try_open_external_agent_runner_lock(path, "Agent Runner GUI owner 锁")? {
Some(lock) => {
drop(lock);
Ok(false)
}
None => Ok(true),
}
}
pub(super) fn private_create_new_file(path: &Path) -> io::Result<File> {
#[cfg(unix)]
{
@@ -445,6 +604,9 @@ pub(super) fn validate_external_agent_runner_endpoint_metadata(
if metadata.uid() != effective_user_id {
return Err("Agent Runner endpoint 不属于当前用户".to_string());
}
if metadata.nlink() != 1 {
return Err("Agent Runner endpoint 不能是硬链接".to_string());
}
let path_metadata = fs::symlink_metadata(path).map_err(|error| {
format!(
"复核 Agent Runner endpoint 路径失败:{}: {error}",
@@ -657,6 +819,7 @@ pub(super) fn acquire_external_agent_runner_instance_lock(
"protocolVersion": EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
"pid": std::process::id(),
"bootId": boot_id,
"processStartIdentity": external_agent_runner_process_start_identity(std::process::id())?,
"startedAt": unix_millis(),
}))
.map_err(|error| format!("生成 Agent Runner 单实例锁信息失败:{error}"))?;
@@ -672,3 +835,30 @@ pub(super) fn acquire_external_agent_runner_instance_lock(
})?;
Ok(ExternalAgentRunnerInstanceLock { _file: file })
}
pub(crate) fn acquire_external_agent_runner_gui_owner_lock(
config_dir: &Path,
) -> Result<ExternalAgentRunnerGuiOwnerLock, String> {
let path = external_agent_runner_gui_owner_lock_path(config_dir);
let Some(mut file) = try_open_external_agent_runner_lock(&path, "Agent Runner GUI owner 锁")?
else {
return Err("AI 游戏创作界面已由同一 AppData 目录中的其他进程运行".to_string());
};
let diagnostic = serde_json::to_vec(&json!({
"protocolVersion": EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
"pid": std::process::id(),
"acquiredAt": unix_millis(),
}))
.map_err(|error| format!("生成 Agent Runner GUI owner 锁信息失败:{error}"))?;
file.set_len(0)
.and_then(|_| file.seek(SeekFrom::Start(0)).map(|_| ()))
.and_then(|_| file.write_all(&diagnostic))
.and_then(|_| file.sync_data())
.map_err(|error| {
format!(
"写入 Agent Runner GUI owner 锁信息失败:{}: {error}",
path.display()
)
})?;
Ok(ExternalAgentRunnerGuiOwnerLock { _file: file })
}
@@ -13,6 +13,8 @@ pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 4;
pub(super) const EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json";
pub(super) const EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock";
pub(super) const EXTERNAL_AGENT_RUNNER_GUI_OWNER_LOCK_FILE_NAME: &str =
"agent-runner.gui-owner.lock";
pub(super) const EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_FILE_NAME: &str = "execution-owner.lock";
pub(super) const EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_DIAGNOSTIC_FILE_NAME: &str =
"execution-owner.json";
@@ -28,12 +30,27 @@ pub(super) const EXTERNAL_AGENT_RUNNER_MAX_CACHED_REQUESTS: usize = 512;
pub(super) const EXTERNAL_AGENT_RUNNER_RETRYABLE_WAKE_ERROR_CODE: &str = "runtime-wake-retryable";
pub(super) const EXTERNAL_AGENT_RUNNER_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
pub(super) const EXTERNAL_AGENT_RUNNER_IO_TIMEOUT: Duration = Duration::from_secs(10);
pub(super) const EXTERNAL_AGENT_RUNNER_GUI_SHUTDOWN_CONNECT_TIMEOUT: Duration =
Duration::from_millis(250);
pub(super) const EXTERNAL_AGENT_RUNNER_GUI_SHUTDOWN_IO_TIMEOUT: Duration =
Duration::from_millis(750);
pub(super) const EXTERNAL_AGENT_RUNNER_FORCED_WORKER_DRAIN_TIMEOUT: Duration =
Duration::from_millis(250);
pub(super) const EXTERNAL_AGENT_RUNNER_FORCED_TOTAL_DRAIN_TIMEOUT: Duration =
Duration::from_millis(1_500);
pub(super) const EXTERNAL_AGENT_RUNNER_GUI_FORCE_TERMINATE_GRACE: Duration =
Duration::from_millis(500);
pub(super) const EXTERNAL_AGENT_RUNNER_GUI_SHUTDOWN_EXIT_TIMEOUT: Duration = Duration::from_secs(2);
pub(super) const EXTERNAL_AGENT_RUNNER_CONTEXT_COMPACTION_IO_TIMEOUT: Duration =
Duration::from_secs(6 * 60);
pub(super) const EXTERNAL_AGENT_RUNNER_MCP_STATUS_IO_TIMEOUT: Duration =
Duration::from_secs(6 * 60);
pub(super) const EXTERNAL_AGENT_RUNNER_START_TIMEOUT: Duration = Duration::from_secs(30);
pub(super) const EXTERNAL_AGENT_RUNNER_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(2);
pub(super) const EXTERNAL_AGENT_RUNNER_GUI_OWNER_CHECK_INTERVAL: Duration =
Duration::from_millis(100);
pub(super) const EXTERNAL_AGENT_RUNNER_GUI_OWNER_WATCHDOG_HARD_EXIT_TIMEOUT: Duration =
Duration::from_millis(1_750);
pub(super) const EXTERNAL_AGENT_RUNNER_LOOP_INTERVAL: Duration = Duration::from_millis(25);
#[cfg(target_os = "linux")]
pub(super) const EXTERNAL_AGENT_RUNNER_LINUX_EPHEMERAL_PORT_RANGE_PATH: &str =
@@ -52,6 +69,8 @@ pub(super) static EXTERNAL_AGENT_RUNNER_CONFIG_DIR: OnceLock<Mutex<Option<PathBu
pub(super) static EXTERNAL_AGENT_RUNNER_CONFIGURE_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
pub(super) static EXTERNAL_AGENT_RUNNER_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
pub(super) static EXTERNAL_AGENT_RUNNER_SERVER_PROCESS: AtomicBool = AtomicBool::new(false);
pub(super) static EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT: AtomicBool =
AtomicBool::new(false);
pub(super) static EXTERNAL_AGENT_RUNNER_EXECUTABLE_FINGERPRINT: OnceLock<String> = OnceLock::new();
#[derive(Clone, Deserialize, Serialize)]
@@ -65,6 +84,8 @@ pub(super) struct ExternalAgentRunnerEndpoint {
pub(super) heartbeat_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) executable_fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) process_start_identity: Option<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -122,6 +143,13 @@ impl ExternalAgentRunnerEndpoint {
}) {
return Err("Agent Runner endpoint executableFingerprint 无效".to_string());
}
if self
.process_start_identity
.as_deref()
.is_some_and(|value| value.is_empty() || value.len() > 128)
{
return Err("Agent Runner endpoint processStartIdentity 无效".to_string());
}
Ok(())
}
}
@@ -3,7 +3,7 @@ use sha2::{Digest as _, Sha256};
use std::fs;
use std::io;
use std::net::{Ipv4Addr, SocketAddrV4, TcpListener};
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::thread;
@@ -152,9 +152,70 @@ pub(crate) fn bind_loopback_listener_with_linux_fallback(seed: &str) -> io::Resu
}
}
pub(crate) fn run_external_agent_runner_server(config_dir: impl AsRef<Path>) -> Result<(), String> {
#[cfg(test)]
pub(super) fn external_agent_runner_shutdown_if_gui_owner_lost(
state: &ExternalAgentRunnerServerState,
) -> Result<bool, String> {
if !state.gui_owner_attached.load(Ordering::Acquire) {
return Ok(false);
}
if external_agent_runner_gui_owner_is_locked(&state.gui_owner_lock_path)? {
return Ok(false);
}
request_external_agent_runner_forced_shutdown(state);
Ok(true)
}
pub(super) fn spawn_external_agent_runner_gui_owner_watchdog(
state: Arc<ExternalAgentRunnerServerState>,
endpoint_path: PathBuf,
boot_id: String,
) -> Result<(), String> {
thread::Builder::new()
.name("agent-runner-gui-owner-watchdog".to_string())
.spawn(move || loop {
if !state.gui_owner_attached.load(Ordering::Acquire) {
thread::sleep(EXTERNAL_AGENT_RUNNER_GUI_OWNER_CHECK_INTERVAL);
continue;
}
let owner_lost =
match external_agent_runner_gui_owner_is_locked(&state.gui_owner_lock_path) {
Ok(locked) => !locked,
Err(_) => true,
};
if !owner_lost {
thread::sleep(EXTERNAL_AGENT_RUNNER_GUI_OWNER_CHECK_INTERVAL);
continue;
}
state.draining.store(true, Ordering::Release);
state
.force_shutdown_requested
.store(true, Ordering::Release);
state.shutdown_requested.store(true, Ordering::Release);
thread::sleep(EXTERNAL_AGENT_RUNNER_GUI_OWNER_WATCHDOG_HARD_EXIT_TIMEOUT);
remove_external_agent_runner_endpoint_if_boot_matches(&endpoint_path, &boot_id);
std::process::exit(1);
})
.map(|_| ())
.map_err(|error| format!("启动 Agent Runner GUI owner watchdog 失败:{error}"))
}
pub(super) fn resolve_external_agent_runner_initial_gui_owner(
gui_owner_required: bool,
gui_owner_present: bool,
) -> Result<bool, String> {
if gui_owner_required && !gui_owner_present {
return Err("GUI owner 在 Agent Runner 启动完成前已释放".to_string());
}
Ok(gui_owner_present)
}
pub(crate) fn run_external_agent_runner_server(
config_dir: impl AsRef<Path>,
gui_owner_required: bool,
) -> Result<(), String> {
let config_dir = normalize_external_agent_runner_config_dir(config_dir.as_ref())?;
let executable_fingerprint = current_external_agent_runner_executable_fingerprint()?;
EXTERNAL_AGENT_RUNNER_SERVER_PROCESS.store(true, Ordering::Release);
crate::set_game_creator_runtime_config_dir(config_dir.clone());
set_external_agent_runner_config_dir(config_dir.clone());
@@ -166,6 +227,13 @@ pub(crate) fn run_external_agent_runner_server(config_dir: impl AsRef<Path>) ->
&external_agent_runner_lock_path(&config_dir),
&boot_id,
)?;
let gui_owner_present_at_start = resolve_external_agent_runner_initial_gui_owner(
gui_owner_required,
external_agent_runner_gui_owner_is_locked(&external_agent_runner_gui_owner_lock_path(
&config_dir,
))?,
)?;
let executable_fingerprint = current_external_agent_runner_executable_fingerprint()?;
let listener = bind_loopback_listener_with_linux_fallback(&boot_id)
.map_err(|error| format!("绑定 Agent Runner loopback 端口失败:{error}"))?;
listener
@@ -183,6 +251,7 @@ pub(crate) fn run_external_agent_runner_server(config_dir: impl AsRef<Path>) ->
token,
heartbeat_at: unix_millis(),
executable_fingerprint: Some(executable_fingerprint),
process_start_identity: external_agent_runner_process_start_identity(std::process::id())?,
};
let endpoint_path = external_agent_runner_endpoint_path(&config_dir);
write_external_agent_runner_endpoint_atomic(&endpoint_path, &endpoint)?;
@@ -191,12 +260,22 @@ pub(crate) fn run_external_agent_runner_server(config_dir: impl AsRef<Path>) ->
boot_id,
};
let state = Arc::new(ExternalAgentRunnerServerState::new(endpoint_path, endpoint));
state
.gui_owner_attached
.store(gui_owner_present_at_start, Ordering::Release);
spawn_external_agent_runner_gui_owner_watchdog(
Arc::clone(&state),
state.endpoint_path.clone(),
state.endpoint_snapshot().boot_id,
)?;
let mut last_heartbeat = Instant::now();
let mut server_error = None;
loop {
if state.shutdown_requested.load(Ordering::Acquire) {
if state.active_connections.load(Ordering::Acquire) == 0 {
if state.force_shutdown_requested.load(Ordering::Acquire)
|| state.active_connections.load(Ordering::Acquire) == 0
{
break;
}
thread::sleep(EXTERNAL_AGENT_RUNNER_LOOP_INTERVAL);
@@ -240,11 +319,43 @@ pub(crate) fn run_external_agent_runner_server(config_dir: impl AsRef<Path>) ->
}
state.shutdown_requested.store(true, Ordering::Release);
let worker_deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_IO_TIMEOUT;
let forced = state.force_shutdown_requested.load(Ordering::Acquire);
let forced_deadline =
forced.then(|| Instant::now() + EXTERNAL_AGENT_RUNNER_FORCED_TOTAL_DRAIN_TIMEOUT);
let worker_deadline = Instant::now()
+ if forced {
EXTERNAL_AGENT_RUNNER_FORCED_WORKER_DRAIN_TIMEOUT
} else {
EXTERNAL_AGENT_RUNNER_IO_TIMEOUT
};
while state.active_connections.load(Ordering::Acquire) > 0 && Instant::now() < worker_deadline {
thread::sleep(EXTERNAL_AGENT_RUNNER_LOOP_INTERVAL);
}
let process_shutdown = crate::shutdown_all_process_sessions_and_wait(Duration::from_secs(3));
let forced_roots = forced.then(|| {
let roots = state.known_roots_snapshot();
crate::interrupt_game_creator_agent_runtime_provider_requests_for_roots(&roots);
roots
});
let process_timeout = forced_deadline
.map(|deadline| deadline.saturating_duration_since(Instant::now()))
.unwrap_or(Duration::from_secs(3));
let mut process_shutdown = crate::shutdown_all_process_sessions_and_wait(process_timeout);
if forced {
let roots = forced_roots.as_deref().unwrap_or_default();
let provider_deadline = forced_deadline.expect("forced shutdown has a deadline");
while crate::game_creator_agent_runtime_provider_request_count_for_roots(roots) > 0
&& Instant::now() < provider_deadline
{
thread::sleep(EXTERNAL_AGENT_RUNNER_LOOP_INTERVAL);
}
if crate::game_creator_agent_runtime_provider_request_count_for_roots(roots) > 0 {
let provider_error = "Runner 退出前未能中断全部 Provider 请求".to_string();
process_shutdown = Err(match process_shutdown {
Ok(()) => provider_error,
Err(process_error) => format!("{process_error};{provider_error}"),
});
}
}
if let Some(error) = server_error {
Err(match process_shutdown {
Ok(()) => error,
@@ -9,9 +9,12 @@ pub(super) struct ExternalAgentRunnerServerState {
pub(super) endpoint_path: PathBuf,
pub(super) endpoint: Mutex<ExternalAgentRunnerEndpoint>,
pub(super) shutdown_requested: AtomicBool,
pub(super) force_shutdown_requested: AtomicBool,
pub(super) gui_owner_attached: AtomicBool,
pub(super) draining: AtomicBool,
pub(super) active_connections: AtomicUsize,
pub(super) known_roots: Mutex<BTreeSet<PathBuf>>,
pub(super) gui_owner_lock_path: PathBuf,
pub(super) project_execution_owners:
Mutex<BTreeMap<PathBuf, ExternalAgentRunnerProjectExecutionOwner>>,
pub(super) write_request_cache: Mutex<ExternalAgentRunnerRequestCache>,
@@ -19,13 +22,20 @@ pub(super) struct ExternalAgentRunnerServerState {
impl ExternalAgentRunnerServerState {
pub(super) fn new(endpoint_path: PathBuf, endpoint: ExternalAgentRunnerEndpoint) -> Self {
let gui_owner_lock_path = endpoint_path
.parent()
.map(external_agent_runner_gui_owner_lock_path)
.unwrap_or_else(|| PathBuf::from(EXTERNAL_AGENT_RUNNER_GUI_OWNER_LOCK_FILE_NAME));
Self {
endpoint_path,
endpoint: Mutex::new(endpoint),
shutdown_requested: AtomicBool::new(false),
force_shutdown_requested: AtomicBool::new(false),
gui_owner_attached: AtomicBool::new(false),
draining: AtomicBool::new(false),
active_connections: AtomicUsize::new(0),
known_roots: Mutex::new(BTreeSet::new()),
gui_owner_lock_path,
project_execution_owners: Mutex::new(BTreeMap::new()),
write_request_cache: Mutex::new(ExternalAgentRunnerRequestCache::default()),
}
@@ -43,6 +53,10 @@ impl ExternalAgentRunnerServerState {
lock_unpoisoned(&self.known_roots).insert(root.to_path_buf());
}
pub(super) fn known_roots_snapshot(&self) -> Vec<PathBuf> {
lock_unpoisoned(&self.known_roots).iter().cloned().collect()
}
pub(super) fn claim_project_execution_owner(&self, root: &Path) -> Result<PathBuf, String> {
let root = canonicalize_external_agent_runner_project_root(root)?;
let config_dir = self
@@ -82,6 +96,11 @@ pub(super) struct ExternalAgentRunnerInstanceLock {
pub(super) _file: File,
}
#[derive(Debug)]
pub(crate) struct ExternalAgentRunnerGuiOwnerLock {
pub(super) _file: File,
}
pub(super) struct ExternalAgentRunnerProjectOwnerStorage {
pub(super) lock_file: File,
pub(super) directory_handles: Vec<File>,
@@ -108,14 +127,18 @@ pub(super) struct ExternalAgentRunnerEndpointGuard {
pub(super) boot_id: String,
}
pub(super) fn remove_external_agent_runner_endpoint_if_boot_matches(path: &Path, boot_id: &str) {
let Ok(endpoint) = read_external_agent_runner_endpoint(path) else {
return;
};
if endpoint.boot_id == boot_id {
let _ = fs::remove_file(path);
}
}
impl Drop for ExternalAgentRunnerEndpointGuard {
fn drop(&mut self) {
let Ok(endpoint) = read_external_agent_runner_endpoint(&self.path) else {
return;
};
if endpoint.boot_id == self.boot_id {
let _ = fs::remove_file(&self.path);
}
remove_external_agent_runner_endpoint_if_boot_matches(&self.path, &self.boot_id);
}
}
@@ -4,11 +4,13 @@ use super::{
use serde_json::{json, Value};
use sha2::{Digest as _, Sha256};
use std::collections::BTreeSet;
use std::ffi::OsString;
use std::fs;
use std::io::{self, Cursor};
use std::net::{Ipv4Addr, SocketAddrV4, TcpListener};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use std::time::{Duration, Instant};
static TEST_DIRECTORY_COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -89,9 +91,60 @@ fn test_endpoint(token: &str, boot_id: &str, port: u16) -> ExternalAgentRunnerEn
token: token.to_string(),
heartbeat_at: 1_725_000_000_000,
executable_fingerprint: Some("a".repeat(64)),
process_start_identity: None,
}
}
fn spawn_identity_ping_fixture(
token: &str,
endpoint_boot_id: &str,
response_boot_id: &str,
) -> (ExternalAgentRunnerEndpoint, std::thread::JoinHandle<()>) {
let listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0))
.expect("bind identity ping fixture");
let port = listener
.local_addr()
.expect("identity fixture address")
.port();
let endpoint = test_endpoint(token, endpoint_boot_id, port);
let response_boot_id = response_boot_id.to_string();
let handle = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept identity ping");
let payload = read_external_agent_runner_frame(&mut stream).expect("read identity ping");
let request = serde_json::from_slice::<ExternalAgentRunnerRequest>(&payload)
.expect("parse identity ping");
let response = ExternalAgentRunnerResponse::success(
&request.request_id,
json!({
"status": "ok",
"pid": std::process::id(),
"bootId": response_boot_id,
}),
);
let response = serde_json::to_vec(&response).expect("serialize identity ping response");
write_external_agent_runner_frame(&mut stream, &response)
.expect("write identity ping response");
});
(endpoint, handle)
}
#[test]
fn legacy_runner_force_migration_requires_authenticated_exact_ping_identity() {
let token = "legacy-ping-token-legacy-ping-token";
let (endpoint, server) =
spawn_identity_ping_fixture(token, "legacy-ping-boot", "legacy-ping-boot");
verify_external_agent_runner_ping_identity(&endpoint)
.expect("matching authenticated ping authorizes legacy identity");
server.join().expect("join matching identity fixture");
let (endpoint, server) =
spawn_identity_ping_fixture(token, "legacy-ping-boot", "different-boot");
let error = verify_external_agent_runner_ping_identity(&endpoint)
.expect_err("mismatched boot must reject legacy identity");
assert!(error.contains("身份与 endpoint 不匹配"));
server.join().expect("join mismatched identity fixture");
}
#[test]
fn endpoint_shape_accepts_legacy_missing_fingerprint_but_rejects_malformed_values() {
let endpoint = test_endpoint(
@@ -139,6 +192,52 @@ fn runner_start_timeout_covers_cold_debug_binary_fingerprinting() {
assert!(EXTERNAL_AGENT_RUNNER_START_TIMEOUT >= Duration::from_secs(30));
}
#[test]
fn forced_runner_drain_deadline_precedes_gui_hard_kill_deadline() {
assert!(
EXTERNAL_AGENT_RUNNER_FORCED_WORKER_DRAIN_TIMEOUT
< EXTERNAL_AGENT_RUNNER_FORCED_TOTAL_DRAIN_TIMEOUT
);
assert!(
EXTERNAL_AGENT_RUNNER_FORCED_TOTAL_DRAIN_TIMEOUT
< EXTERNAL_AGENT_RUNNER_GUI_SHUTDOWN_EXIT_TIMEOUT
);
assert!(
EXTERNAL_AGENT_RUNNER_FORCED_TOTAL_DRAIN_TIMEOUT
< EXTERNAL_AGENT_RUNNER_GUI_OWNER_WATCHDOG_HARD_EXIT_TIMEOUT
);
}
#[test]
fn gui_owned_runner_rejects_start_after_owner_was_already_lost() {
assert!(resolve_external_agent_runner_initial_gui_owner(true, false).is_err());
assert!(resolve_external_agent_runner_initial_gui_owner(true, true).unwrap());
assert!(!resolve_external_agent_runner_initial_gui_owner(false, false).unwrap());
}
#[test]
fn gui_runner_launch_arguments_bind_owner_requirement_to_gui_launches_only() {
let config_dir = Path::new("/private/app-data");
let gui_arguments = external_agent_runner_launch_arguments(config_dir, true);
let cli_arguments = external_agent_runner_launch_arguments(config_dir, false);
assert_eq!(
gui_arguments,
vec![
"--agent-runner",
"--config-dir",
"/private/app-data",
"--gui-owner-required"
]
.into_iter()
.map(OsString::from)
.collect::<Vec<_>>()
);
assert!(!cli_arguments
.iter()
.any(|argument| argument == "--gui-owner-required"));
}
#[test]
fn endpoint_reuse_requires_current_protocol_and_executable_identity() {
let current_fingerprint = "b".repeat(64);
@@ -178,6 +277,144 @@ fn idle_runner_shutdown_treats_missing_endpoint_as_already_stopped() {
.expect("missing endpoint should already be stopped"));
}
#[test]
fn gui_runner_shutdown_rejects_missing_endpoint_while_runner_lock_is_held() {
let directory = unique_test_directory();
let _lock = acquire_external_agent_runner_instance_lock(
&external_agent_runner_lock_path(&directory.0),
"gui-shutdown-held-lock",
)
.expect("hold runner lock without endpoint");
let started = Instant::now();
let error = shutdown_external_agent_runner_at(&directory.0)
.expect_err("held Runner lock means missing endpoint is not proof of shutdown");
assert!(error.contains("实例锁仍被占用"));
assert!(
started.elapsed() < Duration::from_secs(2),
"GUI shutdown must not inherit the 30 second runner startup wait"
);
}
#[test]
fn gui_runner_shutdown_has_a_short_hard_timeout_for_an_unresponsive_endpoint() {
let directory = unique_test_directory();
let listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0))
.expect("bind unresponsive runner fixture");
let port = listener.local_addr().expect("fixture address").port();
let endpoint = test_endpoint(
"gui-timeout-private-token-gui-timeout-private-token",
"gui-timeout-boot-id",
port,
);
write_external_agent_runner_endpoint_atomic(
&external_agent_runner_endpoint_path(&directory.0),
&endpoint,
)
.expect("write unresponsive endpoint");
std::thread::spawn(move || {
let (_stream, _) = listener.accept().expect("accept GUI shutdown request");
std::thread::sleep(Duration::from_secs(2));
});
let started = Instant::now();
let error = shutdown_external_agent_runner_at(&directory.0)
.expect_err("unresponsive runner must hit the GUI shutdown deadline");
assert!(error.contains("读取 Agent Runner 响应失败"));
assert!(
started.elapsed() < Duration::from_secs(2),
"GUI shutdown must not block on the normal 10/30 second runner deadlines"
);
}
#[cfg(target_os = "linux")]
#[test]
fn gui_runner_shutdown_terminates_a_verified_unresponsive_runner_process() {
let directory = unique_test_directory();
let mut runner_process = std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn unresponsive runner process fixture");
let listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0))
.expect("bind unresponsive runner fixture");
let port = listener.local_addr().expect("fixture address").port();
let mut endpoint = test_endpoint(
"gui-force-private-token-gui-force-private-token",
"gui-force-boot-id",
port,
);
endpoint.pid = runner_process.id();
endpoint.process_start_identity =
external_agent_runner_process_start_identity(runner_process.id())
.expect("read runner process start identity");
write_external_agent_runner_endpoint_atomic(
&external_agent_runner_endpoint_path(&directory.0),
&endpoint,
)
.expect("write unresponsive endpoint");
std::thread::spawn(move || {
let (_stream, _) = listener.accept().expect("accept GUI shutdown request");
std::thread::sleep(Duration::from_secs(2));
});
let started = Instant::now();
shutdown_external_agent_runner_at(&directory.0)
.expect("verified unresponsive Runner must be terminated");
let status = runner_process
.wait()
.expect("reap terminated Runner fixture");
assert!(!status.success());
assert!(!external_agent_runner_endpoint_path(&directory.0).exists());
assert!(
started.elapsed() < Duration::from_secs(2),
"forced GUI shutdown must remain bounded"
);
}
#[cfg(target_os = "linux")]
#[test]
fn gui_runner_shutdown_fails_closed_without_exact_process_start_identity() {
let directory = unique_test_directory();
let mut candidate = std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn candidate process fixture");
let mut endpoint = test_endpoint(
"legacy-force-private-token-legacy-force-private-token",
"legacy-force-boot-id",
9,
);
endpoint.pid = candidate.id();
endpoint.process_start_identity = None;
write_external_agent_runner_endpoint_atomic(
&external_agent_runner_endpoint_path(&directory.0),
&endpoint,
)
.expect("write legacy endpoint");
let legacy_error = shutdown_external_agent_runner_at(&directory.0)
.expect_err("legacy endpoint must not authorize process termination");
assert!(legacy_error.contains("安全迁移失败") || legacy_error.contains("连接 Agent Runner"));
assert!(candidate.try_wait().expect("probe candidate").is_none());
endpoint.process_start_identity = Some("not-the-candidate-start-time".to_string());
write_external_agent_runner_endpoint_atomic(
&external_agent_runner_endpoint_path(&directory.0),
&endpoint,
)
.expect("write mismatched endpoint");
let mismatch_error = shutdown_external_agent_runner_at(&directory.0)
.expect_err("mismatched process identity must not authorize termination");
assert!(mismatch_error.contains("pid 已被其他进程复用"));
assert!(candidate.try_wait().expect("probe candidate").is_none());
candidate.kill().expect("stop candidate fixture");
candidate.wait().expect("reap candidate fixture");
}
#[cfg(unix)]
#[test]
fn idle_runner_shutdown_rejects_symlinked_endpoint() {
@@ -197,6 +434,104 @@ fn idle_runner_shutdown_rejects_symlinked_endpoint() {
assert!(error.contains("符号链接"));
}
#[cfg(unix)]
#[test]
fn runner_endpoint_rejects_hard_links() {
let directory = unique_test_directory();
let endpoint_path = external_agent_runner_endpoint_path(&directory.0);
write_external_agent_runner_endpoint_atomic(
&endpoint_path,
&test_endpoint(
"hardlink-endpoint-token-hardlink-endpoint-token",
"hardlink-endpoint-boot",
31318,
),
)
.expect("write endpoint");
fs::hard_link(&endpoint_path, directory.0.join("endpoint-hardlink.json"))
.expect("create endpoint hard link");
let error = match read_external_agent_runner_endpoint(&endpoint_path) {
Ok(_) => panic!("hard-linked endpoint must be rejected"),
Err(error) => error,
};
assert!(error.contains("硬链接"));
}
#[test]
fn gui_owner_lock_allows_only_one_frontend_process_per_appdata() {
let directory = unique_test_directory();
let first =
acquire_external_agent_runner_gui_owner_lock(&directory.0).expect("first GUI owns AppData");
let error = acquire_external_agent_runner_gui_owner_lock(&directory.0)
.expect_err("second GUI must not share the same Runner owner");
assert!(error.contains("其他进程运行"));
drop(first);
acquire_external_agent_runner_gui_owner_lock(&directory.0)
.expect("GUI owner lock is recoverable after the first frontend exits");
}
#[test]
fn attached_gui_owner_loss_forces_runner_shutdown() {
let directory = unique_test_directory();
let token = "gui-owner-monitor-token-gui-owner-monitor-token";
let state = ExternalAgentRunnerServerState::new(
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(token, "gui-owner-monitor-boot", 31319),
);
let owner =
acquire_external_agent_runner_gui_owner_lock(&directory.0).expect("acquire GUI owner lock");
let attached = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "gui-owner-attach-1".to_string(),
token: token.to_string(),
method: "runner.attach_gui_owner".to_string(),
params: ExternalAgentRunnerRequestParams::default(),
},
&state,
);
assert!(attached.ok);
assert!(state.gui_owner_attached.load(Ordering::Acquire));
assert!(
!external_agent_runner_shutdown_if_gui_owner_lost(&state).expect("owner remains present")
);
drop(owner);
assert!(external_agent_runner_shutdown_if_gui_owner_lost(&state)
.expect("owner loss requests shutdown"));
assert!(state.draining.load(Ordering::Acquire));
assert!(state.force_shutdown_requested.load(Ordering::Acquire));
assert!(state.shutdown_requested.load(Ordering::Acquire));
}
#[test]
fn gui_owner_attach_rejects_missing_owner_lock() {
let directory = unique_test_directory();
let token = "gui-owner-missing-token-gui-owner-missing-token";
let state = ExternalAgentRunnerServerState::new(
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(token, "gui-owner-missing-boot", 31320),
);
let response = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "gui-owner-attach-missing-1".to_string(),
token: token.to_string(),
method: "runner.attach_gui_owner".to_string(),
params: ExternalAgentRunnerRequestParams::default(),
},
&state,
);
assert!(!response.ok);
assert_eq!(
response.error.as_ref().map(|error| error.code.as_str()),
Some("gui-owner-missing")
);
assert!(!state.gui_owner_attached.load(Ordering::Acquire));
}
#[test]
fn framing_round_trips_length_prefixed_json() {
let payload = br#"{"method":"runner.ping","requestId":"request-1"}"#;
@@ -861,6 +1196,46 @@ fn durable_pending_action_prevents_shutdown_and_reopens_writes() {
assert!(!state.draining.load(Ordering::Acquire));
}
#[test]
fn forced_shutdown_is_accepted_even_when_runtime_is_busy() {
let directory = unique_test_directory();
let root = directory.0.join("project");
let pending = root.join(".agent/runtime/pending-actions/code-prototype/run-1.json");
fs::create_dir_all(pending.parent().expect("pending parent"))
.expect("create pending directory");
fs::write(&pending, b"{}").expect("write pending action");
let token = "forced-shutdown-token-forced-shutdown-token";
let state = ExternalAgentRunnerServerState::new(
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(token, "forced-shutdown-boot", 32326),
);
state.remember_root(&root);
state.active_connections.store(8, Ordering::Release);
let response = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "forced-shutdown-busy-1".to_string(),
token: token.to_string(),
method: "runner.shutdown".to_string(),
params: ExternalAgentRunnerRequestParams::default(),
},
&state,
);
assert!(response.ok);
assert_eq!(
response
.result
.as_ref()
.and_then(|value| value["willShutdown"].as_bool()),
Some(true)
);
assert!(state.draining.load(Ordering::Acquire));
assert!(state.force_shutdown_requested.load(Ordering::Acquire));
assert!(state.shutdown_requested.load(Ordering::Acquire));
}
#[test]
fn durable_tool_plan_handoff_prevents_shutdown_even_when_corrupt() {
let directory = unique_test_directory();
@@ -13,6 +13,57 @@ static TEST_PROJECT_COUNTER: AtomicU64 = AtomicU64::new(0);
static TEST_MOCK_PORT_COUNTER: AtomicU64 = AtomicU64::new(20_000);
static TEST_CONFIG_LOCK: StdMutex<()> = StdMutex::new(());
#[test]
fn gui_final_exit_is_the_only_run_event_that_requests_runner_shutdown() {
assert!(game_creator_gui_run_event_requests_runner_shutdown(
&tauri::RunEvent::Exit
));
assert!(!game_creator_gui_run_event_requests_runner_shutdown(
&tauri::RunEvent::Ready
));
assert!(!game_creator_gui_run_event_requests_runner_shutdown(
&tauri::RunEvent::MainEventsCleared
));
assert_eq!(
resolve_game_creator_gui_runner_shutdown(&tauri::RunEvent::Ready, || {
panic!("non-exit event must not contact Agent Runner")
}),
GameCreatorGuiRunnerShutdownOutcome::NotRequested
);
assert_eq!(
resolve_game_creator_gui_runner_shutdown(&tauri::RunEvent::Exit, || Ok(())),
GameCreatorGuiRunnerShutdownOutcome::Requested
);
assert_eq!(
resolve_game_creator_gui_runner_shutdown(&tauri::RunEvent::Exit, || {
Err("private shutdown diagnostic".to_string())
}),
GameCreatorGuiRunnerShutdownOutcome::Failed(GameCreatorGuiRunnerShutdownFailure::Other)
);
assert_eq!(
classify_game_creator_gui_runner_shutdown_error(
"Agent Runner 实例锁仍被占用,但 endpoint 未出现"
),
GameCreatorGuiRunnerShutdownFailure::LockTimeout
);
assert_eq!(
classify_game_creator_gui_runner_shutdown_error("打开 Agent Runner pidfd 失败"),
GameCreatorGuiRunnerShutdownFailure::ProcessIdentity
);
assert_eq!(
classify_game_creator_gui_runner_shutdown_error(
"读取响应失败;强制终止 Agent Runner 失败:pid 已被其他进程复用"
),
GameCreatorGuiRunnerShutdownFailure::ProcessIdentity
);
assert_eq!(
classify_game_creator_gui_runner_shutdown_error(
"macOS 不提供可绑定进程实例的安全强制终止句柄"
),
GameCreatorGuiRunnerShutdownFailure::PlatformUnsupported
);
}
fn valid_test_png_bytes() -> Vec<u8> {
base64::engine::general_purpose::STANDARD
.decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
@@ -8021,14 +8021,17 @@ fn provider_success_handoff_reconciliation_persists_only_safe_diagnostics() {
)
.expect("capture Provider handoff diagnostic snapshot");
let request_id = "provider-request-handoff-diagnostic";
let private_error = "tool-plan 成功响应交接 arguments 命中敏感规则 #0:PRIVATE_PROVIDER_TEXT";
let private_error = format!(
"tool-plan 成功响应交接 arguments 命中敏感规则 #0:PRIVATE_PROVIDER_TEXT;safeDiagnostic={}",
r##"{"functionClass":"native:PRIVATE_VALUE","jsonPointer":"#/input/99999999999999999999","pathShape":"PRIVATE_VALUE","relationToRoot":"PRIVATE_VALUE","duplicateSafeJson":true,"hitCount":1}"##,
);
let error_sha256 = format!("{:x}", Sha256::digest(private_error.as_bytes()));
mark_game_creator_agent_runtime_provider_success_handoff_needs_reconciliation_for_test(
&root,
&snapshot,
request_id,
private_error,
&private_error,
)
.expect("persist safe Provider handoff diagnostic");
@@ -8041,6 +8044,7 @@ fn provider_success_handoff_reconciliation_persists_only_safe_diagnostics() {
assert!(public_error.contains(&format!("errorSha256={error_sha256}")));
assert!(public_error.contains(&format!("errorChars={}", private_error.chars().count())));
assert!(!public_error.contains("PRIVATE_PROVIDER_TEXT"));
assert!(!public_error.contains("functionClass="));
let records = read_agent_db_records_for_test(&root);
let audit = records
@@ -8059,6 +8063,77 @@ fn provider_success_handoff_reconciliation_persists_only_safe_diagnostics() {
assert!(audit.get("error").is_none());
assert!(audit.get("response").is_none());
assert!(audit.get("arguments").is_none());
assert!(audit.get("functionClass").is_none());
assert!(audit.get("jsonPointer").is_none());
fs::remove_dir_all(root).ok();
}
#[test]
fn provider_success_handoff_reconciliation_projects_safe_path_location() {
let root = unique_project_path();
let state = start_agent_runtime_steer_fixture(&root, "provider-handoff-path-diagnostic-run");
let snapshot = capture_game_creator_agent_runtime_provider_request_snapshot(
&root,
&state.agent_id,
&state.session_id,
&state.run_id,
"tool-plan",
"loop-12-repair-0",
state.applied_steer_cursor,
)
.expect("capture Provider path diagnostic snapshot");
let request_id = "provider-request-handoff-path-diagnostic";
let diagnostic = crate::tool_plan_handoff::AgentRuntimeToolPlanHandoffSafeDiagnostic {
function_class: "native:command.exec".to_string(),
json_pointer: "#/input/args/1".to_string(),
path_shape: "embedded-absolute".to_string(),
relation_to_root: "not-applicable".to_string(),
duplicate_safe_json: true,
hit_count: 2,
};
let private_error =
crate::tool_plan_handoff::absolute_path_validation_error("arguments", &diagnostic);
mark_game_creator_agent_runtime_provider_success_handoff_needs_reconciliation_for_test(
&root,
&snapshot,
request_id,
&private_error,
)
.expect("persist safe Provider path diagnostic");
let runtime = read_game_creator_agent_runtime_at(&root, &state.agent_id)
.expect("read handoff path diagnostic runtime")
.state;
let public_error = runtime
.error
.expect("handoff path diagnostic runtime error");
assert!(public_error.contains("failureKind=tool-plan-absolute-path"));
assert!(public_error.contains("functionClass=native:command.exec"));
assert!(public_error.contains("jsonPointer=#/input/args/1"));
assert!(public_error.contains("pathShape=embedded-absolute"));
assert!(public_error.contains("relationToRoot=not-applicable"));
assert!(public_error.contains("duplicateSafeJson=true"));
assert!(public_error.contains("hitCount=2"));
let records = read_agent_db_records_for_test(&root);
let audit = records
.iter()
.find(|record| {
record["recordType"] == "agent.runtime.provider_request.needs_reconciliation"
&& record["requestId"] == request_id
})
.expect("handoff path diagnostic audit");
assert_eq!(audit["failureKind"], "tool-plan-absolute-path");
assert_eq!(audit["functionClass"], "native:command.exec");
assert_eq!(audit["jsonPointer"], "#/input/args/1");
assert_eq!(audit["pathShape"], "embedded-absolute");
assert_eq!(audit["relationToRoot"], "not-applicable");
assert_eq!(audit["duplicateSafeJson"], true);
assert_eq!(audit["hitCount"], 2);
assert!(audit.get("error").is_none());
assert!(audit.get("arguments").is_none());
fs::remove_dir_all(root).ok();
}
@@ -12,6 +12,19 @@ mod storage_windows;
mod tests;
mod thinking;
const TOOL_PLAN_HANDOFF_SAFE_DIAGNOSTIC_MARKER: &str = ";safeDiagnostic=";
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, Eq, PartialEq)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct AgentRuntimeToolPlanHandoffSafeDiagnostic {
pub(crate) function_class: String,
pub(crate) json_pointer: String,
pub(crate) path_shape: String,
pub(crate) relation_to_root: String,
pub(crate) duplicate_safe_json: bool,
pub(crate) hit_count: usize,
}
#[cfg(any(unix, windows))]
pub(crate) use discovery::list_at;
pub(crate) use ledger::{
@@ -23,6 +36,93 @@ pub(crate) use model::{
AgentRuntimeToolPlanHandoffLookup, TOOL_PLAN_HANDOFF_SCHEMA_VERSION,
};
pub(crate) fn absolute_path_validation_error(
label: &str,
diagnostic: &AgentRuntimeToolPlanHandoffSafeDiagnostic,
) -> String {
let diagnostic = serde_json::to_string(diagnostic)
.unwrap_or_else(|_| "{\"diagnostic\":\"unavailable\"}".to_string());
format!(
"tool-plan 成功响应交接 {label} 的结构化输入包含绝对路径{TOOL_PLAN_HANDOFF_SAFE_DIAGNOSTIC_MARKER}{diagnostic}"
)
}
pub(crate) fn safe_failure_diagnostic(
error: &str,
) -> Option<AgentRuntimeToolPlanHandoffSafeDiagnostic> {
let (_, diagnostic) = error.split_once(TOOL_PLAN_HANDOFF_SAFE_DIAGNOSTIC_MARKER)?;
serde_json::from_str(diagnostic)
.ok()
.filter(valid_safe_failure_diagnostic)
}
fn valid_safe_failure_diagnostic(diagnostic: &AgentRuntimeToolPlanHandoffSafeDiagnostic) -> bool {
let function_class_valid = matches!(
diagnostic.function_class.as_str(),
"legacy-tool-plan"
| "dynamic-mcp"
| "other"
| "native:project.search"
| "native:file.list"
| "native:file.read"
| "native:file.write"
| "native:file.patch"
| "native:file.delete"
| "native:project.patchset"
| "native:project.git_commit"
| "native:command.exec"
| "native:command.start"
| "native:image.inspect"
| "native:canvas.asset_generate"
);
function_class_valid
&& valid_safe_json_pointer(&diagnostic.json_pointer)
&& matches!(
diagnostic.path_shape.as_str(),
"exact-absolute" | "exact-platform-absolute" | "file-uri" | "embedded-absolute"
)
&& matches!(
diagnostic.relation_to_root.as_str(),
"project-root" | "project-internal" | "external-or-unresolved" | "not-applicable"
)
&& (1..=4096).contains(&diagnostic.hit_count)
}
fn valid_safe_json_pointer(pointer: &str) -> bool {
if pointer == "#" {
return true;
}
if pointer.len() > 192 || !pointer.starts_with("#/") || !pointer.is_ascii() {
return false;
}
pointer[2..].split('/').all(|segment| {
[
"input",
"program",
"args",
"cwd",
"path",
"paths",
"changes",
"outputPath",
"output_path",
"expectedArtifacts",
"expected_artifacts",
"writeScopes",
"write_scopes",
"artifacts",
"children",
"scope",
"field",
"truncated",
]
.contains(&segment)
|| (segment.len() <= 4
&& !segment.is_empty()
&& segment.bytes().all(|byte| byte.is_ascii_digit()))
})
}
pub(crate) fn failure_kind(error: &str) -> &'static str {
if error.contains("敏感规则") || error.contains("敏感 JSON") {
"tool-plan-sensitive-content"

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