合并最新游戏创作目标分支并解决文档冲突
同步通用多智能体 Runtime 与可扩展 Provider 最新实现 完整保留资源画布与 Runtime 双方 pitfalls 长期经验 通过编码、前端类型、资源布局及 Runtime Provider 定向测试 保持本地环境文件不进入提交
This commit is contained in:
@@ -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
@@ -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(
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+47
-6
@@ -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",
|
||||
|
||||
+7
-5
@@ -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())
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user