diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index cf220b3eb..32bc46933 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -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) diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index de032a52b..dc4f2819d 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -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", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 6cf46a961..32c270eb8 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -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" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index c4d44ccfd..946f634ff 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -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::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs index 9677b2ce8..cf43c709d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs @@ -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, 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 { - AGENT_INTERACTION_TOOL_DEFINITIONS +fn agent_interaction_function_tools() -> Result, 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 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 { @@ -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::>( 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( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index a1bec0d29..bc244bcba 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -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:、test:(例如 test:unit)、lint:、typecheck:、build:、verify:、validate: 形式的命名脚本;冒号后的每个非空段必须以字母或数字开头且只能包含字母、数字、连字符、下划线或点,并且 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:、test:(例如 test:unit)、lint:、typecheck:、build:、verify:、validate: 形式的命名脚本;冒号后的每个非空段必须以字母或数字开头且只能包含字母、数字、连字符、下划线或点,并且 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( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_adapter.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_adapter.rs new file mode 100644 index 000000000..fb085c0a0 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_adapter.rs @@ -0,0 +1,136 @@ +use super::*; +use agent_runtime_core::{AgentCatalog, AgentDescriptor, RunProfileCatalog, RunProfileDefinition}; + +fn build_game_creator_runtime_agent_catalog() -> Result { + 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> = 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 { + 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> = 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::>(), + 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![ + 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") + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs index 78dae9847..59cdf8a39 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs @@ -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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs index 69c40f5b2..4d2311a19 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs @@ -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", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs index 63a701732..c1ce7a907 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs @@ -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}")) } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs index 428dcd809..76dfa658c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs @@ -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::>(); + 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::>() + }; + 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::>(); + 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); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs index db19234de..4d6c4c40f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs @@ -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::>(); + 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( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 022cb0516..95e7ab68a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -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 { - 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, 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::, _>>()?; + CapabilityRegistry::try_new(definitions) + .map_err(|error| format!("Runtime capability registry 无效:{error}")) +} + +fn agent_runtime_native_capability_registry() -> Result<&'static CapabilityRegistry, String> +{ + static REGISTRY: OnceLock, 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 { - 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::>(); + 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()) diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 510fdc726..d72097414 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1590,6 +1590,92 @@ struct GameCreatorAgentLoopResult { steps: Vec, } +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( + 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::>(); #[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 "); - 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 " + ); + std::process::exit(1); + } + }; let Some(config_dir) = runtime_config_dir else { eprintln!("Agent Runner 必须显式传入 --config-dir "); 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)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner.rs index 49e92ff3a..f5c50af25 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -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::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index 76fa2fd5b..846c567ea 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -2,6 +2,7 @@ use super::{dispatch::*, endpoint::*, project_owner::*, protocol::*, state::*}; use crate::{AgentRuntimeContextCompactionResult, GameCreatorMcpCatalog}; use serde_json::Value; use sha2::{Digest as _, Sha256}; +use std::ffi::OsString; use std::fs; use std::io::{self, Write}; use std::net::{Ipv4Addr, SocketAddrV4, TcpStream}; @@ -14,10 +15,13 @@ pub(super) fn launch_external_agent_runner(config_dir: &Path) -> Result Result Vec { + let mut arguments = vec![ + OsString::from("--agent-runner"), + OsString::from("--config-dir"), + config_dir.as_os_str().to_os_string(), + ]; + if gui_owner_required { + arguments.push(OsString::from("--gui-owner-required")); + } + arguments +} + pub(super) fn send_external_agent_runner_request_with_protocol_and_id( endpoint: &ExternalAgentRunnerEndpoint, protocol_version: u32, request_id: String, method: &str, params: ExternalAgentRunnerRequestParams, +) -> Result { + send_external_agent_runner_request_with_protocol_and_id_and_timeouts( + endpoint, + protocol_version, + request_id, + method, + params, + EXTERNAL_AGENT_RUNNER_CONNECT_TIMEOUT, + external_agent_runner_client_read_timeout(method), + EXTERNAL_AGENT_RUNNER_IO_TIMEOUT, + ) +} + +fn send_external_agent_runner_request_with_protocol_and_id_and_timeouts( + endpoint: &ExternalAgentRunnerEndpoint, + protocol_version: u32, + request_id: String, + method: &str, + params: ExternalAgentRunnerRequestParams, + connect_timeout: Duration, + read_timeout: Duration, + write_timeout: Duration, ) -> Result { if endpoint.protocol_version != protocol_version { return Err("Agent Runner endpoint 协议版本不兼容".to_string()); @@ -72,12 +113,11 @@ pub(super) fn send_external_agent_runner_request_with_protocol_and_id( let payload = serde_json::to_vec(&request).map_err(|_| "序列化 Agent Runner 请求失败".to_string())?; let address = SocketAddrV4::new(Ipv4Addr::LOCALHOST, endpoint.port).into(); - let mut stream = TcpStream::connect_timeout(&address, EXTERNAL_AGENT_RUNNER_CONNECT_TIMEOUT) + let mut stream = TcpStream::connect_timeout(&address, connect_timeout) .map_err(|error| format!("连接 Agent Runner 失败:{error}"))?; - let io_timeout = external_agent_runner_client_read_timeout(method); stream - .set_read_timeout(Some(io_timeout)) - .and_then(|_| stream.set_write_timeout(Some(EXTERNAL_AGENT_RUNNER_IO_TIMEOUT))) + .set_read_timeout(Some(read_timeout)) + .and_then(|_| stream.set_write_timeout(Some(write_timeout))) .map_err(|error| format!("配置 Agent Runner 客户端超时失败:{error}"))?; write_external_agent_runner_frame(&mut stream, &payload) .map_err(|error| format!("写入 Agent Runner 请求失败:{error}"))?; @@ -153,11 +193,34 @@ pub(super) fn ping_external_agent_runner( pub(super) fn retire_incompatible_external_agent_runner( endpoint_path: &Path, endpoint: &ExternalAgentRunnerEndpoint, + allow_force_busy_migration: bool, ) -> Result<(), String> { if !request_external_agent_runner_shutdown_if_idle_at(endpoint_path, endpoint)? { - return Err( - "Agent Runner 版本与当前客户端不一致,但旧 Runner 仍有任务,暂不能重启".to_string(), - ); + if !allow_force_busy_migration { + return Err( + "Agent Runner 版本与当前客户端不一致,但旧 Runner 仍有任务,暂不能重启".to_string(), + ); + } + force_terminate_external_agent_runner_and_cleanup(endpoint_path, endpoint, true) + .map_err(|error| format!("旧 Agent Runner busy 且安全迁移失败:{error}"))?; + } + Ok(()) +} + +pub(super) fn verify_external_agent_runner_ping_identity( + endpoint: &ExternalAgentRunnerEndpoint, +) -> Result<(), String> { + let result = send_external_agent_runner_request_with_protocol_and_id( + endpoint, + endpoint.protocol_version, + random_identifier(b"genarrative-agent-runner-identity-ping-id")?, + "runner.ping", + ExternalAgentRunnerRequestParams::default(), + )?; + if result.get("pid").and_then(Value::as_u64) != Some(endpoint.pid as u64) + || result.get("bootId").and_then(Value::as_str) != Some(endpoint.boot_id.as_str()) + { + return Err("Agent Runner ping 身份与 endpoint 不匹配".to_string()); } Ok(()) } @@ -183,10 +246,22 @@ fn request_external_agent_runner_shutdown_if_idle_at( } let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_START_TIMEOUT; + let lock_path = endpoint_path + .parent() + .map(external_agent_runner_lock_path) + .ok_or_else(|| "Agent Runner endpoint 缺少 AppData 父目录".to_string())?; loop { match read_external_agent_runner_endpoint(endpoint_path) { Ok(current) if current.boot_id == endpoint.boot_id => {} - _ => return Ok(true), + Ok(_) => return Ok(true), + Err(_) => { + if let Some(lock) = + try_open_external_agent_runner_lock(&lock_path, "Agent Runner 单实例锁")? + { + drop(lock); + return Ok(true); + } + } } if Instant::now() >= deadline { return Err("旧 Agent Runner 未在版本切换期限内退出".to_string()); @@ -237,6 +312,350 @@ pub(crate) fn shutdown_external_agent_runner_if_idle() -> Result { shutdown_external_agent_runner_if_idle_at(&config_dir) } +pub(super) fn shutdown_external_agent_runner_at(config_dir: &Path) -> Result<(), String> { + let endpoint_path = external_agent_runner_endpoint_path(config_dir); + let lock_path = external_agent_runner_lock_path(config_dir); + let endpoint_deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_GUI_SHUTDOWN_IO_TIMEOUT; + loop { + match fs::symlink_metadata(&endpoint_path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err("Agent Runner endpoint 不允许符号链接".to_string()); + } + Ok(_) => break, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + if let Some(lock) = + try_open_external_agent_runner_lock(&lock_path, "Agent Runner 单实例锁")? + { + drop(lock); + return Ok(()); + } + if Instant::now() >= endpoint_deadline { + return Err( + "Agent Runner 实例锁仍被占用,但 endpoint 未在 GUI 关闭期限内出现" + .to_string(), + ); + } + thread::sleep(Duration::from_millis(25)); + } + Err(error) => { + return Err(format!( + "读取 Agent Runner endpoint 元数据失败:{}: {error}", + endpoint_path.display() + )); + } + } + } + let endpoint = read_external_agent_runner_endpoint(&endpoint_path)?; + let graceful_shutdown = send_external_agent_runner_request_with_protocol_and_id_and_timeouts( + &endpoint, + endpoint.protocol_version, + random_identifier(b"genarrative-agent-runner-gui-shutdown-id")?, + "runner.shutdown", + ExternalAgentRunnerRequestParams::default(), + EXTERNAL_AGENT_RUNNER_GUI_SHUTDOWN_CONNECT_TIMEOUT, + EXTERNAL_AGENT_RUNNER_GUI_SHUTDOWN_IO_TIMEOUT, + EXTERNAL_AGENT_RUNNER_GUI_SHUTDOWN_IO_TIMEOUT, + ) + .and_then(|result| { + result + .get("willShutdown") + .and_then(Value::as_bool) + .unwrap_or(false) + .then_some(()) + .ok_or_else(|| "Agent Runner shutdown 响应未确认退出".to_string()) + }); + match graceful_shutdown { + Ok(()) => { + let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_GUI_SHUTDOWN_EXIT_TIMEOUT; + loop { + match read_external_agent_runner_endpoint(&endpoint_path) { + Ok(current) if current.boot_id == endpoint.boot_id => {} + Ok(_) => return Ok(()), + Err(_) => { + if let Some(lock) = try_open_external_agent_runner_lock( + &lock_path, + "Agent Runner 单实例锁", + )? { + drop(lock); + return Ok(()); + } + } + } + if Instant::now() >= deadline { + return force_terminate_external_agent_runner_and_cleanup( + &endpoint_path, + &endpoint, + false, + ); + } + thread::sleep(Duration::from_millis(25)); + } + } + Err(graceful_error) => { + force_terminate_external_agent_runner_and_cleanup(&endpoint_path, &endpoint, true) + .map_err(|force_error| { + format!("{graceful_error};强制终止 Agent Runner 失败:{force_error}") + }) + } + } +} + +fn force_terminate_external_agent_runner_and_cleanup( + endpoint_path: &Path, + endpoint: &ExternalAgentRunnerEndpoint, + allow_verified_legacy_identity: bool, +) -> Result<(), String> { + force_terminate_external_agent_runner_process(endpoint, allow_verified_legacy_identity)?; + let config_dir = endpoint_path + .parent() + .ok_or_else(|| "Agent Runner endpoint 缺少 AppData 父目录".to_string())?; + let lock_path = external_agent_runner_lock_path(config_dir); + let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_GUI_FORCE_TERMINATE_GRACE; + loop { + if let Some(lock) = + try_open_external_agent_runner_lock(&lock_path, "Agent Runner 单实例锁")? + { + if read_external_agent_runner_endpoint(endpoint_path) + .ok() + .is_some_and(|current| current.boot_id == endpoint.boot_id) + { + fs::remove_file(endpoint_path) + .map_err(|error| format!("清理已终止 Agent Runner endpoint 失败:{error}"))?; + } + drop(lock); + return Ok(()); + } + if Instant::now() >= deadline { + return Err("Agent Runner 已终止但实例锁未在期限内释放".to_string()); + } + thread::sleep(Duration::from_millis(25)); + } +} + +#[cfg(target_os = "linux")] +struct ExternalAgentRunnerPidFd(i32); + +#[cfg(target_os = "linux")] +impl Drop for ExternalAgentRunnerPidFd { + fn drop(&mut self) { + // SAFETY: self.0 is an owned pidfd returned by pidfd_open. + unsafe { libc::close(self.0) }; + } +} + +#[cfg(target_os = "linux")] +fn open_external_agent_runner_pidfd(pid: i32) -> Result, String> { + // SAFETY: pidfd_open receives a range-checked pid and flags=0. + let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) }; + if fd < 0 { + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { + return Ok(None); + } + return Err(format!("打开 Agent Runner pidfd 失败:{error}")); + } + Ok(Some(ExternalAgentRunnerPidFd(fd as i32))) +} + +#[cfg(target_os = "linux")] +fn signal_external_agent_runner_pidfd( + pidfd: &ExternalAgentRunnerPidFd, + signal: i32, +) -> Result { + // SAFETY: pidfd is owned and live; null siginfo with flags=0 matches pidfd_send_signal. + let result = unsafe { + libc::syscall( + libc::SYS_pidfd_send_signal, + pidfd.0, + signal, + std::ptr::null::(), + 0, + ) + }; + if result == 0 { + return Ok(true); + } + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { + Ok(false) + } else { + Err(format!("通过 pidfd 发送信号失败:{error}")) + } +} + +#[cfg(target_os = "linux")] +fn force_terminate_external_agent_runner_process( + endpoint: &ExternalAgentRunnerEndpoint, + allow_verified_legacy_identity: bool, +) -> Result<(), String> { + if endpoint.pid == std::process::id() { + return Err("拒绝终止当前 GUI 进程".to_string()); + } + let pid = i32::try_from(endpoint.pid).map_err(|_| "Agent Runner pid 超出范围".to_string())?; + let Some(pidfd) = open_external_agent_runner_pidfd(pid)? else { + return Ok(()); + }; + if let Some(expected_start_identity) = endpoint.process_start_identity.as_deref() { + let actual_start_identity = external_agent_runner_process_start_identity(endpoint.pid)? + .ok_or_else(|| "当前平台未返回 Agent Runner 进程启动身份".to_string())?; + if actual_start_identity != expected_start_identity { + return Err("Agent Runner pid 已被其他进程复用,拒绝终止".to_string()); + } + } else if allow_verified_legacy_identity { + verify_external_agent_runner_ping_identity(endpoint)?; + } else { + return Err("旧 Agent Runner endpoint 缺少进程启动身份,拒绝强制终止".to_string()); + } + + if !signal_external_agent_runner_pidfd(&pidfd, libc::SIGTERM)? { + return Ok(()); + } + let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_GUI_FORCE_TERMINATE_GRACE; + while signal_external_agent_runner_pidfd(&pidfd, 0)? && Instant::now() < deadline { + thread::sleep(Duration::from_millis(25)); + } + if !signal_external_agent_runner_pidfd(&pidfd, 0)? { + return Ok(()); + } + signal_external_agent_runner_pidfd(&pidfd, libc::SIGKILL)?; + Ok(()) +} + +#[cfg(windows)] +fn force_terminate_external_agent_runner_process( + endpoint: &ExternalAgentRunnerEndpoint, + allow_verified_legacy_identity: bool, +) -> Result<(), String> { + 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 TerminateProcess(process: *mut c_void, exit_code: u32) -> i32; + fn WaitForSingleObject(handle: *mut c_void, milliseconds: u32) -> u32; + fn CloseHandle(handle: *mut c_void) -> i32; + } + + const PROCESS_TERMINATE: u32 = 0x0001; + const SYNCHRONIZE: u32 = 0x0010_0000; + const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; + if endpoint.pid == std::process::id() { + return Err("拒绝终止当前 GUI 进程".to_string()); + } + // SAFETY: OpenProcess returns an owned kernel handle or null; it is closed below. + let process = unsafe { + OpenProcess( + PROCESS_TERMINATE | SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, + 0, + endpoint.pid, + ) + }; + if process.is_null() { + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(87) { + return Ok(()); + } + return Err(format!("打开 Agent Runner 进程失败:{error}")); + } + let result = (|| { + if let Some(expected_start_identity) = endpoint.process_start_identity.as_deref() { + // SAFETY: FileTime is plain data filled by GetProcessTimes. + let mut creation = unsafe { std::mem::zeroed::() }; + let mut exit = unsafe { std::mem::zeroed::() }; + let mut kernel = unsafe { std::mem::zeroed::() }; + let mut user = unsafe { std::mem::zeroed::() }; + // SAFETY: process is live and all output pointers refer to writable FileTime values. + if unsafe { GetProcessTimes(process, &mut creation, &mut exit, &mut kernel, &mut user) } + == 0 + { + return Err(format!( + "读取 Agent Runner 进程启动身份失败:{}", + io::Error::last_os_error() + )); + } + let actual_start_identity = ((creation.high_date_time as u64) << 32 + | creation.low_date_time as u64) + .to_string(); + if actual_start_identity != expected_start_identity { + return Err("Agent Runner pid 已被其他进程复用,拒绝终止".to_string()); + } + } else if allow_verified_legacy_identity { + verify_external_agent_runner_ping_identity(endpoint)?; + } else { + return Err("旧 Agent Runner endpoint 缺少进程启动身份,拒绝强制终止".to_string()); + } + // SAFETY: the handle includes PROCESS_TERMINATE and its process start identity was verified. + if unsafe { TerminateProcess(process, 1) } == 0 { + return Err(format!( + "终止 Agent Runner 失败:{}", + io::Error::last_os_error() + )); + } + // SAFETY: the handle includes SYNCHRONIZE and remains valid for this bounded wait. + let wait = unsafe { WaitForSingleObject(process, 1_000) }; + if wait != 0 { + return Err(format!("等待 Agent Runner 退出失败:waitResult={wait}")); + } + Ok(()) + })(); + // SAFETY: process is an owned non-null handle returned by OpenProcess. + unsafe { CloseHandle(process) }; + result +} + +#[cfg(target_os = "macos")] +fn force_terminate_external_agent_runner_process( + _endpoint: &ExternalAgentRunnerEndpoint, + _allow_verified_legacy_identity: bool, +) -> Result<(), String> { + Err("macOS 不提供可绑定进程实例的安全强制终止句柄,拒绝按裸 pid 终止 Agent Runner".to_string()) +} + +#[cfg(not(any(target_os = "linux", windows, target_os = "macos")))] +fn force_terminate_external_agent_runner_process( + _endpoint: &ExternalAgentRunnerEndpoint, + _allow_verified_legacy_identity: bool, +) -> Result<(), String> { + Err("当前平台不支持核验并强制终止 Agent Runner".to_string()) +} + +pub(crate) fn shutdown_external_agent_runner() -> Result<(), String> { + let config_dir = external_agent_runner_config_dir() + .ok_or_else(|| "外部 Agent Runner 尚未配置 AppData;请显式传入 --config-dir".to_string())?; + shutdown_external_agent_runner_at(&config_dir) +} + +pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> { + EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT + .store(true, std::sync::atomic::Ordering::Release); + let config_dir = external_agent_runner_config_dir() + .ok_or_else(|| "外部 Agent Runner 尚未配置 AppData;请显式传入 --config-dir".to_string())?; + let endpoint = ensure_external_agent_runner(&config_dir)?; + let result = send_external_agent_runner_request( + &endpoint, + "runner.attach_gui_owner", + ExternalAgentRunnerRequestParams::default(), + )?; + if result.get("attached").and_then(Value::as_bool) == Some(true) { + Ok(()) + } else { + Err("Agent Runner attach_gui_owner 响应未确认 owner".to_string()) + } +} + pub(super) fn wait_for_external_agent_runner( config_dir: &Path, child: &mut Child, @@ -290,7 +709,12 @@ pub(super) fn ensure_external_agent_runner( ExternalAgentRunnerRequestParams::default(), ); if incompatible_ping.is_ok() { - retire_incompatible_external_agent_runner(&endpoint_path, &endpoint)?; + retire_incompatible_external_agent_runner( + &endpoint_path, + &endpoint, + EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT + .load(std::sync::atomic::Ordering::Acquire), + )?; } } } @@ -337,6 +761,12 @@ pub(crate) fn ensure_external_agent_runner_started() -> Result<(), String> { ensure_external_agent_runner(&config_dir).map(|_| ()) } +pub(crate) fn ensure_external_agent_runner_started_for_gui() -> Result<(), String> { + EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT + .store(true, std::sync::atomic::Ordering::Release); + ensure_external_agent_runner_started() +} + pub(crate) fn require_external_agent_runner_for_cli_runtime_write( root: &Path, ) -> Result<(), String> { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs index 5bed2b909..25547702e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs @@ -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; diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs index 4df657e7c..67b42b3c9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs @@ -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, 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::() }; + let mut exit = unsafe { std::mem::zeroed::() }; + let mut kernel = unsafe { std::mem::zeroed::() }; + let mut user = unsafe { std::mem::zeroed::() }; + // 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::() }; + let expected_size = std::mem::size_of::(); + 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 { + 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 { #[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 { + 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 }) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs index dc9111e40..3eda73886 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs @@ -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> = 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 = 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) process_start_identity: Option, } #[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(()) } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs index 3dea5f7c4..20ee160c3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs @@ -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) -> Result<(), String> { +#[cfg(test)] +pub(super) fn external_agent_runner_shutdown_if_gui_owner_lost( + state: &ExternalAgentRunnerServerState, +) -> Result { + 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, + 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 { + 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, + 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) -> &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) -> 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) -> 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) -> } 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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs index e1c79b794..a59bc1af2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs @@ -9,9 +9,12 @@ pub(super) struct ExternalAgentRunnerServerState { pub(super) endpoint_path: PathBuf, pub(super) endpoint: Mutex, 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>, + pub(super) gui_owner_lock_path: PathBuf, pub(super) project_execution_owners: Mutex>, pub(super) write_request_cache: Mutex, @@ -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 { + lock_unpoisoned(&self.known_roots).iter().cloned().collect() + } + pub(super) fn claim_project_execution_owner(&self, root: &Path) -> Result { 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, @@ -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); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index a887b86b0..c45b9288c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -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::(&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::>() + ); + 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(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 5a1bb8637..dc98e4f36 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -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 { base64::engine::general_purpose::STANDARD .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=") diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index a5b957feb..33faee356 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -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(); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff.rs index 18dd3373e..c9ea9f49b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff.rs @@ -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 { + 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" diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs index d8160e32d..ec8727915 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs @@ -168,7 +168,7 @@ pub(super) fn validate_response( "tool-plan 成功响应交接 arguments 超过 {TOOL_PLAN_HANDOFF_ARGUMENTS_MAX_BYTES} 字节上限" )); } - validate_tool_plan_arguments(root, "arguments", &call.arguments)?; + validate_tool_plan_arguments(root, "arguments", &call.name, &call.arguments)?; } if response.thinking_wrapper_valid && response.thinking_wrapper_balanced @@ -248,21 +248,32 @@ fn validate_source_or_narrative_content(label: &str, value: &str) -> Result<(), Ok(()) } -fn validate_tool_plan_arguments(root: &Path, label: &str, value: &str) -> Result<(), String> { +fn validate_tool_plan_arguments( + root: &Path, + label: &str, + tool_name: &str, + value: &str, +) -> Result<(), String> { validate_secret_tokens_and_controls(label, value)?; - if crate::agent_native_tools::validate_agent_runtime_protocol_json( + let duplicate_safe_json = crate::agent_native_tools::validate_agent_runtime_protocol_json( value, "校验 tool-plan arguments JSON 失败", ) - .is_err() - { + .is_ok(); + if !duplicate_safe_json { validate_json_like_sensitive_keys(label, value)?; validate_json_like_absolute_path_inputs(label, value)?; } match serde_json::from_str::(value) { Ok(json) => { validate_json_sensitive_keys(label, &json)?; - validate_json_absolute_path_inputs(label, &json, None)?; + validate_tool_plan_json_absolute_path_inputs( + root, + label, + tool_name, + &json, + duplicate_safe_json, + )?; validate_json_private_string_values(root, label, &json, None)?; } Err(_) => validate_private_content(root, label, value, true)?, @@ -270,6 +281,195 @@ fn validate_tool_plan_arguments(root: &Path, label: &str, value: &str) -> Result Ok(()) } +#[derive(Debug)] +struct ToolPlanAbsolutePathFinding { + json_pointer: String, + path_shape: String, + relation_to_root: String, +} + +fn validate_tool_plan_json_absolute_path_inputs( + root: &Path, + label: &str, + tool_name: &str, + value: &serde_json::Value, + duplicate_safe_json: bool, +) -> Result<(), String> { + let mut findings = Vec::new(); + collect_tool_plan_absolute_path_findings(root, value, None, "#", &mut findings); + let Some(first) = findings.first() else { + return Ok(()); + }; + Err(super::absolute_path_validation_error( + label, + &super::AgentRuntimeToolPlanHandoffSafeDiagnostic { + function_class: tool_plan_function_class(tool_name), + json_pointer: first.json_pointer.clone(), + path_shape: first.path_shape.clone(), + relation_to_root: first.relation_to_root.clone(), + duplicate_safe_json, + hit_count: findings.len(), + }, + )) +} + +fn collect_tool_plan_absolute_path_findings( + root: &Path, + value: &serde_json::Value, + parent_key: Option<&str>, + pointer: &str, + findings: &mut Vec, +) { + match value { + serde_json::Value::String(value) => { + if parent_key.is_some_and(is_tool_plan_content_field) { + return; + } + let Some(path_shape) = tool_plan_absolute_path_shape(value) else { + return; + }; + let relation_to_root = + if matches!(path_shape, "exact-absolute" | "exact-platform-absolute") { + lexical_absolute_path_relation_to_root(root, value) + } else { + "not-applicable" + }; + findings.push(ToolPlanAbsolutePathFinding { + json_pointer: pointer.to_string(), + path_shape: path_shape.to_string(), + relation_to_root: relation_to_root.to_string(), + }); + } + serde_json::Value::Array(values) => { + for (index, value) in values.iter().enumerate() { + let child_pointer = safe_json_pointer_array_child(pointer, index); + collect_tool_plan_absolute_path_findings( + root, + value, + parent_key, + &child_pointer, + findings, + ); + } + } + serde_json::Value::Object(values) => { + for (key, value) in values { + let child_pointer = safe_json_pointer_object_child(pointer, key); + collect_tool_plan_absolute_path_findings( + root, + value, + Some(key), + &child_pointer, + findings, + ); + } + } + _ => {} + } +} + +fn lexical_absolute_path_relation_to_root(root: &Path, value: &str) -> &'static str { + let candidate = Path::new(value); + if !candidate.is_absolute() { + return "external-or-unresolved"; + } + if candidate == root { + return "project-root"; + } + if crate::project::relative_project_path(root, candidate).is_ok() { + "project-internal" + } else { + "external-or-unresolved" + } +} + +fn safe_json_pointer_array_child(pointer: &str, index: usize) -> String { + safe_json_pointer_append(pointer, &index.to_string()) +} + +fn safe_json_pointer_object_child(pointer: &str, segment: &str) -> String { + const MAX_POINTER_CHARS: usize = 192; + const MAX_SEGMENT_CHARS: usize = 64; + + let known_segment = [ + "input", + "program", + "args", + "cwd", + "path", + "paths", + "changes", + "outputPath", + "output_path", + "expectedArtifacts", + "expected_artifacts", + "writeScopes", + "write_scopes", + "artifacts", + "children", + "scope", + ] + .contains(&segment); + let segment = if known_segment + && segment.chars().count() <= MAX_SEGMENT_CHARS + && segment + .chars() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-')) + { + segment + } else { + "field" + }; + let candidate = safe_json_pointer_append(pointer, segment); + if candidate.chars().count() <= MAX_POINTER_CHARS { + candidate + } else { + "#/truncated".to_string() + } +} + +fn safe_json_pointer_append(pointer: &str, segment: &str) -> String { + let candidate = format!("{pointer}/{segment}"); + if candidate.chars().count() <= 192 { + candidate + } else { + "#/truncated".to_string() + } +} + +fn tool_plan_absolute_path_shape(value: &str) -> Option<&'static str> { + let redacted = redact_absolute_path_tokens(value); + if redacted == value { + return None; + } + if value + .get(..5) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("file:")) + { + return Some("file-uri"); + } + if redacted == "" { + return Some(if Path::new(value).is_absolute() { + "exact-absolute" + } else { + "exact-platform-absolute" + }); + } + Some("embedded-absolute") +} + +fn tool_plan_function_class(tool_name: &str) -> String { + if tool_name == crate::agent::AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME { + "legacy-tool-plan".to_string() + } else if let Some(tool) = super::ledger::runtime_tool_for_native_handoff_function(tool_name) { + format!("native:{tool}") + } else if tool_name.starts_with("mcp_tool_") { + "dynamic-mcp".to_string() + } else { + "other".to_string() + } +} + fn validate_json_private_string_values( root: &Path, label: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/ledger.rs index 2599ecd0b..efdcc52f8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/ledger.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::{fs, path::Path}; use platform_llm::LlmRunResponse; @@ -319,7 +319,7 @@ fn should_normalize_tool_call_paths(tool_name: &str) -> bool { || runtime_tool_for_native_handoff_function(tool_name).is_some() } -fn runtime_tool_for_native_handoff_function(tool_name: &str) -> Option<&'static str> { +pub(super) fn runtime_tool_for_native_handoff_function(tool_name: &str) -> Option<&'static str> { [ "project.search", "file.list", @@ -426,7 +426,7 @@ fn normalize_string_array_field(root: &Path, object: &mut serde_json::Value, key }) } -fn normalize_project_absolute_path( +pub(super) fn normalize_project_absolute_path( root: &Path, value: &str, allow_project_root: bool, @@ -438,5 +438,50 @@ fn normalize_project_absolute_path( if candidate == root { return allow_project_root.then(|| ".".to_string()); } - crate::project::relative_project_path(root, candidate).ok() + if let Ok(relative) = crate::project::relative_project_path(root, candidate) { + return Some(relative); + } + + // Runner 会把 owning project 规范为真实路径,但用户或 Provider 仍可能沿用 + // 启动时的符号链接别名。只在别名解析后仍位于同一真实项目根时接受, + // 并允许 file.write 目标末端尚不存在;指向项目外的别名继续失败关闭。 + let canonical_root = fs::canonicalize(root).ok()?; + relative_path_through_root_alias(&canonical_root, candidate, allow_project_root) +} + +fn relative_path_through_root_alias( + canonical_root: &Path, + candidate: &Path, + allow_project_root: bool, +) -> Option { + let mut ancestor = Some(candidate); + let mut suffix = Vec::new(); + let mut matched_suffix = None; + while let Some(current) = ancestor { + if fs::canonicalize(current) + .ok() + .is_some_and(|canonical| canonical == canonical_root) + { + // Continue walking upward and keep the outermost matching alias root. + // This preserves any symlink component below that root so the normal + // Runtime path guard can still reject it instead of silently resolving it. + matched_suffix = Some(suffix.clone()); + } + let Some(parent) = current.parent() else { + break; + }; + suffix.push(current.file_name()?.to_os_string()); + ancestor = Some(parent); + } + let suffix = matched_suffix?; + if suffix.is_empty() { + return allow_project_root.then(|| ".".to_string()); + } + let relative = suffix + .iter() + .rev() + .map(|component| component.to_string_lossy()) + .collect::>() + .join("/"); + crate::project::normalize_relative_path(&relative).ok() } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs index 5d2711eb9..c2162ab9e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs @@ -659,6 +659,351 @@ fn tool_plan_handoff_normalizes_project_absolute_paths_before_round_trip() { assert_eq!(persisted.entries[0].to_llm_response(), replayed); } +#[test] +fn tool_plan_handoff_reports_safe_absolute_path_location_without_path_value() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let private_path = project.path().join("game/index.html"); + let embedded = format!("open('{}')", private_path.display()); + let error = write_at( + project.path(), + &identity, + &identity.base_request_slot, + 0, + &provider_request_id("absolute-path-safe-diagnostic"), + &response( + "safe text", + vec![call( + "call-embedded-command-path", + "runtime_tool_command_exec", + &serde_json::json!({ + "reason": "检查文件", + "input": { + "program": "python3", + "args": ["-c", embedded], + "cwd": ".", + }, + }) + .to_string(), + )], + ), + ) + .expect_err("embedded absolute path must remain rejected"); + assert!(!error.contains(private_path.to_string_lossy().as_ref())); + let diagnostic = safe_failure_diagnostic(&error).expect("safe absolute path diagnostic"); + assert_eq!(diagnostic.function_class, "native:command.exec"); + assert_eq!(diagnostic.json_pointer, "#/input/args/1"); + assert_eq!(diagnostic.path_shape, "embedded-absolute"); + assert_eq!(diagnostic.relation_to_root, "not-applicable"); + assert!(diagnostic.duplicate_safe_json); + assert_eq!(diagnostic.hit_count, 1); +} + +#[test] +fn tool_plan_handoff_does_not_guess_command_argv_path_semantics() { + for (index, function_name) in ["runtime_tool_command_exec", "runtime_tool_command_start"] + .into_iter() + .enumerate() + { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let pattern = project.path().join("game/index.html"); + let error = write_at( + project.path(), + &identity, + &identity.base_request_slot, + 0, + &provider_request_id(&format!("command-argv-semantics-{index}")), + &response( + "safe text", + vec![call( + "call-command-argv-semantics", + function_name, + &serde_json::json!({ + "reason": "搜索文本", + "input": { + "program": "rg", + "args": [pattern], + "cwd": ".", + }, + }) + .to_string(), + )], + ), + ) + .expect_err("ambiguous command argv must not be rewritten as a path operand"); + let diagnostic = safe_failure_diagnostic(&error).expect("command argv diagnostic"); + assert_eq!( + diagnostic.function_class, + if index == 0 { + "native:command.exec" + } else { + "native:command.start" + } + ); + assert_eq!(diagnostic.json_pointer, "#/input/args/0"); + assert_eq!(diagnostic.path_shape, "exact-absolute"); + assert_eq!(diagnostic.relation_to_root, "project-internal"); + } +} + +#[test] +fn tool_plan_handoff_reports_file_uri_and_flattened_path_shapes() { + for (index, (arguments, expected_pointer, expected_shape)) in [ + ( + serde_json::json!({ + "reason": "修复页面", + "input": { + "path": "file:///tmp/private.html", + "oldText": "old", + "newText": "new", + }, + }), + "#/input/path", + "file-uri", + ), + ( + serde_json::json!({ + "reason": "修复页面", + "path": "/tmp/private.html", + "oldText": "old", + "newText": "new", + }), + "#/path", + "exact-absolute", + ), + ( + serde_json::json!({ + "reason": "修复页面", + "opaqueProviderField": "/tmp/private.html", + }), + "#/field", + "exact-absolute", + ), + ( + serde_json::json!({ + "reason": "修复页面", + "12345678901234567890": "/tmp/private.html", + }), + "#/field", + "exact-absolute", + ), + ] + .into_iter() + .enumerate() + { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let error = write_at( + project.path(), + &identity, + &identity.base_request_slot, + 0, + &provider_request_id(&format!("absolute-path-shape-diagnostic-{index}")), + &response( + "safe text", + vec![call( + "call-path-shape", + "runtime_tool_file_patch", + &arguments.to_string(), + )], + ), + ) + .expect_err("unsupported absolute path shape must remain rejected"); + let diagnostic = safe_failure_diagnostic(&error).expect("safe path shape diagnostic"); + assert_eq!(diagnostic.function_class, "native:file.patch"); + assert_eq!(diagnostic.json_pointer, expected_pointer); + assert_eq!(diagnostic.path_shape, expected_shape); + assert_eq!(diagnostic.hit_count, 1); + } +} + +#[test] +fn tool_plan_handoff_rejects_forged_safe_diagnostics() { + let valid = 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: 1, + }; + let valid_error = absolute_path_validation_error("arguments", &valid); + assert_eq!(safe_failure_diagnostic(&valid_error), Some(valid.clone())); + + for diagnostic in [ + AgentRuntimeToolPlanHandoffSafeDiagnostic { + function_class: "native:PRIVATE\nVALUE".to_string(), + ..valid.clone() + }, + AgentRuntimeToolPlanHandoffSafeDiagnostic { + json_pointer: "#/input/12345678901234567890".to_string(), + ..valid.clone() + }, + AgentRuntimeToolPlanHandoffSafeDiagnostic { + path_shape: "PRIVATE_VALUE".to_string(), + ..valid.clone() + }, + AgentRuntimeToolPlanHandoffSafeDiagnostic { + relation_to_root: "PRIVATE_VALUE".to_string(), + ..valid.clone() + }, + AgentRuntimeToolPlanHandoffSafeDiagnostic { + hit_count: 0, + ..valid.clone() + }, + ] { + let error = absolute_path_validation_error("arguments", &diagnostic); + assert!(safe_failure_diagnostic(&error).is_none()); + } + + let unknown_field = format!( + "tool-plan 成功响应交接 arguments 的结构化输入包含绝对路径{}{}", + TOOL_PLAN_HANDOFF_SAFE_DIAGNOSTIC_MARKER, + r##"{"functionClass":"native:command.exec","jsonPointer":"#/input/args/1","pathShape":"embedded-absolute","relationToRoot":"not-applicable","duplicateSafeJson":true,"hitCount":1,"privateField":"PRIVATE_VALUE"}"##, + ); + assert!(safe_failure_diagnostic(&unknown_field).is_none()); +} + +#[cfg(unix)] +#[test] +fn tool_plan_handoff_normalizes_project_absolute_paths_through_a_root_alias() { + use std::os::unix::fs::symlink; + + let sandbox = tempdir().expect("tool-plan handoff alias sandbox"); + let project = sandbox.path().join("project"); + fs::create_dir_all(project.join("game")).expect("create aliased project"); + fs::write(project.join("game/index.html"), "") + .expect("write aliased project file"); + let canonical_project = project.canonicalize().expect("canonical project"); + let alias = sandbox.path().join("project-alias"); + symlink(&canonical_project, &alias).expect("create project root alias"); + + let identity = identity("loop-0-repair-0"); + let existing_path = alias.join("game/index.html"); + let missing_path = alias.join("game/generated/runtime.js"); + let entry = write( + &canonical_project, + &identity, + 0, + &response( + "safe text", + vec![ + call( + "call-aliased-project-path", + "runtime_tool_file_patch", + &serde_json::json!({ + "reason": "修复页面", + "input": { + "path": existing_path, + "oldText": "old", + "newText": "new", + "expectedReplacements": 1, + }, + }) + .to_string(), + ), + call( + "call-aliased-missing-path", + "runtime_tool_file_write", + &serde_json::json!({ + "reason": "写入生成文件", + "input": { + "path": missing_path, + "content": "export const ready = true;", + }, + }) + .to_string(), + ), + ], + ), + ); + let replayed = entry.to_llm_response(); + let patch: serde_json::Value = + serde_json::from_str(&replayed.tool_calls[0].arguments).expect("file.patch arguments"); + assert_eq!(patch["input"]["path"], "game/index.html"); + let write: serde_json::Value = + serde_json::from_str(&replayed.tool_calls[1].arguments).expect("file.write arguments"); + assert_eq!(write["input"]["path"], "game/generated/runtime.js"); +} + +#[cfg(unix)] +#[test] +fn tool_plan_handoff_root_alias_preserves_internal_symlink_components() { + use std::os::unix::fs::symlink; + + let sandbox = tempdir().expect("tool-plan handoff alias sandbox"); + let project = sandbox.path().join("project"); + fs::create_dir_all(project.join("game")).expect("create aliased project"); + fs::write(project.join("game/index.html"), "") + .expect("write aliased project file"); + symlink("game", project.join("link")).expect("create internal project symlink"); + let canonical_project = project.canonicalize().expect("canonical project"); + let alias = sandbox.path().join("project-alias"); + symlink(&canonical_project, &alias).expect("create project root alias"); + + let identity = identity("loop-0-repair-0"); + let entry = write( + &canonical_project, + &identity, + 0, + &response( + "safe text", + vec![call( + "call-aliased-internal-symlink", + "runtime_tool_file_read", + &serde_json::json!({ + "reason": "读取页面", + "input": { "path": alias.join("link/index.html") }, + }) + .to_string(), + )], + ), + ); + let arguments: serde_json::Value = + serde_json::from_str(&entry.to_llm_response().tool_calls[0].arguments) + .expect("file.read arguments"); + assert_eq!(arguments["input"]["path"], "link/index.html"); +} + +#[cfg(unix)] +#[test] +fn tool_plan_handoff_rejects_a_root_alias_that_resolves_outside_the_project() { + use std::os::unix::fs::symlink; + + let sandbox = tempdir().expect("tool-plan handoff outside alias sandbox"); + let project = sandbox.path().join("project"); + let outside = sandbox.path().join("outside"); + fs::create_dir_all(&project).expect("create project"); + fs::create_dir_all(&outside).expect("create outside directory"); + fs::write(outside.join("secret.txt"), "secret").expect("write outside file"); + let outside_alias = sandbox.path().join("outside-alias"); + symlink(&outside, &outside_alias).expect("create outside alias"); + + let identity = identity("loop-0-repair-0"); + let error = write_at( + &project.canonicalize().expect("canonical project"), + &identity, + &identity.base_request_slot, + 0, + &provider_request_id("outside-project-alias"), + &response( + "safe text", + vec![call( + "call-outside-project-alias", + "runtime_tool_file_read", + &serde_json::json!({ + "reason": "读取文件", + "input": { "path": outside_alias.join("secret.txt") }, + }) + .to_string(), + )], + ), + ) + .expect_err("outside alias must remain rejected"); + assert!(error.contains("绝对路径"), "unexpected error: {error}"); +} + #[test] fn tool_plan_handoff_normalizes_legacy_wrapper_paths_without_changing_source() { let project = tempdir().expect("tool-plan handoff project"); diff --git a/apps/ai-game-creator-shell/src-tauri/tests/runner_gui_owner_lifecycle.rs b/apps/ai-game-creator-shell/src-tauri/tests/runner_gui_owner_lifecycle.rs new file mode 100644 index 000000000..c49ef18c1 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/tests/runner_gui_owner_lifecycle.rs @@ -0,0 +1,165 @@ +#![cfg(unix)] + +use std::fs::{self, File, OpenOptions}; +use std::os::fd::AsRawFd; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +const RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json"; +const RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock"; +const GUI_OWNER_LOCK_FILE_NAME: &str = "agent-runner.gui-owner.lock"; + +struct TestDirectory(PathBuf); + +impl TestDirectory { + fn new(label: &str) -> Self { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "genarrative-runner-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir(&path).expect("create isolated Runner AppData"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)) + .expect("secure isolated Runner AppData"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn open_locked_file(path: &Path) -> File { + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .mode(0o600) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW) + .open(path) + .expect("open isolated lock file"); + // SAFETY: file owns a live descriptor and flock does not retain pointers. + assert_eq!(unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) }, 0); + file +} + +fn lock_is_available(path: &Path) -> bool { + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .mode(0o600) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW) + .open(path) + .expect("open Runner lock probe"); + // SAFETY: file owns a live descriptor and flock does not retain pointers. + unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) == 0 } +} + +fn wait_until(timeout: Duration, mut predicate: impl FnMut() -> bool) -> bool { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if predicate() { + return true; + } + thread::sleep(Duration::from_millis(5)); + } + predicate() +} + +fn wait_for_child(child: &mut Child, timeout: Duration) -> ExitStatus { + let deadline = Instant::now() + timeout; + loop { + if let Some(status) = child.try_wait().expect("poll Runner child") { + return status; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("Runner child did not exit before deadline"); + } + thread::sleep(Duration::from_millis(10)); + } +} + +fn runner_binary() -> &'static str { + env!("CARGO_BIN_EXE_genarrative-ai-game-creator-shell") +} + +#[test] +fn gui_owned_runner_rejects_start_when_owner_dies_before_first_check() { + let directory = TestDirectory::new("owner-lost-before-check"); + let owner = open_locked_file(&directory.path().join(GUI_OWNER_LOCK_FILE_NAME)); + let script = + "kill -STOP $$; exec \"$1\" --agent-runner --config-dir \"$2\" --gui-owner-required"; + let mut child = Command::new("/bin/sh") + .arg("-c") + .arg(script) + .arg("runner-owner-loss-wrapper") + .arg(runner_binary()) + .arg(directory.path()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn stopped Runner wrapper"); + let child_pid = child.id() as i32; + assert!(wait_until(Duration::from_secs(5), || { + let mut status = 0; + // SAFETY: child_pid belongs to this test and status is a writable integer. + let waited = + unsafe { libc::waitpid(child_pid, &mut status, libc::WUNTRACED | libc::WNOHANG) }; + waited == child_pid && libc::WIFSTOPPED(status) + })); + + drop(owner); + // SAFETY: child_pid still identifies the stopped child owned by this test. + assert_eq!(unsafe { libc::kill(child_pid, libc::SIGCONT) }, 0); + let status = wait_for_child(&mut child, Duration::from_secs(15)); + + assert!(!status.success()); + assert!(!directory.path().join(RUNNER_ENDPOINT_FILE_NAME).exists()); + assert!(lock_is_available( + &directory.path().join(RUNNER_LOCK_FILE_NAME) + )); +} + +#[test] +fn gui_owned_runner_exits_and_cleans_endpoint_after_established_owner_dies() { + let directory = TestDirectory::new("owner-lost-after-start"); + let owner = open_locked_file(&directory.path().join(GUI_OWNER_LOCK_FILE_NAME)); + let mut child = Command::new(runner_binary()) + .arg("--agent-runner") + .arg("--config-dir") + .arg(directory.path()) + .arg("--gui-owner-required") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn GUI-owned Runner"); + let endpoint_path = directory.path().join(RUNNER_ENDPOINT_FILE_NAME); + let runner_lock_path = directory.path().join(RUNNER_LOCK_FILE_NAME); + assert!(wait_until(Duration::from_secs(30), || { + endpoint_path.exists() && !lock_is_available(&runner_lock_path) + })); + + drop(owner); + let status = wait_for_child(&mut child, Duration::from_secs(10)); + + assert!(status.success()); + assert!(!endpoint_path.exists()); + assert!(lock_is_available(&runner_lock_path)); +} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 78c01b9a1..cb79f5898 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4886,7 +4886,7 @@ - 决策:新增 `command.start / command.poll / command.stdin / command.terminate` 四个模型工具,专门承载受控前台持久进程。start / stdin / terminate 默认 `confirm`,poll 默认 `auto`。`command.start` 复用 `command.exec` 的固定 program、逐项 argv、项目 cwd、白名单解析、安全 PATH、隔离环境和参数拒绝,不接受 shell、环境注入、用户 executable、管道、重定向或 daemonize / detach;Runner 直接持有固定 `120x30` PTY、child handle、stdin writer 和输出泵,同项目最多 4 个、同 Agent instance 最多 2 个 running session。V1.2 对 PTY / 后台进程的排除只适用于一次性 `command.exec`。 - 决策:`processId` 是 start action create-once 的 opaque Runtime 身份,完整绑定 project、Agent instance、task、session、run、start action、action / command fingerprint 和 Runner boot;它不是 OS PID。poll / stdin / terminate 每次都从活 registry 和 durable record 交叉复核 owning 身份,跨 Agent、动态 sibling、run 或项目一律失败关闭,不能把知道 ID 当成授权。 -- 决策:App / WebView / 发起 CLI 退出不影响会话,独立 Runner 继续持有 PTY。Runner 重启只做 reconciliation:旧 boot 已进入 prepared / launching / running / terminating 且没有可信 terminal record 的会话进入 `needs-reconciliation`,不得重放 start 或 stdin,不得重发 terminate,也不得按持久化 PID 重连或接管 PTY;可信终态只补 observation / audit / receipt。首版连旧 boot 的 prepared 也保守核对,不自动推断为安全重试。 +- 决策:2026-07-27 起,独立 Runner 归 Tauri GUI 生命周期所有,同一 AppData 通过 OS GUI owner 锁只允许一个前端进程持有 Runner。GUI 启动子进程显式携带 `--gui-owner-required`,Runner 若在启动检查前发现 owner 已释放就直接失败,不能退化成 CLI-owned Runner;就绪后仍必须调用 `runner.attach_gui_owner`。Runner 由独立 watchdog 线程每 100ms 探测 owner 锁,不依赖服务端主循环;owner 丢失后先标记 draining / forced shutdown 并让服务端在 1.5 秒共享 deadline 内中断 Provider、回收 process session,若主循环或排空链路卡死则 watchdog 在 1.75 秒后复核 bootId、清理 endpoint 并由 Runner 自身进程硬退出。正常最终 `RunEvent::Exit` 仍同步请求专用 `runner.shutdown`;GUI panic、SIGKILL 或构建中途失败不再只依赖退出回调。`runner.shutdown` 不得复用版本切换用的 `runner.shutdown_if_idle`,也不得以 busy 为由继续留在后台。GUI 侧使用专用短连接 / I/O 超时;endpoint 缺失或读取失败不能单独证明 Runner 已退出,必须结合实例锁释放,失败日志只输出脱敏阶段分类。GUI 客户端兜底在 Linux 通过同一 pidfd 校验 / 发信号,Windows 绑定同一进程 handle;macOS 没有等价稳定句柄,客户端不得按裸 PID 强杀,由跨平台 Runner 自身 watchdog 承担主循环卡死的最终兜底。旧 endpoint 缺 start identity 时,只有 GUI owner 路径且认证 ping 同时精确匹配 PID 和 bootId,才允许一次性迁移 busy 旧 Runner;普通 CLI 仍必须被 busy 阻断,不能按相同二进制猜测强杀。客户端强制终止后必须先取得同一 Runner 实例锁,再在锁内复核 bootId 并清理 endpoint;Unix endpoint 必须是当前用户持有的 0600 单硬链接普通文件。退出不得把任务伪造为 completed、不得重放工具副作用;未完成 run 保留既有 durable 状态,下一次启动按 reconciliation / recovery 合同处理。单个 WebView/子窗口关闭不触发 Runner shutdown,普通 CLI 退出也保持原行为,显式 `--runner-shutdown-if-idle` 仍只用于安全关闭空闲 Runner。Runner 重启只做 reconciliation:旧 boot 已进入 prepared / launching / running / terminating 且没有可信 terminal record 的会话进入 `needs-reconciliation`,不得重放 start 或 stdin,不得重发 terminate,也不得按持久化 PID 重连或接管 PTY;可信终态只补 observation / audit / receipt。首版连旧 boot 的 prepared 也保守核对,不自动推断为安全重试。 - 决策:`command.poll` 使用绑定 processId 的 opaque cursor,并以 `maxChars / waitMs` 分页读取保留逻辑行边界的清洗后私有 PTY transcript;默认 / 最大返回 8,000 / 16,000 字符,最长等待 30 秒,同一 action/cursor 恢复必须稳定。后台输出泵独立等待 child 并排空尾部,单会话清洗后输出上限为 256 KiB,超限终止并落 `output-limit-exceeded`。输出正文只进入 owning Agent 的私有 transcript、observation 和 context bundle,task/event/Agent DB/receipt/action history/activity/output/UI snapshot/report 只保存 cursor、字节数、SHA-256、截断和退出元数据。`command.stdin` 单次最终 UTF-8 bytes 上限 8 KiB,支持 `appendNewline / eof`,是不可重放副作用;公共确认与审计只留 `processId / bytesWritten / contentSha256 / stdinOpen / eof`,不得保存 data、摘要、前后缀或可逆编码。 - 决策:owning run 存在 launching / running / terminating 或未解决 reconciliation 会话时,final reply、finalization journal 和 completed 投影全部阻断。`runner.shutdown_if_idle` 同时检查活 registry、输出泵、终止任务和 durable unresolved record;取消 run 也必须先完成进程收束,不能留下会话后把 Runner 判 idle。 - 决策:terminate 必须携带最后一次 poll cursor,并返回同一 cursor 的零消费状态元数据;后续 poll 不得从 0 重读或跳过尾部。Unix 固定为 graceful request + 完整固定宽限等待、随后只 force kill 同组残留、再 wait / reap / drain PTY;Windows 首版使用 Job force terminate + wait / reap,不宣称已有等价 graceful console event。只发送信号不算完成;signal / Job / wait / reap 或终态审计无法确认都进入 reconciliation。重复 terminate 只幂等返回已知终态,不能按 PID 再杀一次。 @@ -5607,3 +5607,30 @@ - 更正:上一条把固定 SSE fixture 的真实抓包来源、确定性 parser 覆盖和真实端点 smoke 合并描述,并写成“证明转录没有偏差”,超出了实际测试证据。本次验收命令为 `cargo test --manifest-path server-rs/Cargo.toml -p platform-llm`;固定 fixture 和本地解析测试只验证 parser / 配置归一结果,实时测试只验证最终归一后的工具名、id、完整参数 JSON 和文本增量字符数。测试数量随用例自然变化,不作为共享文档中的固定契约。 - 当前口径:`server-rs/crates/platform-llm/tests/live_stream_tool_calls.rs` 是默认忽略的真实端点工具调用 smoke;`on_delta` 只接收文本,工具调用从最终 `LlmRunResponse.tool_calls` 读取。现有测试没有原始 SSE 录制、事件类型/slot/分片顺序保存或逐事件比较,因此两类测试都不能证明 raw SSE fidelity 或抓包转录无偏差。 - 现有确定性流式工具覆盖应与普通 Anthropic 文本流测试分开统计:三协议真实来源 fixture、Responses 仅有 completed / incomplete 终态事件时的恢复、并行 slot 聚合、截断参数和无片段 `StreamUnavailable` 等用例共同覆盖 parser 边界;未来若需证明转录一致性,必须另行增加受控原始 SSE capture/compare 能力。 + +## 2026-07-29 抽取通用多 Agent Runtime 公共内核第一阶段 + +- 背景:AI 游戏创作 Runtime 已有独立 Runner、持久任务、Provider 恢复、Goal、计划、静态/隔离协作和 finalization,但实现仍属于 Tauri package;内建 capability、Agent 目录和 Run Profile 缺少第二个产品可直接依赖的公开契约。 +- 决策:新增独立 `agent-runtime-core` 纯 Rust crate,只依赖 `serde / serde_json`。第一阶段公开泛型 dispatch 的 Capability Registry、Agent Catalog、Run Profile Catalog 和 Completion Policy;不公开或复制 `.agent/runtime/**`、Runner IPC、Provider DTO、权限、执行器、Prompt 或游戏完成合同。 +- 生产接入:AGC interaction 和全部 native Runtime function 由同一 registry 生成并反向解析,重复 capability/function binding 在构造时失败关闭;现有 Supervisor/部门角色和 `standard / autonomous-game-build` 作为 game adapter 注册,静态 Agent/profile normalization 读取 catalog。权限继续以 tool policy snapshot 为权威,完成继续以现有 finalization/自主游戏合同为权威,不形成双重事实源。 +- 边界:动态 MCP 继续作为外部不可信目录独立校验;`platform-agent::game_creation` 继续保留游戏任务图和旧隔离合同。本轮不迁移当前正在演进的 GUI owner、Provider retry/handoff、Runner 和 sidecar schema,也不宣称已完成 Scheduler、Store、PromptSection、Artifact/Event 接口、多租户或远端 Runner。 +- 验证:纯内核单测与无游戏语义文档审查 conformance、interaction/native registry、AGC adapter/profile 定向测试、AGC tests 编译、依赖树、encoding 和 diff 门禁。`npm run ai-game-creator-shell:check` 增加 `agent-runtime-core:check`,避免公共内核成为不执行测试的旁路 crate。 +- 关联文档:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md` V1.48。 + +## 2026-07-29 通用多 Agent Runtime 执行内核第二阶段 + +- 背景:V1.48 只有 capability/Agent/profile/completion 契约,无法在脱离 AGC 后执行 run,`agent-runtime-core` 命名与实际能力不匹配。 +- 决策:在同一 core 中增加 versioned snapshot、run/action/observation/event/delegation 模型、CAS `RuntimeStore`、`RuntimeClock`、`ToolHost`、per-Agent lane、确定性 step driver、spawn/all-join、completion 收束和 reconciliation;action 入队必须经 V1.48 `CapabilityRegistry` 验证,不允许 engine 绕过 catalog 执行未注册 capability。core 仍只依赖 `serde / serde_json`。 +- 副作用契约:action 必须先独立 commit `executing` 再调用 ToolHost,调用后 observation commit 失败或宿主返回 Unknown 时进入 `needs-reconciliation`;重载和重复 resume 都不得重放 ToolHost。 +- 生产接入:AGC 现役恢复队列的 running/waiting/pending/idle 优先级改由 core `next_recovery_step` 判定;AGC 仍负责 JSONL、去重、状态字符串校验、终态父回执抑制和后续 recovery driver。 +- 边界:不迁移 Runner IPC、Provider/handoff、finalization 多文件提交、`.agent/runtime/**` 或游戏 completion context;这些仍是 AGC adapter/store 的唯一事实源,后续逐段迁移而不双写。 +- 验证:非游戏文档审查 Runtime 已覆盖 action、双 child lane、反向完成下的稳定 all-join、observation commit 故障、序列化重载、零重放、显式 reconciliation 和 completion blocker/ready;完整 `ai-game-creator-shell:check` 退出 0。 + +## 2026-07-30 通用 LLM Provider 通过实例注册接入 Runtime Core + +- 背景:`platform-llm` 已有 OpenAI Responses、OpenAI Chat 和 Anthropic 的稳定 HTTP/SSE 实现,但 Runtime/AGC 直接依赖 `LlmClient / LlmApiKind / LlmRunRequest`,新宿主无法只依赖通用内核注册 Provider。 +- 决策:`agent-runtime-core` 新增中立 Provider instance/protocol ID、descriptor、七项能力、request/response/stream/error DTO、object-safe adapter 和 `Arc` registry。Provider 实例 ID 与 wire protocol ID 分离;同 protocol 可注册多个隔离实例,重复实例、未知实例、protocol 漂移和能力不匹配在 adapter 调用前失败关闭。 +- 平台边界:`platform-llm` 实现三个 adapter 和可扩展 builder,只转换中立 DTO,仍复用唯一 `LlmClient::run/stream_run`、request body、auth、raw failure log 和 parser。Key、base URL、HTTP client 与 raw-log 目录继续绑定 `LlmClient/LlmConfig` 实例,不进 core 或进程全局 registry。 +- 生产接入:AGC Agent interaction 的 stream、普通请求及原 stream-unavailable/empty/deserialize fallback 已改由 `ProviderRegistry` 执行;对外 `LlmStreamDelta`、function name/schema、错误文案、Runner IPC 和 Provider retry/handoff/finalization 持久协议不变。 +- 扩展边界:新 Provider 可直接实现 core `ProviderAdapter` 并注册,不修改 core enum/match。`platform-llm` 当前 DTO 不支持的 tool role/result、toolChoice none/specific 和 reasoning minimal/x-high 在 adapter 转换层零网络失败关闭;工具调用仍以最终 response 为权威。 +- 关联文档:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md` V1.50。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 7436b0648..d5c4cac2a 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -67,6 +67,41 @@ npm run ai-game-creator-shell:agent-task -- --config-dir /absolute/app-data --in 省略 `--init` 时项目必须已经由客户端初始化。所有 Runtime 写命令都必须显式传入项目外 `--config-dir` 并投递给独立 Runner;`--runner-status` 和 Agent 状态查询只读取已有配置与 endpoint,不得创建 AppData、修改权限或为了查询启动 Runner。遇到权限确认会返回非零并保留待确认动作,继续操作应回到开发窗口,不能用 CLI 静默绕过。 +### 通用 Agent Runtime 内核抽取复验 + +修改 `server-rs/crates/agent-runtime-core`、AGC capability registry、Agent catalog、Run Profile 或 Completion Policy 后,先运行纯内核与适配器定向门禁: + +```bash +npm run agent-runtime-core:check +cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml interaction_ -- --nocapture --test-threads=1 +cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml native_runtime_capability_registry_is_the_bidirectional_catalog -- --nocapture --test-threads=1 +cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml game_creator_runtime_ -- --nocapture --test-threads=1 +``` + +修改 Provider 中立契约、注册表、`platform-llm` adapter 或 AGC interaction Provider 路由时,追加: + +```bash +cargo test --manifest-path server-rs/crates/agent-runtime-core/Cargo.toml --test provider_registry +cargo test --manifest-path server-rs/Cargo.toml -p platform-llm +cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml agent::interaction::tests:: -- --nocapture +cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --tests +``` + +验收必须同时证明同 protocol 多实例可并存、能力不匹配时 adapter 零调用、三种现役 protocol 仍复用原 HTTP/SSE/parser,以及 AGC stream/non-stream/fallback 均经 registry。Provider Key、base URL、HTTP client 和 raw-log 目录必须保留在 `LlmClient/LlmConfig` 实例,不得下沉 core descriptor/error 或进程全局状态。 + +修改通用 run 状态机、Store/ToolHost、Agent lane、action 恢复或 delegation/join 时,追加执行内核 conformance 和 AGC 恢复优先级回归: + +```bash +cargo test --manifest-path server-rs/crates/agent-runtime-core/Cargo.toml --test runtime_execution_conformance +cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml background_agent_runtime_legacy_waiting_task_blocks_pending_recovery -- --test-threads=1 +cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml background_agent_runtime_recovers_stale_running_before_pending_task -- --test-threads=1 +cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml background_agent_runtime_recovers_pending_task_after_cancelled_canonical_run -- --test-threads=1 +``` + +action conformance 必须在 ToolHost 调用前看到 durable `executing`,并在 observation commit 失败后用全新 engine 重载快照;只在同一进程里重试不能作为 crash recovery 证据。all-join 结果顺序按 child 注册顺序,不按完成先后。 + +内核必须保持纯 Rust,只允许 `serde / serde_json` 和标准库依赖;用 `cargo tree --manifest-path server-rs/crates/agent-runtime-core/Cargo.toml --depth 1` 核对不得出现 Tauri、`platform-llm`、MCP、HTTP、图片、浏览器、SpacetimeDB 或游戏领域 crate。AGC 的 function name、schema、Agent id、profile、权限和持久协议必须保持兼容;新增内建 capability 只能经统一 registry 建立双向唯一 binding,不能重新增加平行字符串清单。独立 core 测试会生成 crate 内 `Cargo.lock/target` 时,开发者只保留源码和 manifest,交付前清理生成物;正式 AGC lock 仍需提交 path dependency 变更。 + ### AI 游戏创作 Runtime V1.2 定向复验 `command.exec` 动作必须保留固定程序和逐项 argv,不得把参数拼成 shell 字符串。例如,定向执行当前仓库的受控命令测试时,action 形状为: diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index de73d7ac8..a0ff3713b 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3765,8 +3765,9 @@ - 现象:Provider 已成功返回原生工具调用,但 `path / paths / cwd / outputPath` 等结构化输入使用了当前项目根目录内的绝对路径;交接安全门禁以 `tool-plan-absolute-path` 失败,run 进入 `needs-reconciliation`,后续“继续”只能排队。 - 原因:Runtime 工具最终只接受项目相对路径,但 Provider 不一定始终遵守提示;交接层此前只能拒绝全部绝对路径,无法区分“当前项目内、可无损转换”的输入与项目外越界输入。 -- 处理:成功响应写入 tool-plan handoff 前,只对内置 Runtime 原生函数和 legacy tool-plan wrapper 的合法、无重复 key JSON arguments 按工具 schema 的精确位置做规范化;仅改写完整字符串且位于当前项目根目录内的 `file.*.path`、`project.patchset.changes[*].path`、`project.git_commit.paths[*]`、`command.*.cwd`、`image.inspect.paths[*]` 与 `canvas.asset_generate.outputPath` 等真实路径字段。源码/叙述字段、任务产物描述和动态 MCP arguments 不改写。项目外绝对路径、畸形或重复 key JSON、敏感 key 和真实凭据继续失败关闭。规范化后的 handoff 同时作为当前进程执行值和重启 replay 值,避免 live/restart 语义漂移。 -- 验证:覆盖项目内 `path`、项目根 `cwd`、`paths` 数组的相对化与落账重放,源码 `content` 原文保持不变,动态 MCP 和项目外绝对路径仍拒绝,并运行全部 tool-plan handoff 回归。 +- 处理:成功响应写入 tool-plan handoff 前,只对内置 Runtime 原生函数和 legacy tool-plan wrapper 的合法、无重复 key JSON arguments 按工具 schema 的精确位置做规范化;仅改写完整字符串且位于当前项目根目录内的 `file.*.path`、`project.patchset.changes[*].path`、`project.git_commit.paths[*]`、`command.*.cwd`、`image.inspect.paths[*]` 与 `canvas.asset_generate.outputPath` 等无歧义真实路径字段。不要泛化改写 `command.*.args[*]`:同一个完整绝对路径字符串在 `rg` 中可能是搜索 pattern,在其他程序中也可能不是 path operand;无法由固定工具契约确认语义的位置继续拒绝。项目根通过符号链接别名传入时,只解析根身份并保留根内原始相对后缀,使后续 Runtime 仍能拒绝内部 symlink / reparse point;写入目标末端尚不存在可正常处理,越出项目根的 alias 继续失败关闭。源码/叙述字段、动态 MCP arguments、项目外绝对路径、`file://`、路径加行号、畸形或重复 key JSON、敏感 key 和真实凭据继续拒绝。 +- 诊断:拒绝合法 JSON 中剩余的绝对路径时,只公开固定枚举/白名单约束的 `functionClass`、`jsonPointer`、`pathShape`、`relationToRoot`、`duplicateSafeJson` 和 `hitCount`;数组下标与 object key 分开生成,未知或纯数字 object key 不原样公开,root 关系只做词法分类,不对任意外部路径执行 canonicalize。不得记录 arguments、路径值、正文、前后缀或可逆编码。Provider prompt 同时明确 `command.exec` / `command.start` 的 argv 项目路径必须相对 `cwd`,禁止绝对路径、file URI、路径加行号或嵌入式绝对路径。规范化后的 handoff 同时作为当前进程执行值和重启 replay 值,避免 live/restart 语义漂移。 +- 验证:覆盖项目内 `path`、项目根 `cwd`、`paths` 数组和符号链接根别名的相对化与落账重放,并锁定根 alias 之后的内部 symlink 仍以原相对后缀交给 Runtime 拒绝;覆盖 `command.exec` / `command.start` argv 不猜测 path 语义,嵌入式 argv、`file://`、扁平化 `path`、项目外 alias 继续拒绝且只生成安全字段定位;覆盖纯数字 object key、伪造/超长/非法枚举 diagnostic 不进入公开状态。源码 `content` 原文保持不变,动态 MCP 和项目外绝对路径仍拒绝,并运行全部 tool-plan handoff 与 Provider reconciliation 回归。 - 关联:`src/components/project/ProjectGalleryView.tsx`、`src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx`、`src/components/common/PlatformToolModalShell.tsx`、`src/components/common/UnifiedModal.tsx`。 ## 待用户确认的 Agent 工具不能依赖模型自行结束回合 @@ -3878,3 +3879,30 @@ - 处理:FIFO 和单写者只约束同一 `projectPath + projectId + mode` scope。切换 scope 或卸载时立即放弃旧活动槽并清空旧队列,旧 Promise 仍可在后台结束,但其结果由 epoch 丢弃,finally 也只能按意图身份清理自己,不能清掉新 scope 的活动请求。后端继续用 `expectedProjectId`、CAS revision 和系统锁仲裁已经发出的旧写入。同 scope 在途 CAS 不取消;其后相同资源与 section 的排队拖动只保留最后坐标,避免连续输入造成无界队列。 - 验证:让旧 mode 更新 Promise 永不先 resolve,切换 mode 后应立即发送并完成新 mode CAS;随后再 resolve 旧请求,新布局、saving 状态和请求数均不得变化。另以百次同资源拖动证明在途请求之后只追加一笔、坐标为最后一次输入。 - 关联:`apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts`、`apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts`。 + +## 抽通用 Runtime 时不要把产品持久文件直接变成公共 ABI + +- 现象:为了快速“抽 crate”,直接把 Tauri package 内的 `AgentRuntimeState`、sidecar struct 或 Runner protocol 改成 `pub`,第二个消费者虽然能编译,却同时绑定游戏 schema、UI 投影、文件路径和未稳定恢复顺序。 +- 原因:代码可见性被误当成领域解耦;产品私有 DTO 中仍混有 `game-creator-*` schema、固定 profile、Provider 类型和本地持久化细节,公开后只会把后续迁移变成 breaking change。 +- 处理:先从纯值对象和宿主注入契约抽取,公共 core 不依赖 Tauri、Provider DTO 或游戏 crate;产品通过 adapter 注册 capability、Agent、profile 和 completion policy。Store/Runner 等只有在事实源、事务和迁移协议单独稳定后再抽接口,不能双写或复制 sidecar。 +- 验证:必须存在完全不含游戏语义的 conformance fixture,并让至少一个现役生产入口真实消费公共契约;只新增未被调用的 crate、`include!`、路径搬家或旧类型 re-export 都不算完成。 + +## Runtime 不能在外部工具返回后才首次记录执行意图 + +- 现象:Runtime 调用工具成功后准备写 observation,但进程在写入前崩溃;重启后只看到 queued action,于是再执行一次外部副作用。 +- 根因:把“调用返回”当成 durable 事实,缺少工具调用前的持久 executing checkpoint;网络、文件、命令和外部 API 都不能因为“看起来幂等”就自动重放。 +- 处理:先以 CAS 单独 commit `queued -> executing`,成功后才调 ToolHost;调用返回后再 commit observation。恢复见到 executing 或 ToolHost 返回 Unknown 时只能进入 reconciliation,不得自动重执行。重复 resume 不得继续增 revision 或重复 event。 +- 验证:在“ToolHost 已调用、observation commit 失败”处注入故障,序列化快照并用新 engine 重载;断言重复 resume 后 ToolHost 计数仍为 1,且只有显式 reconcile observation 才恢复 running。 + +## Provider 可扩展不能用一个全局 protocol 枚举代替实例隔离 + +- 现象:把 `openai_chat / openai_responses / anthropic` 直接当 Provider 身份,注册第二个同协议 endpoint 时发生 ID 冲突;或为方便调用把 API Key、base URL、raw-log 目录放进全局状态,并行请求后日志串目录。 +- 原因:wire protocol 是 adapter 能力,Provider instance 才是配置与资源所有者;两者被闭集枚举合并后,无法表达同协议多租户/多 endpoint。 +- 处理:core 同时校验 `ProviderInstanceId + ProviderProtocolId`,registry 只以 instance ID 索引 adapter;adapter 内持有独立 `LlmClient`。新协议通过实现 trait 注册,新实例通过自定义 instance ID 注册,都不得修改 core match。 +- 验证:至少同时注册两个同 protocol 实例,证明 descriptor/lookup 互不污染;对每项未声明能力断言 adapter 调用计数为 0;并行 raw-log 测试必须使用两个显式临时目录,不用串行化掩盖错误路由。 + +## Provider 适配器不能重新实现一份 HTTP/SSE parser + +- 现象:为了让 Runtime 调用中立 trait,在 core 或 AGC 内再拼一次 URL/header/request body,或自己消费 SSE;它会与 `platform-llm` 的重试、脱敏、工具分片和错误分类迅速漂移。 +- 处理:adapter 只做 core DTO 与现有 `Llm*` DTO 转换,网络调用唯一落到 `LlmClient::run/stream_run`。流式 sink 保留累计文本、当前增量和 finish reason;tool calls 继续从最终 response 读取。 +- 验证:三种 descriptor/capability、request/response round-trip、stream callback 和稳定 error kind 单测后,仍必须运行 `platform-llm` 全量 parser 测试;只有 adapter fake 通过不能证明 wire 协议没有回归。 diff --git a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md index ceceb8594..ba43a2979 100644 --- a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md +++ b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md @@ -1495,6 +1495,103 @@ V1.43 不放宽 V1.41 的文本型 `game-creator-provider-handoff.v1`,而是 - 2026-07-20 当前确定性证据:本轮 `tool_plan_handoff_` 为 `44/44`,Supervisor collaboration 相关过滤为 `55/55`,权威返工合同用例为 `1/1`;Tauri/Rust 串行全量 1058 tests 为 `1054 passed / 4 ignored / 0 failed`,Linux `cargo check --tests` 与 `x86_64-pc-windows-gnu cargo check --tests` 均通过。E2E self-test、typecheck、变更脚本 ESLint、encoding 与 `git diff --check` 通过。默认并发全量只作竞态诊断,不替代 `--test-threads=1`。Unix handoff 存储使用固定目录句柄、根/Agent 双层 `flock`、`RENAME_EXCHANGE` 安装回滚和 `RENAME_NOREPLACE` quarantine;Windows 使用相对父句柄、`GetFileInformationByHandleEx` 句柄枚举与独占 temp 句柄,并拒绝 junction/reparse point 与硬链接。非协作同 UID 进程仍属于宿主 OS 信任边界,不能据此宣称完整沙箱。真实 suite 的 checkpoint 已有单轮外部证据,但整轮仍无 PASS。 - 2026-07-27 文档更正:本节及 V1.42 中的 `platform-llm 41/41` 是 2026-07-20 的历史门禁计数,不能代表本次工具协议修复后的当前结果;从仓库根目录运行 `cargo test --manifest-path server-rs/Cargo.toml -p platform-llm` 作为当前验收命令。当前证据应区分为 checked-in SSE fixture 的 parser 覆盖、本地解析单元测试和默认忽略的真实端点归一工具调用 smoke;三类测试的数量以命令实际输出为准,不作为需要手工维护的固定契约。两类外部 SSE 证据都不录制或逐事件比较原始 SSE,不能据此宣称转录无偏差。 +## V1.48 通用多 Agent Runtime 内核抽取第一阶段 + +V1.1-V1.47 已形成独立 Runner、持久任务、Provider lifecycle、Goal、计划、静态委派、isolated all-join、恢复、权限、沙箱和 finalization,但生产实现仍编译在 `genarrative-ai-game-creator-shell` Tauri package 内。V1.48 先抽出第二个本地项目型产品可以直接依赖的纯 Rust 公共内核,不新建任务队列、Runner、持久化事实源或平行业务流程,也不在本阶段搬迁正在演进的 GUI owner、Provider retry/handoff 和 `.agent/runtime/**` schema。 + +### Crate 与依赖边界 + +- 新 crate 固定为 `server-rs/crates/agent-runtime-core`,package name 为 `agent-runtime-core`。它与只供 AGC 使用的 `platform-agent` 一样排除在 `server-rs` 后端 workspace 之外,由 AI 游戏创作 Tauri manifest 通过路径依赖显式消费;不得让后端 workspace 因本次抽取重新依赖桌面 Runtime。 +- 内核只依赖 `serde / serde_json` 和 Rust 标准库,不依赖 Tauri、`platform-llm`、MCP client、HTTP、图片、浏览器、SpacetimeDB 或游戏领域 crate。Provider function DTO、Tauri command、文件锁、项目路径和 AppData 仍由宿主适配器负责。 +- 公共类型使用公开构造器和只读访问器,不直接公开 AGC 当前 `pub(crate)` 持久 sidecar 结构。现有 `.agent/runtime/**` 文件名、schema version、恢复顺序和 Runner IPC 在本阶段保持原样,不能因为抽 crate 产生双写、迁移或兼容副本。 + +### Capability Registry + +- `CapabilityDefinition` 固定包含稳定 `id`、Provider 可见 `functionName`、非空 `description`、object 形态的 JSON `inputSchema` 和宿主自定义 `dispatch(namespace, key)`。核心只校验与索引,不执行工具、不决定 auto/confirm/deny,也不把 dispatch key 当模型输入。 +- `CapabilityRegistry` 构造时一次性拒绝空/非法 identifier、重复 capability id、重复 function name、空 description 和非 object schema;成功后同时提供 `id -> definition`、`functionName -> definition` 的确定性只读查找和稳定迭代顺序。宿主不能在请求解析时再用多份手写列表猜测反向绑定。 +- AGC 的 interaction capability 和后台 native Runtime tool catalog 都必须从同一公共 registry 定义派生 Provider function tools;MCP 动态 catalog 继续保留独立的外部不可信目录与 fingerprint/sidecar 协议,不在本阶段伪装成宿主内建 capability。 +- 本阶段不迁移具体 executor、审计、pending action、confirmation、recovery 或 tool policy。现有执行器仍按 capability id 分发,但 native function name 的正反向解析必须改读 registry,证明公共内核已进入生产路径而不是无人使用的抽象。 + +### Agent、Profile 与 Completion 注入面 + +- `AgentDescriptor / AgentCatalog` 只保存稳定 Agent id、role、声明 capability id 和安全 JSON metadata;构造时拒绝重复 Agent、重复 capability 和非法 id,并可显式对 Capability Registry 校验引用。游戏六部门、`project-supervisor`、Prompt 和 LLM 配置继续由 AGC adapter 提供。 +- `RunProfileDefinition / RunProfileCatalog` 保存 profile id、启用 capability id、completion policy id 和安全 metadata;构造与引用校验同样失败关闭。`standard / autonomous-game-build` 只是 AGC 注册的两个实例,不能成为通用内核枚举。 +- `CompletionPolicy` 是宿主实现的只读评估接口,返回 `Ready` 或带稳定 code/summary 的 `Blocked`;内核不认识 manifest、`game/index.html`、画布、static smoke、preview 或 playtest。AGC 现有自主完成合同继续留在 game adapter,本阶段只用非游戏 fixture 证明接口可以独立工作。 +- 本轮不抽 PromptSection、Scheduler、Runtime Store、Artifact Store、Event Sink、远端 Runner、租户配额或 telemetry exporter。只有后续消费者在不复制 AGC 代码的情况下真实使用公共契约后,才按相同原则继续迁移这些层。 + +### 兼容与验收 + +- AGC 对外 Tauri command、Runner IPC、Provider 请求外形、native function name、工具 schema、Agent id、run profile 字符串、权限、持久文件和错误语义必须保持不变;registry 接入前后的 function catalog 逐项等价。 +- `agent-runtime-core` 独立测试至少覆盖重复/非法 registry、双向 lookup、Agent/Profile 引用校验,以及完全不含游戏语义的 completion policy fixture。测试不得依赖 Tauri binary、AppData、项目目录或外部 Provider。 +- AGC 定向测试至少覆盖 interaction registry、native function catalog、未知 function fail-closed 和现有原生工具解析;随后运行 shell cargo check、`platform-agent` 回归、编码检查和 `git diff --check`。当前工作树已有 Runner/handoff 未提交改动时,验收必须区分本轮目标 diff 与并发基线,不能把历史 PASS 外推为当前全树 PASS。 +- 2026-07-29 当前工作树证据:纯内核最终 `8/8`、AGC interaction `7/7`、native registry `1/1`、game adapter/profile `2/2` 和 Tauri `cargo check --tests` 均通过;`cargo tree --depth 1` 只含 `serde / serde_json`。从根目录运行 `npm run ai-game-creator-shell:check` 完整退出 0,包含前端 `481/481`、当时纯内核 `7/7`、`platform-llm 111/111`、shared contracts、Tauri 串行全量和 CLI smoke;终审新增标识空白与 completion policy 失败关闭负向用例后,再跑纯内核、Tauri 编译和上述适配器定向门禁仍全部通过。encoding 检查 `5088` 个文件与 `git diff --check` 通过。为捕获精确全量计数而启动的第二次冗余 quiet 运行被 120 秒外层超时中止,不作为失败或新 PASS,也不覆盖前一次完整门禁。 + +## V1.49 通用多 Agent Runtime 执行内核 + +V1.48 只建立了 catalog/policy 公共契约,不足以独立执行一个 run。V1.49 在同一 `agent-runtime-core` crate 内增加确定性执行内核,验收标准是:不依赖 Tauri、AGC、Provider 或项目目录,可启动非游戏 run、按 Agent lane 调度、执行宿主 capability、spawn child、all-join、经 completion policy 完成,并在快照重载后保持不重放外部副作用。 + +### 通用运行模型 + +- core 公开 `RuntimeSnapshot / RunRecord / RunStatus / RuntimeAction / RuntimeObservation / DelegationGroup / RuntimeEvent`。类型使用 versioned serde 快照,但不规定宿主的文件名、目录、数据库或锁实现。 +- action 入队必须同时提供 V1.48 `CapabilityRegistry`,core 在读取/修改 snapshot 前先验证 `capabilityId` 已注册;未知 capability 零修改失败关闭。catalog 与 engine 不得成为两套孤立系统。 +- `RunStatus` 至少覆盖 `pending / running / waiting-for-action / waiting-for-children / paused / completed / failed / cancelled / needs-reconciliation`。所有转移都经 `RuntimeEngine` 验证,终态 run 不得恢复、重复完成或新增 action/child。 +- 快照保留稳定创建顺序。调度每个 Agent lane 同时最多启动一个 run,不同 Agent 可在同一次调度中进入 running;core 只决定可运行集合,不创建线程或进程。 + +### Store、ToolHost 与 step driver + +- `RuntimeStore` 只提供 load 与基于 `expectedRevision` 的 snapshot+events 原子 commit;冲突必须失败关闭。`RuntimeClock` 提供可测试时间,`ToolHost` 只按 capability id 执行结构化 input 并返回结构化 observation。 +- action 持久生命周期固定为 `queued -> executing -> observed`。step driver 调用外部 ToolHost 之前,必须先单独 commit `executing`;宿主返回后再 commit observation。若进程在外部调用中或返回后崩溃,重载快照只能看到 `executing`,core 返回 `NeedsReconciliation` 且绝不自动重放。 +- 宿主核对外部结果后,可通过显式 reconciliation 提交 observation 或将 run 置为 `needs-reconciliation`。core 不猜测外部副作用是否发生。 + +### Delegation、join 与 completion + +- `spawn` 必须由 running 父 run 发起,一次原子写入 delegation group 与全部 child run,父 run 进入 `waiting-for-children`。child run id、Agent id 和 group id 由宿主给定并由 core 校验唯一性。 +- V1.49 先支持 `joinMode=all`。只有全部 child 进入终态才生成确定性 join observation 并恢复父 run;结果按原 child 注册顺序排列,不按完成先后改变。 +- `RuntimeEngine::complete_if_ready` 调用现有 `CompletionPolicy`;`Blocked` 只返回 blocker 且保持 run 非终态,`Ready` 才 commit completed。游戏 manifest、static smoke、preview/playtest 仍属于 AGC policy/context。 + +### AGC 生产接入与兼容 + +- 首个现役生命周期消费点是恢复队列下一步选择。AGC 在完成 JSONL 读取、latest-by-run 去重和字符串校验后,把状态映射为 core `RecoverableTaskState`,调用 `next_recovery_step`:首个 running 优先;无 running 但存在等待确认/用户输入时阻断 pending;否则启动首个 pending;无则 idle。 +- `read_recoverable_game_creator_agent_runtime_task` 签名、返回值、队列顺序、恢复 driver、Runner IPC、task JSONL 和 sidecar schema 不变。父 run 终态时抑制回执等 AGC 协作策略仍留在 adapter。 +- V1.49 不声称 AGC 整个现役 driver 已迁移;它建立可独立运行的内核和第一个生产生命周期消费点,后续按同样方式逐段迁移 AGC driver/finalization,不复制第二份存储。 + +### V1.49 验收 + +- core 单测覆盖非法转移、per-Agent lane、action 两阶段、CAS 冲突、spawn/all-join 顺序、completion blocker 和 reconciliation。 +- 非游戏 conformance 使用文档审查宿主:根 run 执行 capability、spawn 两个 child、模拟外部调用后 observation commit 失败、序列化/重载、证明零重放并经显式 reconciliation 收束 all-join。 +- AGC 回归覆盖 running/waiting/pending/idle 优先级与现有 recovery scan;随后运行 Tauri `cargo check --tests`、完整 `ai-game-creator-shell:check`、dependency tree、encoding 和 `git diff --check`。 +- 2026-07-29 当前证据:core `15/15`,其中执行 conformance `4/4`;AGC waiting 阻断 pending、stale running 优先和 cancelled canonical 后 pending 恢复三条定向回归均通过,Tauri `cargo check --tests` 通过。根目录 `npm run ai-game-creator-shell:check` 完整退出 0,包含前端 `481/481`、core `15/15`、`platform-llm 111/111`、shared contracts、Tauri 串行全量和 CLI smoke;终审将 Capability Registry 接入 action 入队后再跑 core `15/15` 仍通过。rustfmt、Prettier、encoding `5091` files 和 `git diff --check` 通过,core 独立 `Cargo.lock/target` 已清理。当前 Rust 1.96 工具链未安装 Clippy component,本轮未擅自安装;该项不用其它门禁伪装为 PASS。 + +## V1.50 通用 LLM Provider 契约与可扩展注册机制 + +V1.49 已经让 `agent-runtime-core` 能在不依赖 Provider 的情况下独立执行 run,但宿主仍需直接依赖 `platform-llm::LlmClient / LlmApiKind / LlmRunRequest`。V1.50 把“Runtime 如何请求一个 LLM Provider”收口为公共契约,但不把 HTTP、SSE、密钥、厂商 URL 或 AGC 持久协议搬入 core。 + +### 三层边界 + +- `agent-runtime-core` 只拥有中立 Provider 值对象、能力声明、object-safe adapter trait 和注册表。core 继续只依赖 `serde / serde_json` 和 Rust 标准库,不得反向依赖 `platform-llm`、Tokio、Reqwest 或任何厂商 SDK。 +- `platform-llm` 保留唯一 HTTP/SSE 实现和 OpenAI Responses、OpenAI Chat、Anthropic wire parser,通过 adapter 完成 core 中立 DTO 与现有 `Llm*` DTO 之间的转换。不复制请求体、认证、重试、raw failure log 或 SSE 解析。 +- AGC 负责把用户配置解析为 Provider 实例与对应 protocol adapter,并让至少一条现役 interaction/planning 请求经注册表执行。Runner IPC、Provider lifecycle、retry/handoff/finalization sidecar 和原 `providerRequestId / requestSlot / attempt` 身份完全不变。 + +### 身份、能力与注册 + +- `ProviderId` 标识一个已配置 Provider **实例**;`ProviderProtocolId` 标识 wire protocol/adapter。同一 protocol 可有多个 base URL、model、Key 和 raw-log 目录完全隔离的实例,两者不能复用一个闭集枚举表达。 +- `ProviderDescriptor` 包含稳定 Provider ID、protocol ID、显示名和 `ProviderCapabilities`。V1 能力至少显式声明 streaming、function tools、required tool choice、image input、web search、reasoning effort 和 text verbosity。 +- `ProviderRegistry` 按 Provider ID 保存 `Arc`,拒绝重复 Provider ID、非法 ID 和未知 Provider。新 Provider 通过注册接入,不得为此修改 core enum/match。 +- registry 在调用 adapter 之前从 `ProviderRequest` 推导所需能力;实例未声明对应能力时以稳定 `UnsupportedCapability` 失败关闭,adapter/HTTP 调用计数必须为 0。具体 wire 约束可在 `platform-llm` 转换层进一步失败关闭。 + +### 中立请求、流式与错误 + +- core 公开 message role/content part、function tool/tool choice、tool call、request/response/usage、stream event 和 error kind,不公开 `LlmProvider` 厂商来源枚举、`LlmApiKind` 或 Reqwest 类型。请求保留 model override、output token、timeout、推理档、文本精度、web search、图片、function tools 和 tool choice 语义。 +- trait 使用标准库 boxed future 保持 object-safe:普通请求返回 boxed future;流式请求接收宿主提供的同步回调,不将 Tokio stream 泄漏进 core。流式文本继续维持“累计文本 + 当前增量 + finish reason”语义,tool calls 从最终 response 读取。 +- `ProviderErrorKind` 稳定区分 invalid config/request、unknown provider、unsupported capability、timeout、connectivity、upstream、stream unavailable、empty response、transport 和 deserialize;upstream status、attempts 等有界结构化信息保留,API Key、URL、header 和原始响应不进入 core descriptor/error。 + +### 生产接入和验收 + +- `platform-llm` 提供 OpenAI Responses、OpenAI Chat 和 Anthropic descriptor/adapter 构造器以及 registry builder。adapter 绑定自己的 `LlmClient` 实例;`LlmConfig.raw_log_dir`、base URL、HTTP client 和 Key 不进入全局状态,并行实例的 raw log 不得串目录。 +- AGC 的 `apiKind` 现有字符串和错误文案保持兼容,但解析结果同时用于选择 registry adapter。首条现役消费点固定为 Agent interaction 的 stream/non-stream 请求和既有普通回退;不在本切片改动 `provider_retry.rs` 泛型执行器。 +- core conformance 覆盖第三方 fake Provider 注册/调用、重复与未知 ID、能力不匹配零调用和 stream callback。`platform-llm` 覆盖三种 descriptor、request/response/tool-call/stream/error 双向转换与实例隔离;AGC 覆盖 interaction 实际走 registry 及原 fallback 语义。 +- 完成后运行 core conformance、`cargo test -p platform-llm --manifest-path server-rs/Cargo.toml`、AGC interaction/config 定向回归、Tauri `cargo check --tests`、`npm run ai-game-creator-shell:check`、`npm run check:encoding` 和 `git diff --check`;独立 core 生成的 `Cargo.lock/target` 交付前清理。 + ## 验收命令 - `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml structured_plan_ -- --nocapture` diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 8ab2a4e96..5b730804d 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -226,7 +226,7 @@ Agent Runtime 负责: - 2026-07-10 补充:后台任务工具箱已加入 `agent.run_status`。Agent 可在 loop 中读取自己、目标 Agent 或一组 Agent 的 Runtime 状态摘要,判断同伴是否正在运行、最近任务和最近工具动作;Runtime 复用 `agent.run_status` 项目权限策略,策略要求确认或拒绝时不读取状态,observation 不返回 `.agent/runtime/*` 文件绝对路径。 - 2026-07-10 补充:后台任务工具箱已加入 `agent.delegate`。Agent 可在 loop 中把明确任务投递到另一个 Agent 的独立后台队列,复用目标 Agent 原有锁和 pending drain 语义;同一目标 Agent 串行,不同目标 Agent 可并行。该工具受 `agent.delegate` 策略保护,策略要求确认或拒绝时不会写目标对话、不会启动目标后台任务,也不会写 `agent.runtime.agent.delegate` 审计记录。 - 2026-07-10 补充:`agent.delegate` 已形成可恢复的父子任务闭环。`delegationId` 由 durable pending action 的 `actionId` 派生,子任务记录会保存 `parentAgentId / parentRunId / delegationId`,终态记录额外保存经过统一凭据清洗和安全截断的 `terminalDetail`;同一委派的提交和回执分别受 delegation 级 OS 文件锁保护,同一目标 Agent 的 runId 分配与 pending 追加还受任务账本 OS 锁保护。子任务进入 `completed / failed / cancelled / budget-exhausted` 任一终态时,Runtime 按 `delegationId` 幂等生成且至多生成一次 `agent.delegate.result` 回执,失败、排队或活跃取消、预算耗尽都必须回传,不能只覆盖成功。回执会向父 Agent 既有队列追加固定 runId、`source=agent-delegate-receipt` 的续跑任务,把完整的已清洗 `terminalDetail` 交回父 run,不再只保留 80 字符 UI 摘要;回执 prompt 明确禁止重复同一委派,排队期间不提前写入父会话,真正开始执行时才幂等落盘,用户消息或回执消息落盘失败时不会进入 LLM。回执任务保留父 run 关联,并在真正开始或恢复前再次检查父 run 状态,关联缺失或父 run 不存在时失败关闭;该续跑仍受父 Agent 原有 FIFO、per-Agent OS 锁、权限确认、取消、恢复和 `needs-reconciliation` 屏障约束,不直接重入父 run、不插队、不新增独立 worker;父 run 已取消或普通失败时只保留 suppressed receipt 审计,不自动复活,父 Session 归档与切换会被未结束委派阻止,极端归档竞态下回执回落到父 Agent 当前可写 Session。恢复先恢复 pending action / reconciliation 屏障,再扫描“子任务终态已落盘但回执未提交”的窗口并补齐缺失回执;`needs-reconciliation` 本身不回执,只有人工核对后最终取消才回传 `cancelled`。 -- 历史记录(已由 V1.1 独立 Runner 替代):Runtime 最初通过 `resume_game_creator_agent_runtime_tasks` 把本地 JSONL 队列重接到当前 App 进程。当前恢复入口仍保留权限、任务顺序和 `agent.runtime.background_task.recovered` 审计语义,但实际由独立 Runner 接管原 run / session;已发出的上游 LLM 请求仍不能从网络中间点续传。 +- 历史记录(已由 V1.1 独立 Runner 替代):Runtime 最初通过 `resume_game_creator_agent_runtime_tasks` 把本地 JSONL 队列重接到当前 App 进程。当前恢复入口仍保留权限、任务顺序和 `agent.runtime.background_task.recovered` 审计语义,但实际由独立 Runner 接管原 run / session;已发出的上游 LLM 请求仍不能从网络中间点续传。2026-07-27 起,Runner 归 Tauri GUI 生命周期所有,同一 AppData 只允许一个 GUI owner。GUI 启动子进程会显式声明 `--gui-owner-required` 并在就绪后 attach owner;Runner 若在启动检查前已发现 owner 释放则直接失败,不得退化成 CLI-owned Runner。Runner 使用独立 watchdog 线程每 100ms 监控 owner OS 锁,不依赖服务端主循环继续推进;owner 丢失后先触发 1.5 秒共享 deadline 的 draining、Provider 中断和 process session 回收,若主循环或排空链路卡死则在 1.75 秒后由 Runner 自身进程安全硬退出并清理匹配 bootId 的 endpoint。因此正常最终退出、panic、SIGKILL 和 setup 中途失败都不会再因 busy 或主循环卡死而残留后台进程。endpoint 缺失 / 读取失败必须结合 Runner 实例锁判断;GUI 客户端强制兜底在 Linux 使用 pidfd、Windows 使用稳定进程 handle。macOS 没有等价稳定句柄,客户端不得在 start identity 检查后按裸 PID 强杀,而由跨平台 Runner 自身 watchdog 提供硬退出兜底。旧 endpoint 缺 start identity 时,只有认证 ping 精确匹配 PID + bootId 才允许迁移 busy 旧 Runner。未完成任务保持 durable 状态并在下一次启动走 reconciliation / recovery,不能伪造 completed 或重放副作用。关闭单个 WebView / 子窗口和普通 CLI 退出不触发该行为,版本切换与人工命令仍可使用只关闭空闲实例的 `runner.shutdown_if_idle`。 - 2026-07-10 补充,2026-07-16 由 V1.28 澄清:后台 planning 与预算内 final reply 使用专用最小上下文,只预置 Agent 身份、sessionId、runId、执行模式和工具策略;Agent 私有记忆、项目记忆、黑板、对话、资产、项目索引与文件正文只能经对应工具通过权限 gate 后作为 observation 进入下一轮。只有开发窗口的专业 Agent 前台直调可使用对应角色上下文;正式用户前台现已统一进入 `project-supervisor`。长黑板、记忆和对话按尾部截断,确保最新结论与最新定向消息优先保留。 - 2026-07-10 补充,2026-07-16 由 V1.28 澄清:同一 Agent 的开发前台直调、流式调试和后台任务统一使用 `.agent/runtime/locks/.lock` OS 文件锁。开发前台不再在整个 LLM 请求期间占用项目级写锁;同 Agent 后台任务在开发前台运行时只入队,前台成功或失败后把当前 Agent 锁直接移交给 drain,不重新抢锁,也不允许 drain 启动异常把已经完成的调试结果改判为失败。正式用户 GUI 不通过该入口直聊专业 Agent;不同 Agent 继续并行,真实项目写工具只在副作用执行期间短暂申请项目写锁。 - 2026-07-10 补充:默认 `agent.resume=confirm` 时,客户端自动恢复命令只做 auto gate 并返回待确认错误;主工作区和独立开发 Agent 聊天窗口在首次读取项目 Runtime 时都必须显示 `agent.resume` 确认条,确认对象绑定发起时的项目路径,切换项目会取消旧确认,异步返回后也不得把旧项目 Runtime 合并到新项目 UI。开发者确认后调用独立 `confirm_resume_game_creator_agent_runtime_tasks`,该命令仍执行 deny-only 权限检查后才接回 durable queue。临时调用失败不锁死项目路径,允许后续刷新重试;明确 deny 或取消都不恢复任务。 diff --git a/package.json b/package.json index e2eb51499..527265380 100644 --- a/package.json +++ b/package.json @@ -162,8 +162,9 @@ "ai-game-creator-shell:agent-runtime:supervisor-swarm-tool-plan-handoff-runner-kill-real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-swarm-tool-plan-handoff-runner-kill-real-e2e --", "ai-game-creator-shell:agent-runtime:steer-real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:steer-real-e2e --", "ai-game-creator-shell:agent-runtime:steer-runner-kill-real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:steer-runner-kill-real-e2e --", + "agent-runtime-core:check": "cargo test --manifest-path server-rs/crates/agent-runtime-core/Cargo.toml", "ai-game-creator-shell:typecheck": "npm --prefix apps/ai-game-creator-shell run typecheck", - "ai-game-creator-shell:check": "npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests && cargo test -p platform-llm --manifest-path server-rs/Cargo.toml && cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app && cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1 && npm run ai-game-creator-shell:agent-run:smoke", + "ai-game-creator-shell:check": "npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests && npm run agent-runtime-core:check && cargo test -p platform-llm --manifest-path server-rs/Cargo.toml && cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app && cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1 && npm run ai-game-creator-shell:agent-run:smoke", "check:native-shells": "node scripts/check-native-shells.mjs" }, "dependencies": { diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 1a660bb88..9af9effa3 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -44,6 +44,14 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "agent-runtime-core" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "ahash" version = "0.8.12" @@ -4100,6 +4108,7 @@ dependencies = [ name = "platform-llm" version = "0.1.0" dependencies = [ + "agent-runtime-core", "log", "reqwest", "serde", diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 3c68a1d50..ff6ab4ceb 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -7,6 +7,7 @@ default-members = [ "crates/api-server", ] exclude = [ + "crates/agent-runtime-core", "crates/module-bark-battle", "crates/module-big-fish", "crates/module-combat", diff --git a/server-rs/crates/agent-runtime-core/.gitignore b/server-rs/crates/agent-runtime-core/.gitignore new file mode 100644 index 000000000..042776aad --- /dev/null +++ b/server-rs/crates/agent-runtime-core/.gitignore @@ -0,0 +1,2 @@ +/Cargo.lock +/target/ diff --git a/server-rs/crates/agent-runtime-core/Cargo.toml b/server-rs/crates/agent-runtime-core/Cargo.toml new file mode 100644 index 000000000..655e2e25a --- /dev/null +++ b/server-rs/crates/agent-runtime-core/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "agent-runtime-core" +edition = "2024" +version = "0.1.0" +license = "UNLICENSED" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/server-rs/crates/agent-runtime-core/src/capability.rs b/server-rs/crates/agent-runtime-core/src/capability.rs new file mode 100644 index 000000000..d0abb7787 --- /dev/null +++ b/server-rs/crates/agent-runtime-core/src/capability.rs @@ -0,0 +1,287 @@ +use std::collections::BTreeMap; +use std::fmt; + +use serde_json::Value; + +use crate::contract::{ + ContractError, ContractErrorKind, validate_description, validate_function_name, + validate_identifier, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CapabilityRegistryErrorKind { + InvalidDefinition, + DuplicateId, + DuplicateFunctionName, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CapabilityRegistryError { + kind: CapabilityRegistryErrorKind, + detail: String, +} + +impl CapabilityRegistryError { + pub fn kind(&self) -> CapabilityRegistryErrorKind { + self.kind + } + + pub fn detail(&self) -> &str { + &self.detail + } +} + +impl fmt::Display for CapabilityRegistryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.detail) + } +} + +impl std::error::Error for CapabilityRegistryError {} + +impl From for CapabilityRegistryError { + fn from(error: ContractError) -> Self { + let kind = match error.kind() { + ContractErrorKind::DuplicateId => CapabilityRegistryErrorKind::DuplicateId, + ContractErrorKind::InvalidDefinition + | ContractErrorKind::DuplicateReference + | ContractErrorKind::UnknownCapability + | ContractErrorKind::UnknownCompletionPolicy => { + CapabilityRegistryErrorKind::InvalidDefinition + } + }; + Self { + kind, + detail: error.to_string(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CapabilityDefinition { + id: String, + function_name: String, + description: String, + input_schema: Value, + dispatch: D, +} + +impl CapabilityDefinition { + pub fn try_new( + id: impl Into, + function_name: impl Into, + description: impl Into, + input_schema: Value, + dispatch: D, + ) -> Result { + let id = id.into(); + let function_name = function_name.into(); + let description = description.into(); + validate_identifier(&id, "capability id")?; + validate_function_name(&function_name)?; + validate_description(&description, "capability description")?; + validate_input_schema(&id, &input_schema)?; + Ok(Self { + id, + function_name, + description, + input_schema, + dispatch, + }) + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn function_name(&self) -> &str { + &self.function_name + } + + pub fn description(&self) -> &str { + &self.description + } + + pub fn input_schema(&self) -> &Value { + &self.input_schema + } + + pub fn dispatch(&self) -> &D { + &self.dispatch + } +} + +fn validate_input_schema(id: &str, schema: &Value) -> Result<(), CapabilityRegistryError> { + let Some(object) = schema.as_object() else { + return Err(CapabilityRegistryError { + kind: CapabilityRegistryErrorKind::InvalidDefinition, + detail: format!("capability {id} 的 inputSchema 必须是 JSON object"), + }); + }; + if object.get("type").and_then(Value::as_str) != Some("object") { + return Err(CapabilityRegistryError { + kind: CapabilityRegistryErrorKind::InvalidDefinition, + detail: format!("capability {id} 的 inputSchema.type 必须为 object"), + }); + } + Ok(()) +} + +#[derive(Clone, Debug)] +pub struct CapabilityRegistry { + definitions: Vec>, + by_id: BTreeMap, + by_function_name: BTreeMap, +} + +impl CapabilityRegistry { + pub fn try_new( + definitions: impl IntoIterator>, + ) -> Result { + let definitions = definitions.into_iter().collect::>(); + let mut by_id = BTreeMap::new(); + let mut by_function_name = BTreeMap::new(); + for (index, definition) in definitions.iter().enumerate() { + if by_id.insert(definition.id.clone(), index).is_some() { + return Err(CapabilityRegistryError { + kind: CapabilityRegistryErrorKind::DuplicateId, + detail: format!("capability id 重复:{}", definition.id), + }); + } + if by_function_name + .insert(definition.function_name.clone(), index) + .is_some() + { + return Err(CapabilityRegistryError { + kind: CapabilityRegistryErrorKind::DuplicateFunctionName, + detail: format!("capability functionName 重复:{}", definition.function_name), + }); + } + } + Ok(Self { + definitions, + by_id, + by_function_name, + }) + } + + pub fn len(&self) -> usize { + self.definitions.len() + } + + pub fn is_empty(&self) -> bool { + self.definitions.is_empty() + } + + pub fn get(&self, id: &str) -> Option<&CapabilityDefinition> { + self.by_id + .get(id) + .and_then(|index| self.definitions.get(*index)) + } + + pub fn get_by_function_name(&self, function_name: &str) -> Option<&CapabilityDefinition> { + self.by_function_name + .get(function_name) + .and_then(|index| self.definitions.get(*index)) + } + + pub fn iter(&self) -> impl ExactSizeIterator> { + self.definitions.iter() + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn definition(id: &str, function_name: &str) -> CapabilityDefinition<&'static str> { + CapabilityDefinition::try_new( + id, + function_name, + "测试能力", + json!({"type": "object", "properties": {}, "additionalProperties": false}), + "host", + ) + .expect("definition") + } + + #[test] + fn registry_preserves_order_and_supports_both_lookups() { + let registry = CapabilityRegistry::try_new([ + definition("document.read", "runtime_tool_document_read"), + definition("document.review", "runtime_tool_document_review"), + ]) + .expect("registry"); + assert_eq!( + registry.iter().map(|item| item.id()).collect::>(), + vec!["document.read", "document.review"] + ); + assert_eq!( + registry + .get_by_function_name("runtime_tool_document_review") + .map(CapabilityDefinition::id), + Some("document.review") + ); + assert_eq!( + registry.get("document.read").map(|item| *item.dispatch()), + Some("host") + ); + } + + #[test] + fn registry_rejects_duplicate_id_and_function_name() { + let duplicate_id = CapabilityRegistry::try_new([ + definition("document.read", "runtime_tool_document_read"), + definition("document.read", "runtime_tool_document_search"), + ]) + .expect_err("duplicate id"); + assert_eq!( + duplicate_id.kind(), + CapabilityRegistryErrorKind::DuplicateId + ); + + let duplicate_function = CapabilityRegistry::try_new([ + definition("document.read", "runtime_tool_document_read"), + definition("document.search", "runtime_tool_document_read"), + ]) + .expect_err("duplicate function"); + assert_eq!( + duplicate_function.kind(), + CapabilityRegistryErrorKind::DuplicateFunctionName + ); + } + + #[test] + fn definition_rejects_non_object_schema() { + let error = CapabilityDefinition::try_new( + "document.read", + "runtime_tool_document_read", + "读取文档", + json!({"type": "string"}), + (), + ) + .expect_err("schema must be object"); + assert_eq!(error.kind(), CapabilityRegistryErrorKind::InvalidDefinition); + } + + #[test] + fn definition_rejects_invalid_identity_and_empty_description() { + for (id, function_name, description) in [ + ("../document.read", "runtime_tool_document_read", "读取文档"), + ("document.read", "runtime-tool-document-read", "读取文档"), + ("document.read", "runtime_tool_document_read", " "), + ] { + let error = CapabilityDefinition::try_new( + id, + function_name, + description, + json!({"type": "object"}), + (), + ) + .expect_err("invalid definition must fail closed"); + assert_eq!(error.kind(), CapabilityRegistryErrorKind::InvalidDefinition); + } + } +} diff --git a/server-rs/crates/agent-runtime-core/src/catalog.rs b/server-rs/crates/agent-runtime-core/src/catalog.rs new file mode 100644 index 000000000..c40d59840 --- /dev/null +++ b/server-rs/crates/agent-runtime-core/src/catalog.rs @@ -0,0 +1,125 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::{Map, Value}; + +use crate::capability::CapabilityRegistry; +use crate::contract::{ContractError, ContractErrorKind, validate_identifier, validate_metadata}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AgentDescriptor { + id: String, + role: String, + capability_ids: Vec, + metadata: Value, +} + +impl AgentDescriptor { + pub fn try_new( + id: impl Into, + role: impl Into, + capability_ids: impl IntoIterator>, + ) -> Result { + let id = id.into(); + let role = role.into(); + validate_identifier(&id, "agent id")?; + validate_identifier(&role, "agent role")?; + let capability_ids = collect_unique_capability_ids(capability_ids, "agent")?; + Ok(Self { + id, + role, + capability_ids, + metadata: Value::Object(Map::new()), + }) + } + + pub fn with_metadata(mut self, metadata: Value) -> Result { + validate_metadata(&metadata, "agent metadata")?; + self.metadata = metadata; + Ok(self) + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn role(&self) -> &str { + &self.role + } + + pub fn capability_ids(&self) -> &[String] { + &self.capability_ids + } + + pub fn metadata(&self) -> &Value { + &self.metadata + } +} + +#[derive(Clone, Debug)] +pub struct AgentCatalog { + agents: Vec, + by_id: BTreeMap, +} + +impl AgentCatalog { + pub fn try_new( + agents: impl IntoIterator, + ) -> Result { + let agents = agents.into_iter().collect::>(); + let mut by_id = BTreeMap::new(); + for (index, agent) in agents.iter().enumerate() { + if by_id.insert(agent.id.clone(), index).is_some() { + return Err(ContractError::new( + ContractErrorKind::DuplicateId, + format!("agent id 重复:{}", agent.id), + )); + } + } + Ok(Self { agents, by_id }) + } + + pub fn get(&self, id: &str) -> Option<&AgentDescriptor> { + self.by_id.get(id).and_then(|index| self.agents.get(*index)) + } + + pub fn iter(&self) -> impl ExactSizeIterator { + self.agents.iter() + } + + pub fn validate_capabilities( + &self, + capabilities: &CapabilityRegistry, + ) -> Result<(), ContractError> { + for agent in &self.agents { + for capability_id in &agent.capability_ids { + if capabilities.get(capability_id).is_none() { + return Err(ContractError::new( + ContractErrorKind::UnknownCapability, + format!("agent {} 引用了未知 capability:{capability_id}", agent.id), + )); + } + } + } + Ok(()) + } +} + +pub(crate) fn collect_unique_capability_ids( + capability_ids: impl IntoIterator>, + owner: &str, +) -> Result, ContractError> { + let mut seen = BTreeSet::new(); + let mut output = Vec::new(); + for capability_id in capability_ids { + let capability_id = capability_id.into(); + validate_identifier(&capability_id, "capability id")?; + if !seen.insert(capability_id.clone()) { + return Err(ContractError::new( + ContractErrorKind::DuplicateReference, + format!("{owner} capability 重复:{capability_id}"), + )); + } + output.push(capability_id); + } + Ok(output) +} diff --git a/server-rs/crates/agent-runtime-core/src/completion.rs b/server-rs/crates/agent-runtime-core/src/completion.rs new file mode 100644 index 000000000..f26bed332 --- /dev/null +++ b/server-rs/crates/agent-runtime-core/src/completion.rs @@ -0,0 +1,79 @@ +use crate::contract::{ContractError, validate_description, validate_identifier}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CompletionBlocker { + code: String, + summary: String, +} + +impl CompletionBlocker { + pub fn try_new( + code: impl Into, + summary: impl Into, + ) -> Result { + let code = code.into(); + let summary = summary.into(); + validate_identifier(&code, "completion blocker code")?; + validate_description(&summary, "completion blocker summary")?; + Ok(Self { code, summary }) + } + + pub fn code(&self) -> &str { + &self.code + } + + pub fn summary(&self) -> &str { + &self.summary + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum CompletionDecisionState { + Ready, + Blocked(Vec), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CompletionDecision { + state: CompletionDecisionState, +} + +impl CompletionDecision { + pub fn ready() -> Self { + Self { + state: CompletionDecisionState::Ready, + } + } + + pub fn blocked( + blockers: impl IntoIterator, + ) -> Result { + let blockers = blockers.into_iter().collect::>(); + if blockers.is_empty() { + return Err(ContractError::new( + crate::ContractErrorKind::InvalidDefinition, + "blocked completion decision 至少需要一个 blocker", + )); + } + Ok(Self { + state: CompletionDecisionState::Blocked(blockers), + }) + } + + pub fn is_ready(&self) -> bool { + matches!(self.state, CompletionDecisionState::Ready) + } + + pub fn blockers(&self) -> &[CompletionBlocker] { + match &self.state { + CompletionDecisionState::Ready => &[], + CompletionDecisionState::Blocked(blockers) => blockers, + } + } +} + +pub trait CompletionPolicy { + fn id(&self) -> &str; + + fn evaluate(&self, context: &Context) -> CompletionDecision; +} diff --git a/server-rs/crates/agent-runtime-core/src/contract.rs b/server-rs/crates/agent-runtime-core/src/contract.rs new file mode 100644 index 000000000..cbd873e8a --- /dev/null +++ b/server-rs/crates/agent-runtime-core/src/contract.rs @@ -0,0 +1,121 @@ +use std::fmt; + +use serde_json::Value; + +const IDENTIFIER_MAX_CHARS: usize = 128; +const DESCRIPTION_MAX_CHARS: usize = 4_000; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ContractErrorKind { + InvalidDefinition, + DuplicateId, + DuplicateReference, + UnknownCapability, + UnknownCompletionPolicy, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContractError { + kind: ContractErrorKind, + detail: String, +} + +impl ContractError { + pub fn new(kind: ContractErrorKind, detail: impl Into) -> Self { + Self { + kind, + detail: detail.into(), + } + } + + pub fn kind(&self) -> ContractErrorKind { + self.kind + } + + pub fn detail(&self) -> &str { + &self.detail + } +} + +impl fmt::Display for ContractError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.detail) + } +} + +impl std::error::Error for ContractError {} + +pub(crate) fn validate_identifier(value: &str, field: &str) -> Result<(), ContractError> { + if value != value.trim() { + return Err(ContractError::new( + ContractErrorKind::InvalidDefinition, + format!("{field} 不得包含首尾空白"), + )); + } + let mut chars = value.chars(); + let first = chars.next().ok_or_else(|| { + ContractError::new( + ContractErrorKind::InvalidDefinition, + format!("{field} 不能为空"), + ) + })?; + if value.chars().count() > IDENTIFIER_MAX_CHARS + || !first.is_ascii_alphanumeric() + || !chars.all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-' | ':') + }) + { + return Err(ContractError::new( + ContractErrorKind::InvalidDefinition, + format!("{field} 不是合法稳定标识:{value}"), + )); + } + Ok(()) +} + +pub(crate) fn validate_function_name(value: &str) -> Result<(), ContractError> { + if value != value.trim() { + return Err(ContractError::new( + ContractErrorKind::InvalidDefinition, + "functionName 不得包含首尾空白", + )); + } + let mut chars = value.chars(); + let first = chars.next().ok_or_else(|| { + ContractError::new( + ContractErrorKind::InvalidDefinition, + "functionName 不能为空", + ) + })?; + if value.chars().count() > IDENTIFIER_MAX_CHARS + || !(first.is_ascii_alphabetic() || first == '_') + || !chars.all(|character| character.is_ascii_alphanumeric() || character == '_') + { + return Err(ContractError::new( + ContractErrorKind::InvalidDefinition, + format!("functionName 不是合法函数标识:{value}"), + )); + } + Ok(()) +} + +pub(crate) fn validate_description(value: &str, field: &str) -> Result<(), ContractError> { + let chars = value.trim().chars().count(); + if chars == 0 || chars > DESCRIPTION_MAX_CHARS { + return Err(ContractError::new( + ContractErrorKind::InvalidDefinition, + format!("{field} 必须为 1..={DESCRIPTION_MAX_CHARS} 个字符"), + )); + } + Ok(()) +} + +pub(crate) fn validate_metadata(metadata: &Value, field: &str) -> Result<(), ContractError> { + if !metadata.is_object() { + return Err(ContractError::new( + ContractErrorKind::InvalidDefinition, + format!("{field} 必须是 JSON object"), + )); + } + Ok(()) +} diff --git a/server-rs/crates/agent-runtime-core/src/lib.rs b/server-rs/crates/agent-runtime-core/src/lib.rs new file mode 100644 index 000000000..f525a8696 --- /dev/null +++ b/server-rs/crates/agent-runtime-core/src/lib.rs @@ -0,0 +1,39 @@ +//! Pure execution kernel and contracts shared by local multi-agent runtime hosts. +//! +//! The kernel owns deterministic lifecycle, lane, action and all-join decisions, +//! while product hosts inject persistence, time and capability execution. It does +//! not depend on provider DTOs, desktop frameworks, prompts, permissions or any +//! product domain. + +mod capability; +mod catalog; +mod completion; +mod contract; +mod lifecycle; +mod profile; +mod provider; +mod runtime; + +pub use capability::{ + CapabilityDefinition, CapabilityRegistry, CapabilityRegistryError, CapabilityRegistryErrorKind, +}; +pub use catalog::{AgentCatalog, AgentDescriptor}; +pub use completion::{CompletionBlocker, CompletionDecision, CompletionPolicy}; +pub use contract::{ContractError, ContractErrorKind}; +pub use lifecycle::{RecoverableTaskState, RecoveryStep, next_recovery_step}; +pub use profile::{RunProfileCatalog, RunProfileDefinition}; +pub use provider::{ + ProviderAdapter, ProviderCapability, ProviderContentPart, ProviderDescriptor, ProviderError, + ProviderErrorKind, ProviderFuture, ProviderInstanceId, ProviderMessage, ProviderProtocolId, + ProviderReasoningEffort, ProviderRegistry, ProviderRequest, ProviderResponse, ProviderRole, + ProviderStreamEvent, ProviderStreamFuture, ProviderStreamSink, ProviderTarget, + ProviderTextVerbosity, ProviderToolCall, ProviderToolChoice, ProviderToolDefinition, + ProviderUsage, +}; +pub use runtime::{ + ActionExecutionOutcome, ActionRecord, ActionStatus, CompletionAttempt, DelegationGroup, + JoinMode, ObservationStatus, RUNTIME_SNAPSHOT_SCHEMA_VERSION, RunRecord, RunSpec, RunStatus, + RunTerminalResult, RuntimeAction, RuntimeClock, RuntimeEngine, RuntimeError, RuntimeErrorKind, + RuntimeEvent, RuntimeEventKind, RuntimeObservation, RuntimeSnapshot, RuntimeStore, + ToolExecution, ToolHost, ToolOutput, +}; diff --git a/server-rs/crates/agent-runtime-core/src/lifecycle.rs b/server-rs/crates/agent-runtime-core/src/lifecycle.rs new file mode 100644 index 000000000..0a1749fd0 --- /dev/null +++ b/server-rs/crates/agent-runtime-core/src/lifecycle.rs @@ -0,0 +1,88 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum RecoverableTaskState { + Pending, + Running, + WaitingForConfirmation, + WaitingForUserInput, + Other, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RecoveryStep { + ResumeRunning { index: usize }, + WaitForExternalInput, + StartPending { index: usize }, + Idle, +} + +pub fn next_recovery_step(tasks: &[RecoverableTaskState]) -> RecoveryStep { + if let Some(index) = tasks + .iter() + .position(|state| *state == RecoverableTaskState::Running) + { + return RecoveryStep::ResumeRunning { index }; + } + if tasks.iter().any(|state| { + matches!( + state, + RecoverableTaskState::WaitingForConfirmation + | RecoverableTaskState::WaitingForUserInput + ) + }) { + return RecoveryStep::WaitForExternalInput; + } + tasks + .iter() + .position(|state| *state == RecoverableTaskState::Pending) + .map_or(RecoveryStep::Idle, |index| RecoveryStep::StartPending { + index, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recovery_prefers_first_running_task() { + assert_eq!( + next_recovery_step(&[ + RecoverableTaskState::Pending, + RecoverableTaskState::Running, + RecoverableTaskState::Running, + RecoverableTaskState::WaitingForConfirmation, + ]), + RecoveryStep::ResumeRunning { index: 1 } + ); + } + + #[test] + fn external_input_blocks_pending_recovery() { + assert_eq!( + next_recovery_step(&[ + RecoverableTaskState::Pending, + RecoverableTaskState::WaitingForUserInput, + ]), + RecoveryStep::WaitForExternalInput + ); + } + + #[test] + fn recovery_starts_first_pending_or_remains_idle() { + assert_eq!( + next_recovery_step(&[ + RecoverableTaskState::Other, + RecoverableTaskState::Pending, + RecoverableTaskState::Pending, + ]), + RecoveryStep::StartPending { index: 1 } + ); + assert_eq!( + next_recovery_step(&[RecoverableTaskState::Other]), + RecoveryStep::Idle + ); + } +} diff --git a/server-rs/crates/agent-runtime-core/src/profile.rs b/server-rs/crates/agent-runtime-core/src/profile.rs new file mode 100644 index 000000000..d7cc7a5db --- /dev/null +++ b/server-rs/crates/agent-runtime-core/src/profile.rs @@ -0,0 +1,134 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::{Map, Value}; + +use crate::capability::CapabilityRegistry; +use crate::catalog::collect_unique_capability_ids; +use crate::contract::{ContractError, ContractErrorKind, validate_identifier, validate_metadata}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RunProfileDefinition { + id: String, + capability_ids: Vec, + completion_policy_id: String, + metadata: Value, +} + +impl RunProfileDefinition { + pub fn try_new( + id: impl Into, + capability_ids: impl IntoIterator>, + completion_policy_id: impl Into, + ) -> Result { + let id = id.into(); + let completion_policy_id = completion_policy_id.into(); + validate_identifier(&id, "run profile id")?; + validate_identifier(&completion_policy_id, "completion policy id")?; + let capability_ids = collect_unique_capability_ids(capability_ids, "run profile")?; + Ok(Self { + id, + capability_ids, + completion_policy_id, + metadata: Value::Object(Map::new()), + }) + } + + pub fn with_metadata(mut self, metadata: Value) -> Result { + validate_metadata(&metadata, "run profile metadata")?; + self.metadata = metadata; + Ok(self) + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn capability_ids(&self) -> &[String] { + &self.capability_ids + } + + pub fn completion_policy_id(&self) -> &str { + &self.completion_policy_id + } + + pub fn metadata(&self) -> &Value { + &self.metadata + } +} + +#[derive(Clone, Debug)] +pub struct RunProfileCatalog { + profiles: Vec, + by_id: BTreeMap, +} + +impl RunProfileCatalog { + pub fn try_new( + profiles: impl IntoIterator, + ) -> Result { + let profiles = profiles.into_iter().collect::>(); + let mut by_id = BTreeMap::new(); + for (index, profile) in profiles.iter().enumerate() { + if by_id.insert(profile.id.clone(), index).is_some() { + return Err(ContractError::new( + ContractErrorKind::DuplicateId, + format!("run profile id 重复:{}", profile.id), + )); + } + } + Ok(Self { profiles, by_id }) + } + + pub fn get(&self, id: &str) -> Option<&RunProfileDefinition> { + self.by_id + .get(id) + .and_then(|index| self.profiles.get(*index)) + } + + pub fn iter(&self) -> impl ExactSizeIterator { + self.profiles.iter() + } + + pub fn validate_capabilities( + &self, + capabilities: &CapabilityRegistry, + ) -> Result<(), ContractError> { + for profile in &self.profiles { + for capability_id in &profile.capability_ids { + if capabilities.get(capability_id).is_none() { + return Err(ContractError::new( + ContractErrorKind::UnknownCapability, + format!( + "run profile {} 引用了未知 capability:{capability_id}", + profile.id + ), + )); + } + } + } + Ok(()) + } + + pub fn validate_completion_policy_ids<'a>( + &self, + policy_ids: impl IntoIterator, + ) -> Result<(), ContractError> { + let mut known_policy_ids = BTreeSet::new(); + for policy_id in policy_ids { + validate_identifier(policy_id, "completion policy id")?; + known_policy_ids.insert(policy_id); + } + for profile in &self.profiles { + if !known_policy_ids.contains(profile.completion_policy_id.as_str()) { + return Err(ContractError::new( + ContractErrorKind::UnknownCompletionPolicy, + format!( + "run profile {} 引用了未知 completion policy:{}", + profile.id, profile.completion_policy_id + ), + )); + } + } + Ok(()) + } +} diff --git a/server-rs/crates/agent-runtime-core/src/provider.rs b/server-rs/crates/agent-runtime-core/src/provider.rs new file mode 100644 index 000000000..a093045c0 --- /dev/null +++ b/server-rs/crates/agent-runtime-core/src/provider.rs @@ -0,0 +1,1091 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::contract::{ + validate_description, validate_function_name, validate_identifier, validate_metadata, +}; + +macro_rules! provider_id { + ($name:ident, $label:literal) => { + #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] + #[serde(transparent)] + pub struct $name(String); + + impl $name { + pub fn try_new(value: impl Into) -> Result { + let value = value.into(); + validate_identifier(&value, $label).map_err(ProviderError::from_contract)?; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl TryFrom for $name { + type Error = ProviderError; + + fn try_from(value: String) -> Result { + Self::try_new(value) + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::try_new(value).map_err(serde::de::Error::custom) + } + } + + impl fmt::Display for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } + } + }; +} + +provider_id!(ProviderInstanceId, "provider instance id"); +provider_id!(ProviderProtocolId, "provider protocol id"); + +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ProviderCapability { + Streaming, + FunctionTools, + RequiredToolChoice, + ImageInput, + WebSearch, + ReasoningEffort, + TextVerbosity, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ProviderDescriptor { + instance_id: ProviderInstanceId, + protocol_id: ProviderProtocolId, + display_name: String, + capabilities: BTreeSet, + metadata: Value, +} + +impl ProviderDescriptor { + pub fn try_new( + instance_id: ProviderInstanceId, + protocol_id: ProviderProtocolId, + capabilities: impl IntoIterator, + ) -> Result { + let descriptor = Self { + display_name: instance_id.as_str().to_string(), + instance_id, + protocol_id, + capabilities: capabilities.into_iter().collect(), + metadata: Value::Object(Map::new()), + }; + descriptor.validate()?; + Ok(descriptor) + } + + pub fn with_metadata(mut self, metadata: Value) -> Result { + validate_metadata(&metadata, "provider metadata").map_err(ProviderError::from_contract)?; + self.metadata = metadata; + Ok(self) + } + + pub fn with_display_name( + mut self, + display_name: impl Into, + ) -> Result { + self.display_name = display_name.into(); + self.validate()?; + Ok(self) + } + + pub fn instance_id(&self) -> &ProviderInstanceId { + &self.instance_id + } + + pub fn protocol_id(&self) -> &ProviderProtocolId { + &self.protocol_id + } + + pub fn display_name(&self) -> &str { + &self.display_name + } + + pub fn capabilities(&self) -> &BTreeSet { + &self.capabilities + } + + pub fn supports(&self, capability: ProviderCapability) -> bool { + self.capabilities.contains(&capability) + } + + pub fn metadata(&self) -> &Value { + &self.metadata + } + + fn validate(&self) -> Result<(), ProviderError> { + validate_description(&self.display_name, "provider display name") + .map_err(ProviderError::from_contract)?; + validate_metadata(&self.metadata, "provider metadata") + .map_err(ProviderError::from_contract)?; + if self.supports(ProviderCapability::RequiredToolChoice) + && !self.supports(ProviderCapability::FunctionTools) + { + return Err(ProviderError::invalid( + "required-tool-choice capability 依赖 function-tools capability", + )); + } + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ProviderRole { + System, + User, + Assistant, + Tool, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde( + deny_unknown_fields, + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] +pub enum ProviderContentPart { + Text { + text: String, + }, + Image { + source: Value, + }, + ToolResult { + tool_call_id: String, + output: Value, + is_error: bool, + }, +} + +impl ProviderContentPart { + pub fn text(text: impl Into) -> Result { + let text = text.into(); + validate_non_empty_text(&text, "message text")?; + Ok(Self::Text { text }) + } + + pub fn image(source: Value) -> Result { + if !source.is_object() { + return Err(ProviderError::invalid("image source 必须是 JSON object")); + } + Ok(Self::Image { source }) + } + + pub fn tool_result( + tool_call_id: impl Into, + output: Value, + is_error: bool, + ) -> Result { + let tool_call_id = tool_call_id.into(); + validate_identifier(&tool_call_id, "tool call id").map_err(ProviderError::from_contract)?; + Ok(Self::ToolResult { + tool_call_id, + output, + is_error, + }) + } + + fn validate(&self) -> Result<(), ProviderError> { + match self { + Self::Text { text } => validate_non_empty_text(text, "message text"), + Self::Image { source } if !source.is_object() => { + Err(ProviderError::invalid("image source 必须是 JSON object")) + } + Self::ToolResult { tool_call_id, .. } => { + validate_identifier(tool_call_id, "tool call id") + .map_err(ProviderError::from_contract) + } + Self::Image { .. } => Ok(()), + } + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ProviderMessage { + role: ProviderRole, + content: Vec, +} + +impl ProviderMessage { + pub fn try_new( + role: ProviderRole, + content: impl IntoIterator, + ) -> Result { + let message = Self { + role, + content: content.into_iter().collect(), + }; + message.validate()?; + Ok(message) + } + + pub fn role(&self) -> ProviderRole { + self.role + } + + pub fn content(&self) -> &[ProviderContentPart] { + &self.content + } + + fn validate(&self) -> Result<(), ProviderError> { + if self.content.is_empty() { + return Err(ProviderError::invalid("provider message content 不能为空")); + } + for part in &self.content { + part.validate()?; + } + Ok(()) + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ProviderToolDefinition { + name: String, + description: String, + input_schema: Value, + strict: bool, +} + +impl ProviderToolDefinition { + pub fn try_new( + name: impl Into, + description: impl Into, + input_schema: Value, + ) -> Result { + let tool = Self { + name: name.into(), + description: description.into(), + input_schema, + strict: false, + }; + tool.validate()?; + Ok(tool) + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn description(&self) -> &str { + &self.description + } + + pub fn input_schema(&self) -> &Value { + &self.input_schema + } + + pub fn with_strict(mut self, strict: bool) -> Self { + self.strict = strict; + self + } + + pub fn strict(&self) -> bool { + self.strict + } + + fn validate(&self) -> Result<(), ProviderError> { + validate_function_name(&self.name).map_err(ProviderError::from_contract)?; + validate_description(&self.description, "provider tool description") + .map_err(ProviderError::from_contract)?; + if self.input_schema.get("type").and_then(Value::as_str) != Some("object") { + return Err(ProviderError::invalid( + "provider tool inputSchema.type 必须为 object", + )); + } + Ok(()) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "type", content = "name", rename_all = "kebab-case")] +pub enum ProviderToolChoice { + Auto, + None, + Required, + Specific(String), +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ProviderReasoningEffort { + Minimal, + Low, + Medium, + High, + XHigh, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ProviderTextVerbosity { + Low, + Medium, + High, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ProviderRequest { + request_id: String, + model: Option, + messages: Vec, + max_output_tokens: Option, + request_timeout_ms: Option, + tools: Vec, + tool_choice: ProviderToolChoice, + web_search: bool, + reasoning_effort: Option, + text_verbosity: Option, + metadata: Value, +} + +impl ProviderRequest { + pub fn try_new( + request_id: impl Into, + messages: impl IntoIterator, + ) -> Result { + let request = Self { + request_id: request_id.into(), + model: None, + messages: messages.into_iter().collect(), + max_output_tokens: None, + request_timeout_ms: None, + tools: Vec::new(), + tool_choice: ProviderToolChoice::Auto, + web_search: false, + reasoning_effort: None, + text_verbosity: None, + metadata: Value::Object(Map::new()), + }; + request.validate()?; + Ok(request) + } + + pub fn with_tools( + mut self, + tools: impl IntoIterator, + tool_choice: ProviderToolChoice, + ) -> Result { + self.tools = tools.into_iter().collect(); + self.tool_choice = tool_choice; + self.validate()?; + Ok(self) + } + + pub fn with_model(mut self, model: impl Into) -> Result { + self.model = Some(model.into()); + self.validate()?; + Ok(self) + } + + pub fn with_max_output_tokens(mut self, max_output_tokens: u32) -> Result { + self.max_output_tokens = Some(max_output_tokens); + self.validate()?; + Ok(self) + } + + pub fn with_request_timeout_ms( + mut self, + request_timeout_ms: u64, + ) -> Result { + self.request_timeout_ms = Some(request_timeout_ms); + self.validate()?; + Ok(self) + } + + pub fn with_web_search(mut self, enabled: bool) -> Self { + self.web_search = enabled; + self + } + + pub fn with_reasoning_effort(mut self, effort: ProviderReasoningEffort) -> Self { + self.reasoning_effort = Some(effort); + self + } + + pub fn with_text_verbosity(mut self, verbosity: ProviderTextVerbosity) -> Self { + self.text_verbosity = Some(verbosity); + self + } + + pub fn with_metadata(mut self, metadata: Value) -> Result { + validate_metadata(&metadata, "provider request metadata") + .map_err(ProviderError::from_contract)?; + self.metadata = metadata; + Ok(self) + } + + pub fn request_id(&self) -> &str { + &self.request_id + } + pub fn model(&self) -> Option<&str> { + self.model.as_deref() + } + pub fn messages(&self) -> &[ProviderMessage] { + &self.messages + } + pub fn max_output_tokens(&self) -> Option { + self.max_output_tokens + } + pub fn request_timeout_ms(&self) -> Option { + self.request_timeout_ms + } + pub fn tools(&self) -> &[ProviderToolDefinition] { + &self.tools + } + pub fn tool_choice(&self) -> &ProviderToolChoice { + &self.tool_choice + } + pub fn web_search(&self) -> bool { + self.web_search + } + pub fn reasoning_effort(&self) -> Option { + self.reasoning_effort + } + pub fn text_verbosity(&self) -> Option { + self.text_verbosity + } + pub fn metadata(&self) -> &Value { + &self.metadata + } + + pub fn required_capabilities(&self) -> BTreeSet { + let mut required = BTreeSet::new(); + if !self.tools.is_empty() { + required.insert(ProviderCapability::FunctionTools); + } + if matches!( + self.tool_choice, + ProviderToolChoice::Required | ProviderToolChoice::Specific(_) + ) { + required.insert(ProviderCapability::FunctionTools); + required.insert(ProviderCapability::RequiredToolChoice); + } + if self + .messages + .iter() + .flat_map(|message| message.content.iter()) + .any(|part| matches!(part, ProviderContentPart::ToolResult { .. })) + { + required.insert(ProviderCapability::FunctionTools); + } + if self + .messages + .iter() + .flat_map(|message| message.content.iter()) + .any(|part| matches!(part, ProviderContentPart::Image { .. })) + { + required.insert(ProviderCapability::ImageInput); + } + if self.web_search { + required.insert(ProviderCapability::WebSearch); + } + if self.reasoning_effort.is_some() { + required.insert(ProviderCapability::ReasoningEffort); + } + if self.text_verbosity.is_some() { + required.insert(ProviderCapability::TextVerbosity); + } + required + } + + fn validate(&self) -> Result<(), ProviderError> { + validate_identifier(&self.request_id, "provider request id") + .map_err(ProviderError::from_contract)?; + if self + .model + .as_ref() + .is_some_and(|model| model.trim().is_empty()) + { + return Err(ProviderError::invalid("provider request model 不能为空")); + } + if self.max_output_tokens == Some(0) { + return Err(ProviderError::invalid( + "provider request maxOutputTokens 必须大于 0", + )); + } + if self.request_timeout_ms == Some(0) { + return Err(ProviderError::invalid( + "provider request requestTimeoutMs 必须大于 0", + )); + } + if self.messages.is_empty() { + return Err(ProviderError::invalid("provider request messages 不能为空")); + } + for message in &self.messages { + message.validate()?; + } + let mut tool_names = BTreeSet::new(); + for tool in &self.tools { + tool.validate()?; + if !tool_names.insert(tool.name.as_str()) { + return Err(ProviderError::invalid(format!( + "provider tool name 重复:{}", + tool.name + ))); + } + } + match &self.tool_choice { + ProviderToolChoice::Auto | ProviderToolChoice::None => {} + ProviderToolChoice::Required if self.tools.is_empty() => { + return Err(ProviderError::invalid( + "required tool choice 需要至少一个 function tool", + )); + } + ProviderToolChoice::Specific(name) => { + validate_function_name(name).map_err(ProviderError::from_contract)?; + if !tool_names.contains(name.as_str()) { + return Err(ProviderError::invalid(format!( + "specific tool choice 引用了未知工具:{name}" + ))); + } + } + ProviderToolChoice::Required => {} + } + validate_metadata(&self.metadata, "provider request metadata") + .map_err(ProviderError::from_contract) + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ProviderToolCall { + id: String, + name: String, + arguments: String, +} + +impl ProviderToolCall { + pub fn try_new( + id: impl Into, + name: impl Into, + arguments: impl Into, + ) -> Result { + let call = Self { + id: id.into(), + name: name.into(), + arguments: arguments.into(), + }; + validate_identifier(&call.id, "provider tool call id") + .map_err(ProviderError::from_contract)?; + validate_function_name(&call.name).map_err(ProviderError::from_contract)?; + Ok(call) + } + pub fn id(&self) -> &str { + &self.id + } + pub fn name(&self) -> &str { + &self.name + } + pub fn arguments(&self) -> &str { + &self.arguments + } +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ProviderUsage { + input_tokens: u64, + output_tokens: u64, + total_tokens: u64, +} + +impl ProviderUsage { + pub fn new(input_tokens: u64, output_tokens: u64, total_tokens: u64) -> Self { + Self { + input_tokens, + output_tokens, + total_tokens, + } + } + pub fn input_tokens(&self) -> u64 { + self.input_tokens + } + pub fn output_tokens(&self) -> u64 { + self.output_tokens + } + pub fn total_tokens(&self) -> u64 { + self.total_tokens + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ProviderResponse { + request_id: String, + model: String, + response_id: Option, + content: Vec, + tool_calls: Vec, + finish_reason: Option, + usage: Option, +} + +impl ProviderResponse { + pub fn try_new( + request_id: impl Into, + model: impl Into, + content: impl IntoIterator, + tool_calls: impl IntoIterator, + ) -> Result { + let response = Self { + request_id: request_id.into(), + model: model.into(), + response_id: None, + content: content.into_iter().collect(), + tool_calls: tool_calls.into_iter().collect(), + finish_reason: None, + usage: None, + }; + validate_identifier(&response.request_id, "provider response request id") + .map_err(ProviderError::from_contract)?; + validate_non_empty_text(&response.model, "provider response model")?; + Ok(response) + } + pub fn with_finish_reason(mut self, reason: impl Into) -> Result { + let reason = reason.into(); + validate_non_empty_text(&reason, "provider finish reason")?; + self.finish_reason = Some(reason); + Ok(self) + } + pub fn with_response_id( + mut self, + response_id: impl Into, + ) -> Result { + let response_id = response_id.into(); + validate_non_empty_text(&response_id, "provider response id")?; + self.response_id = Some(response_id); + Ok(self) + } + pub fn with_usage(mut self, usage: ProviderUsage) -> Self { + self.usage = Some(usage); + self + } + pub fn request_id(&self) -> &str { + &self.request_id + } + pub fn model(&self) -> &str { + &self.model + } + pub fn response_id(&self) -> Option<&str> { + self.response_id.as_deref() + } + pub fn content(&self) -> &[ProviderContentPart] { + &self.content + } + pub fn tool_calls(&self) -> &[ProviderToolCall] { + &self.tool_calls + } + pub fn finish_reason(&self) -> Option<&str> { + self.finish_reason.as_deref() + } + pub fn usage(&self) -> Option { + self.usage + } + + fn validate_for_request( + &self, + request_id: &str, + declared_tools: &BTreeSet, + ) -> Result<(), ProviderError> { + if self.request_id != request_id { + return Err(ProviderError::invalid( + "provider response request id 与请求不匹配", + )); + } + validate_non_empty_text(&self.model, "provider response model")?; + if self.content.is_empty() && self.tool_calls.is_empty() { + return Err(ProviderError::invalid( + "provider response 必须包含 content 或 toolCalls", + )); + } + for part in &self.content { + part.validate()?; + } + let mut call_ids = BTreeSet::new(); + for call in &self.tool_calls { + validate_identifier(&call.id, "provider tool call id") + .map_err(ProviderError::from_contract)?; + validate_function_name(&call.name).map_err(ProviderError::from_contract)?; + if !call_ids.insert(call.id.as_str()) { + return Err(ProviderError::invalid(format!( + "provider tool call id 重复:{}", + call.id + ))); + } + if !declared_tools.contains(&call.name) { + return Err(ProviderError::invalid(format!( + "provider response 返回了未声明工具:{}", + call.name + ))); + } + } + Ok(()) + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde( + deny_unknown_fields, + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] +pub enum ProviderStreamEvent { + TextDelta { + accumulated_text: String, + delta_text: String, + finish_reason: Option, + }, + ToolCallDelta { + tool_call_id: String, + arguments_delta: String, + }, + Usage { + usage: ProviderUsage, + }, +} + +pub trait ProviderStreamSink { + fn emit(&mut self, event: ProviderStreamEvent) -> Result<(), ProviderError>; +} + +pub type ProviderFuture<'a, T> = Pin + Send + 'a>>; +pub type ProviderStreamFuture<'a, T> = Pin + 'a>>; + +pub trait ProviderAdapter: Send + Sync { + fn descriptor(&self) -> &ProviderDescriptor; + fn invoke( + &self, + request: ProviderRequest, + ) -> ProviderFuture<'_, Result>; + fn stream<'a>( + &'a self, + request: ProviderRequest, + sink: Box, + ) -> ProviderStreamFuture<'a, Result>; +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProviderTarget { + instance_id: ProviderInstanceId, + protocol_id: ProviderProtocolId, +} + +impl ProviderTarget { + pub fn new(instance_id: ProviderInstanceId, protocol_id: ProviderProtocolId) -> Self { + Self { + instance_id, + protocol_id, + } + } + pub fn instance_id(&self) -> &ProviderInstanceId { + &self.instance_id + } + pub fn protocol_id(&self) -> &ProviderProtocolId { + &self.protocol_id + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProviderErrorKind { + InvalidContract, + InvalidConfig, + InvalidRequest, + DuplicateInstanceId, + UnknownInstanceId, + ProtocolMismatch, + CapabilityMismatch, + Timeout, + Connectivity, + Upstream, + StreamUnavailable, + EmptyResponse, + Transport, + Deserialize, + StreamSink, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProviderError { + kind: ProviderErrorKind, + detail: String, + attempts: Option, + status_code: Option, +} + +impl ProviderError { + pub fn invalid_config(detail: impl Into) -> Self { + Self::new(ProviderErrorKind::InvalidConfig, detail) + } + pub fn invalid_request(detail: impl Into) -> Self { + Self::new(ProviderErrorKind::InvalidRequest, detail) + } + pub fn timeout(attempts: u32, detail: impl Into) -> Self { + Self::new(ProviderErrorKind::Timeout, detail).with_attempts(attempts) + } + pub fn connectivity(attempts: u32, detail: impl Into) -> Self { + Self::new(ProviderErrorKind::Connectivity, detail).with_attempts(attempts) + } + pub fn upstream(status_code: u16, detail: impl Into) -> Self { + Self::new(ProviderErrorKind::Upstream, detail).with_status_code(status_code) + } + pub fn stream_unavailable(detail: impl Into) -> Self { + Self::new(ProviderErrorKind::StreamUnavailable, detail) + } + pub fn empty_response(detail: impl Into) -> Self { + Self::new(ProviderErrorKind::EmptyResponse, detail) + } + pub fn transport(detail: impl Into) -> Self { + Self::new(ProviderErrorKind::Transport, detail) + } + pub fn deserialize(detail: impl Into) -> Self { + Self::new(ProviderErrorKind::Deserialize, detail) + } + pub fn stream_sink(detail: impl Into) -> Self { + Self::new(ProviderErrorKind::StreamSink, detail) + } + fn new(kind: ProviderErrorKind, detail: impl Into) -> Self { + Self { + kind, + detail: detail.into(), + attempts: None, + status_code: None, + } + } + fn with_attempts(mut self, attempts: u32) -> Self { + self.attempts = Some(attempts); + self + } + fn with_status_code(mut self, status_code: u16) -> Self { + self.status_code = Some(status_code); + self + } + fn invalid(detail: impl Into) -> Self { + Self::new(ProviderErrorKind::InvalidContract, detail) + } + fn from_contract(error: crate::ContractError) -> Self { + Self::invalid(error.to_string()) + } + pub fn kind(&self) -> ProviderErrorKind { + self.kind + } + pub fn detail(&self) -> &str { + &self.detail + } + pub fn attempts(&self) -> Option { + self.attempts + } + pub fn status_code(&self) -> Option { + self.status_code + } +} + +impl fmt::Display for ProviderError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.detail) + } +} +impl std::error::Error for ProviderError {} + +struct RegisteredProvider { + descriptor: ProviderDescriptor, + adapter: Arc, +} + +#[derive(Default)] +pub struct ProviderRegistry { + providers: BTreeMap, +} + +impl ProviderRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn try_new( + adapters: impl IntoIterator>, + ) -> Result { + let mut registry = Self::new(); + for adapter in adapters { + registry.register(adapter)?; + } + Ok(registry) + } + + pub fn register(&mut self, adapter: Arc) -> Result<(), ProviderError> { + let descriptor = adapter.descriptor().clone(); + descriptor.validate()?; + if self.providers.contains_key(descriptor.instance_id()) { + return Err(ProviderError::new( + ProviderErrorKind::DuplicateInstanceId, + format!("provider instance id 重复:{}", descriptor.instance_id()), + )); + } + self.providers.insert( + descriptor.instance_id.clone(), + RegisteredProvider { + descriptor, + adapter, + }, + ); + Ok(()) + } + + pub fn len(&self) -> usize { + self.providers.len() + } + pub fn is_empty(&self) -> bool { + self.providers.is_empty() + } + pub fn descriptor( + &self, + instance_id: &ProviderInstanceId, + ) -> Result<&ProviderDescriptor, ProviderError> { + self.providers + .get(instance_id) + .map(|entry| &entry.descriptor) + .ok_or_else(|| { + ProviderError::new( + ProviderErrorKind::UnknownInstanceId, + format!("未知 provider instance:{instance_id}"), + ) + }) + } + pub fn adapter( + &self, + instance_id: &ProviderInstanceId, + ) -> Result, ProviderError> { + self.providers + .get(instance_id) + .map(|entry| Arc::clone(&entry.adapter)) + .ok_or_else(|| { + ProviderError::new( + ProviderErrorKind::UnknownInstanceId, + format!("未知 provider instance:{instance_id}"), + ) + }) + } + + pub fn invoke( + &self, + target: &ProviderTarget, + request: ProviderRequest, + ) -> ProviderFuture<'static, Result> { + let adapter = match self.resolve(target, &request, false) { + Ok(adapter) => adapter, + Err(error) => return Box::pin(async move { Err(error) }), + }; + let request_id = request.request_id.clone(); + let declared_tools = request + .tools + .iter() + .map(|tool| tool.name.clone()) + .collect::>(); + Box::pin(async move { + let response = adapter.invoke(request).await?; + response.validate_for_request(&request_id, &declared_tools)?; + Ok(response) + }) + } + + pub fn stream<'a>( + &'a self, + target: &ProviderTarget, + request: ProviderRequest, + sink: Box, + ) -> ProviderStreamFuture<'a, Result> { + let adapter = match self.resolve(target, &request, true) { + Ok(adapter) => adapter, + Err(error) => return Box::pin(async move { Err(error) }), + }; + let request_id = request.request_id.clone(); + let declared_tools = request + .tools + .iter() + .map(|tool| tool.name.clone()) + .collect::>(); + Box::pin(async move { + let response = adapter.stream(request, sink).await?; + response.validate_for_request(&request_id, &declared_tools)?; + Ok(response) + }) + } + + fn resolve( + &self, + target: &ProviderTarget, + request: &ProviderRequest, + streaming: bool, + ) -> Result, ProviderError> { + request.validate()?; + let entry = self.providers.get(target.instance_id()).ok_or_else(|| { + ProviderError::new( + ProviderErrorKind::UnknownInstanceId, + format!("未知 provider instance:{}", target.instance_id()), + ) + })?; + if entry.descriptor.protocol_id() != target.protocol_id() { + return Err(ProviderError::new( + ProviderErrorKind::ProtocolMismatch, + format!( + "provider instance {} 的 protocol 不匹配", + target.instance_id() + ), + )); + } + let mut required = request.required_capabilities(); + if streaming { + required.insert(ProviderCapability::Streaming); + } + let missing = required + .difference(entry.descriptor.capabilities()) + .copied() + .collect::>(); + if !missing.is_empty() { + return Err(ProviderError::new( + ProviderErrorKind::CapabilityMismatch, + format!( + "provider instance {} 缺少能力:{missing:?}", + target.instance_id() + ), + )); + } + Ok(Arc::clone(&entry.adapter)) + } +} + +fn validate_non_empty_text(value: &str, field: &str) -> Result<(), ProviderError> { + if value.trim().is_empty() { + Err(ProviderError::invalid(format!("{field} 不能为空"))) + } else { + Ok(()) + } +} diff --git a/server-rs/crates/agent-runtime-core/src/runtime.rs b/server-rs/crates/agent-runtime-core/src/runtime.rs new file mode 100644 index 000000000..70b178ca1 --- /dev/null +++ b/server-rs/crates/agent-runtime-core/src/runtime.rs @@ -0,0 +1,1468 @@ +use std::collections::BTreeSet; +use std::fmt; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::capability::CapabilityRegistry; +use crate::completion::{CompletionBlocker, CompletionPolicy}; +use crate::contract::{validate_description, validate_identifier, validate_metadata}; + +pub const RUNTIME_SNAPSHOT_SCHEMA_VERSION: &str = "agent-runtime-core-snapshot.v1"; +const MAX_DELEGATION_CHILDREN: usize = 64; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum RunStatus { + Pending, + Running, + WaitingForAction, + WaitingForChildren, + Paused, + Completed, + Failed, + Cancelled, + NeedsReconciliation, +} + +impl RunStatus { + pub fn is_terminal(self) -> bool { + matches!(self, Self::Completed | Self::Failed | Self::Cancelled) + } + + fn occupies_agent_lane(self) -> bool { + !matches!(self, Self::Pending) && !self.is_terminal() + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ActionStatus { + Queued, + Executing, + Observed, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ObservationStatus { + Completed, + Failed, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum JoinMode { + All, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RunSpec { + run_id: String, + agent_id: String, + task: String, + metadata: Value, +} + +impl RunSpec { + pub fn try_new( + run_id: impl Into, + agent_id: impl Into, + task: impl Into, + ) -> Result { + let run_id = run_id.into(); + let agent_id = agent_id.into(); + let task = task.into(); + validate_identifier(&run_id, "run id")?; + validate_identifier(&agent_id, "agent id")?; + validate_description(&task, "run task")?; + Ok(Self { + run_id, + agent_id, + task, + metadata: Value::Object(Map::new()), + }) + } + + pub fn with_metadata(mut self, metadata: Value) -> Result { + validate_metadata(&metadata, "run metadata")?; + self.metadata = metadata; + Ok(self) + } + + pub fn run_id(&self) -> &str { + &self.run_id + } + + pub fn agent_id(&self) -> &str { + &self.agent_id + } + + pub fn task(&self) -> &str { + &self.task + } + + pub fn metadata(&self) -> &Value { + &self.metadata + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RuntimeAction { + id: String, + capability_id: String, + input: Value, +} + +impl RuntimeAction { + pub fn try_new( + id: impl Into, + capability_id: impl Into, + input: Value, + ) -> Result { + let id = id.into(); + let capability_id = capability_id.into(); + validate_identifier(&id, "action id")?; + validate_identifier(&capability_id, "capability id")?; + if !input.is_object() { + return Err(RuntimeError::invalid("action input 必须是 JSON object")); + } + Ok(Self { + id, + capability_id, + input, + }) + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn capability_id(&self) -> &str { + &self.capability_id + } + + pub fn input(&self) -> &Value { + &self.input + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ActionRecord { + action: RuntimeAction, + status: ActionStatus, + updated_at_ms: u64, +} + +impl ActionRecord { + pub fn action(&self) -> &RuntimeAction { + &self.action + } + + pub fn status(&self) -> ActionStatus { + self.status + } + + pub fn updated_at_ms(&self) -> u64 { + self.updated_at_ms + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ToolOutput { + status: ObservationStatus, + summary: String, + output: Value, +} + +impl ToolOutput { + pub fn completed(summary: impl Into, output: Value) -> Result { + Self::try_new(ObservationStatus::Completed, summary, output) + } + + pub fn failed(summary: impl Into, output: Value) -> Result { + Self::try_new(ObservationStatus::Failed, summary, output) + } + + fn try_new( + status: ObservationStatus, + summary: impl Into, + output: Value, + ) -> Result { + let summary = summary.into(); + validate_description(&summary, "tool output summary")?; + Ok(Self { + status, + summary, + output, + }) + } + + pub fn status(&self) -> ObservationStatus { + self.status + } + + pub fn summary(&self) -> &str { + &self.summary + } + + pub fn output(&self) -> &Value { + &self.output + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ToolExecution { + Observed(ToolOutput), + Unknown, +} + +pub trait ToolHost { + fn execute(&mut self, run: &RunRecord, action: &RuntimeAction) -> ToolExecution; +} + +pub trait RuntimeClock { + fn now_millis(&self) -> u64; +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RuntimeObservation { + action_id: String, + capability_id: String, + status: ObservationStatus, + summary: String, + output: Value, + observed_at_ms: u64, +} + +impl RuntimeObservation { + pub fn action_id(&self) -> &str { + &self.action_id + } + + pub fn capability_id(&self) -> &str { + &self.capability_id + } + + pub fn status(&self) -> ObservationStatus { + self.status + } + + pub fn summary(&self) -> &str { + &self.summary + } + + pub fn output(&self) -> &Value { + &self.output + } + + pub fn observed_at_ms(&self) -> u64 { + self.observed_at_ms + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RunRecord { + run_id: String, + agent_id: String, + task: String, + metadata: Value, + parent_run_id: Option, + delegation_group_id: Option, + status: RunStatus, + actions: Vec, + observations: Vec, + terminal_summary: Option, + terminal_error: Option, + created_at_ms: u64, + updated_at_ms: u64, +} + +impl RunRecord { + pub fn run_id(&self) -> &str { + &self.run_id + } + + pub fn agent_id(&self) -> &str { + &self.agent_id + } + + pub fn task(&self) -> &str { + &self.task + } + + pub fn metadata(&self) -> &Value { + &self.metadata + } + + pub fn parent_run_id(&self) -> Option<&str> { + self.parent_run_id.as_deref() + } + + pub fn delegation_group_id(&self) -> Option<&str> { + self.delegation_group_id.as_deref() + } + + pub fn status(&self) -> RunStatus { + self.status + } + + pub fn actions(&self) -> &[ActionRecord] { + &self.actions + } + + pub fn observations(&self) -> &[RuntimeObservation] { + &self.observations + } + + pub fn terminal_summary(&self) -> Option<&str> { + self.terminal_summary.as_deref() + } + + pub fn terminal_error(&self) -> Option<&str> { + self.terminal_error.as_deref() + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RunTerminalResult { + run_id: String, + status: RunStatus, + summary: Option, + error: Option, +} + +impl RunTerminalResult { + pub fn run_id(&self) -> &str { + &self.run_id + } + + pub fn status(&self) -> RunStatus { + self.status + } + + pub fn summary(&self) -> Option<&str> { + self.summary.as_deref() + } + + pub fn error(&self) -> Option<&str> { + self.error.as_deref() + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct DelegationGroup { + id: String, + parent_run_id: String, + child_run_ids: Vec, + join_mode: JoinMode, + resolved: bool, + results: Vec, +} + +impl DelegationGroup { + pub fn id(&self) -> &str { + &self.id + } + + pub fn parent_run_id(&self) -> &str { + &self.parent_run_id + } + + pub fn child_run_ids(&self) -> &[String] { + &self.child_run_ids + } + + pub fn join_mode(&self) -> JoinMode { + self.join_mode + } + + pub fn is_resolved(&self) -> bool { + self.resolved + } + + pub fn results(&self) -> &[RunTerminalResult] { + &self.results + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RuntimeSnapshot { + schema_version: String, + runtime_id: String, + revision: u64, + runs: Vec, + delegations: Vec, +} + +impl RuntimeSnapshot { + pub fn schema_version(&self) -> &str { + &self.schema_version + } + + pub fn runtime_id(&self) -> &str { + &self.runtime_id + } + + pub fn revision(&self) -> u64 { + self.revision + } + + pub fn runs(&self) -> &[RunRecord] { + &self.runs + } + + pub fn run(&self, run_id: &str) -> Option<&RunRecord> { + self.runs.iter().find(|run| run.run_id == run_id) + } + + pub fn delegations(&self) -> &[DelegationGroup] { + &self.delegations + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum RuntimeEventKind { + RuntimeCreated, + RunAdded, + RunStarted, + ActionQueued, + ActionExecuting, + ActionObserved, + ActionNeedsReconciliation, + DelegationSpawned, + JoinResolved, + RunPaused, + RunResumed, + RunCompleted, + RunFailed, + RunCancelled, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RuntimeEvent { + runtime_id: String, + revision: u64, + occurred_at_ms: u64, + kind: RuntimeEventKind, + run_id: Option, + detail: Value, +} + +impl RuntimeEvent { + pub fn runtime_id(&self) -> &str { + &self.runtime_id + } + + pub fn revision(&self) -> u64 { + self.revision + } + + pub fn occurred_at_ms(&self) -> u64 { + self.occurred_at_ms + } + + pub fn kind(&self) -> RuntimeEventKind { + self.kind + } + + pub fn run_id(&self) -> Option<&str> { + self.run_id.as_deref() + } + + pub fn detail(&self) -> &Value { + &self.detail + } +} + +pub trait RuntimeStore { + fn load(&self, runtime_id: &str) -> Result, String>; + + fn commit( + &mut self, + runtime_id: &str, + expected_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> Result<(), String>; +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RuntimeErrorKind { + InvalidInput, + NotFound, + Conflict, + InvalidTransition, + Store, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RuntimeError { + kind: RuntimeErrorKind, + detail: String, +} + +impl RuntimeError { + fn invalid(detail: impl Into) -> Self { + Self { + kind: RuntimeErrorKind::InvalidInput, + detail: detail.into(), + } + } + + fn not_found(detail: impl Into) -> Self { + Self { + kind: RuntimeErrorKind::NotFound, + detail: detail.into(), + } + } + + fn conflict(detail: impl Into) -> Self { + Self { + kind: RuntimeErrorKind::Conflict, + detail: detail.into(), + } + } + + fn transition(detail: impl Into) -> Self { + Self { + kind: RuntimeErrorKind::InvalidTransition, + detail: detail.into(), + } + } + + fn store(detail: impl Into) -> Self { + Self { + kind: RuntimeErrorKind::Store, + detail: detail.into(), + } + } + + pub fn kind(&self) -> RuntimeErrorKind { + self.kind + } + + pub fn detail(&self) -> &str { + &self.detail + } +} + +impl fmt::Display for RuntimeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.detail) + } +} + +impl std::error::Error for RuntimeError {} + +impl From for RuntimeError { + fn from(error: crate::ContractError) -> Self { + Self::invalid(error.to_string()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ActionExecutionOutcome { + Observed, + NeedsReconciliation, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CompletionAttempt { + Completed, + Blocked(Vec), +} + +struct PendingEvent { + kind: RuntimeEventKind, + run_id: Option, + detail: Value, +} + +pub struct RuntimeEngine { + store: S, + clock: C, +} + +impl RuntimeEngine +where + S: RuntimeStore, + C: RuntimeClock, +{ + pub fn new(store: S, clock: C) -> Self { + Self { store, clock } + } + + pub fn store(&self) -> &S { + &self.store + } + + pub fn store_mut(&mut self) -> &mut S { + &mut self.store + } + + pub fn into_parts(self) -> (S, C) { + (self.store, self.clock) + } + + pub fn load(&self, runtime_id: &str) -> Result, RuntimeError> { + validate_identifier(runtime_id, "runtime id")?; + let snapshot = self.store.load(runtime_id).map_err(RuntimeError::store)?; + if let Some(snapshot) = &snapshot { + validate_runtime_snapshot(snapshot, runtime_id)?; + } + Ok(snapshot) + } + + pub fn create_runtime( + &mut self, + runtime_id: impl Into, + ) -> Result { + let runtime_id = runtime_id.into(); + validate_identifier(&runtime_id, "runtime id")?; + if self + .store + .load(&runtime_id) + .map_err(RuntimeError::store)? + .is_some() + { + return Err(RuntimeError::conflict(format!( + "runtime 已存在:{runtime_id}" + ))); + } + let snapshot = RuntimeSnapshot { + schema_version: RUNTIME_SNAPSHOT_SCHEMA_VERSION.to_string(), + runtime_id: runtime_id.clone(), + revision: 1, + runs: Vec::new(), + delegations: Vec::new(), + }; + let event = RuntimeEvent { + runtime_id: runtime_id.clone(), + revision: 1, + occurred_at_ms: self.clock.now_millis(), + kind: RuntimeEventKind::RuntimeCreated, + run_id: None, + detail: Value::Object(Map::new()), + }; + self.store + .commit(&runtime_id, None, &snapshot, &[event]) + .map_err(RuntimeError::store)?; + Ok(snapshot) + } + + pub fn add_run( + &mut self, + runtime_id: &str, + spec: RunSpec, + ) -> Result { + let mut snapshot = self.load_required(runtime_id)?; + if snapshot.run(&spec.run_id).is_some() { + return Err(RuntimeError::conflict(format!( + "run 已存在:{}", + spec.run_id + ))); + } + let now = self.clock.now_millis(); + snapshot.runs.push(RunRecord { + run_id: spec.run_id.clone(), + agent_id: spec.agent_id, + task: spec.task, + metadata: spec.metadata, + parent_run_id: None, + delegation_group_id: None, + status: RunStatus::Pending, + actions: Vec::new(), + observations: Vec::new(), + terminal_summary: None, + terminal_error: None, + created_at_ms: now, + updated_at_ms: now, + }); + self.commit_transition( + snapshot, + vec![PendingEvent { + kind: RuntimeEventKind::RunAdded, + run_id: Some(spec.run_id), + detail: Value::Object(Map::new()), + }], + ) + } + + pub fn schedule_ready(&mut self, runtime_id: &str) -> Result, RuntimeError> { + let mut snapshot = self.load_required(runtime_id)?; + let mut occupied = snapshot + .runs + .iter() + .filter(|run| run.status.occupies_agent_lane()) + .map(|run| run.agent_id.clone()) + .collect::>(); + let now = self.clock.now_millis(); + let mut started = Vec::new(); + for run in &mut snapshot.runs { + if run.status == RunStatus::Pending && occupied.insert(run.agent_id.clone()) { + run.status = RunStatus::Running; + run.updated_at_ms = now; + started.push(run.run_id.clone()); + } + } + if started.is_empty() { + return Ok(started); + } + let events = started + .iter() + .map(|run_id| PendingEvent { + kind: RuntimeEventKind::RunStarted, + run_id: Some(run_id.clone()), + detail: Value::Object(Map::new()), + }) + .collect(); + self.commit_transition(snapshot, events)?; + Ok(started) + } + + pub fn enqueue_action( + &mut self, + runtime_id: &str, + run_id: &str, + capabilities: &CapabilityRegistry, + action: RuntimeAction, + ) -> Result { + if capabilities.get(action.capability_id()).is_none() { + return Err(RuntimeError::invalid(format!( + "action 引用了未注册 capability:{}", + action.capability_id() + ))); + } + let mut snapshot = self.load_required(runtime_id)?; + if snapshot + .runs + .iter() + .flat_map(|run| run.actions.iter()) + .any(|record| record.action.id == action.id) + { + return Err(RuntimeError::conflict(format!( + "action id 已存在:{}", + action.id + ))); + } + let now = self.clock.now_millis(); + let run = run_mut(&mut snapshot, run_id)?; + require_status(run, &[RunStatus::Running], "enqueue action")?; + run.actions.push(ActionRecord { + action: action.clone(), + status: ActionStatus::Queued, + updated_at_ms: now, + }); + run.status = RunStatus::WaitingForAction; + run.updated_at_ms = now; + self.commit_transition( + snapshot, + vec![PendingEvent { + kind: RuntimeEventKind::ActionQueued, + run_id: Some(run_id.to_string()), + detail: serde_json::json!({"actionId": action.id, "capabilityId": action.capability_id}), + }], + ) + } + + pub fn execute_next_action( + &mut self, + runtime_id: &str, + run_id: &str, + host: &mut H, + ) -> Result { + let mut snapshot = self.load_required(runtime_id)?; + let now = self.clock.now_millis(); + let (action, was_executing, already_reconciling) = { + let run = run_mut(&mut snapshot, run_id)?; + let current_status = run.status; + require_status( + run, + &[RunStatus::WaitingForAction, RunStatus::NeedsReconciliation], + "execute action", + )?; + let record = run + .actions + .last_mut() + .ok_or_else(|| RuntimeError::transition("run 缺少待执行 action"))?; + match record.status { + ActionStatus::Observed => { + return Err(RuntimeError::transition("最新 action 已 observed")); + } + ActionStatus::Executing => ( + record.action.clone(), + true, + current_status == RunStatus::NeedsReconciliation, + ), + ActionStatus::Queued => { + record.status = ActionStatus::Executing; + record.updated_at_ms = now; + (record.action.clone(), false, false) + } + } + }; + if was_executing { + if already_reconciling { + return Ok(ActionExecutionOutcome::NeedsReconciliation); + } + let run = run_mut(&mut snapshot, run_id)?; + run.status = RunStatus::NeedsReconciliation; + run.updated_at_ms = now; + self.commit_transition( + snapshot, + vec![PendingEvent { + kind: RuntimeEventKind::ActionNeedsReconciliation, + run_id: Some(run_id.to_string()), + detail: serde_json::json!({"actionId": action.id}), + }], + )?; + return Ok(ActionExecutionOutcome::NeedsReconciliation); + } + let executing = self.commit_transition( + snapshot, + vec![PendingEvent { + kind: RuntimeEventKind::ActionExecuting, + run_id: Some(run_id.to_string()), + detail: serde_json::json!({"actionId": action.id}), + }], + )?; + let execution = host.execute( + executing + .run(run_id) + .expect("committed executing run must exist"), + &action, + ); + match execution { + ToolExecution::Observed(output) => { + self.persist_observation(runtime_id, run_id, &action, output)?; + Ok(ActionExecutionOutcome::Observed) + } + ToolExecution::Unknown => { + let mut snapshot = self.load_required(runtime_id)?; + let run = run_mut(&mut snapshot, run_id)?; + run.status = RunStatus::NeedsReconciliation; + run.updated_at_ms = self.clock.now_millis(); + self.commit_transition( + snapshot, + vec![PendingEvent { + kind: RuntimeEventKind::ActionNeedsReconciliation, + run_id: Some(run_id.to_string()), + detail: serde_json::json!({"actionId": action.id}), + }], + )?; + Ok(ActionExecutionOutcome::NeedsReconciliation) + } + } + } + + pub fn reconcile_action( + &mut self, + runtime_id: &str, + run_id: &str, + output: ToolOutput, + ) -> Result { + let snapshot = self.load_required(runtime_id)?; + let run = snapshot + .run(run_id) + .ok_or_else(|| RuntimeError::not_found(format!("run 不存在:{run_id}")))?; + require_status( + run, + &[RunStatus::WaitingForAction, RunStatus::NeedsReconciliation], + "reconcile action", + )?; + let action = run + .actions + .last() + .filter(|record| record.status == ActionStatus::Executing) + .map(|record| record.action.clone()) + .ok_or_else(|| RuntimeError::transition("run 缺少 executing action"))?; + self.persist_observation(runtime_id, run_id, &action, output) + } + + pub fn spawn( + &mut self, + runtime_id: &str, + parent_run_id: &str, + group_id: impl Into, + children: impl IntoIterator, + ) -> Result { + let group_id = group_id.into(); + validate_identifier(&group_id, "delegation group id")?; + let children = children.into_iter().collect::>(); + if children.is_empty() || children.len() > MAX_DELEGATION_CHILDREN { + return Err(RuntimeError::invalid(format!( + "delegation children 必须为 1..={MAX_DELEGATION_CHILDREN}" + ))); + } + let mut child_ids = BTreeSet::new(); + for child in &children { + if !child_ids.insert(child.run_id.clone()) { + return Err(RuntimeError::conflict(format!( + "child run id 重复:{}", + child.run_id + ))); + } + } + let mut snapshot = self.load_required(runtime_id)?; + if snapshot + .delegations + .iter() + .any(|group| group.id == group_id) + { + return Err(RuntimeError::conflict(format!( + "delegation group 已存在:{group_id}" + ))); + } + if children + .iter() + .any(|child| snapshot.run(&child.run_id).is_some()) + { + return Err(RuntimeError::conflict("child run id 已存在")); + } + let now = self.clock.now_millis(); + let parent = run_mut(&mut snapshot, parent_run_id)?; + require_status(parent, &[RunStatus::Running], "spawn children")?; + parent.status = RunStatus::WaitingForChildren; + parent.updated_at_ms = now; + let child_run_ids = children + .iter() + .map(|child| child.run_id.clone()) + .collect::>(); + for child in children { + snapshot.runs.push(RunRecord { + run_id: child.run_id, + agent_id: child.agent_id, + task: child.task, + metadata: child.metadata, + parent_run_id: Some(parent_run_id.to_string()), + delegation_group_id: Some(group_id.clone()), + status: RunStatus::Pending, + actions: Vec::new(), + observations: Vec::new(), + terminal_summary: None, + terminal_error: None, + created_at_ms: now, + updated_at_ms: now, + }); + } + snapshot.delegations.push(DelegationGroup { + id: group_id.clone(), + parent_run_id: parent_run_id.to_string(), + child_run_ids: child_run_ids.clone(), + join_mode: JoinMode::All, + resolved: false, + results: Vec::new(), + }); + self.commit_transition( + snapshot, + vec![PendingEvent { + kind: RuntimeEventKind::DelegationSpawned, + run_id: Some(parent_run_id.to_string()), + detail: serde_json::json!({"groupId": group_id, "childRunIds": child_run_ids}), + }], + ) + } + + pub fn complete_if_ready( + &mut self, + runtime_id: &str, + run_id: &str, + policy: &Policy, + context: &Context, + summary: impl Into, + ) -> Result + where + Policy: CompletionPolicy, + { + validate_identifier(policy.id(), "completion policy id")?; + let snapshot = self.load_required(runtime_id)?; + let run = snapshot + .run(run_id) + .ok_or_else(|| RuntimeError::not_found(format!("run 不存在:{run_id}")))?; + require_status(run, &[RunStatus::Running], "evaluate completion")?; + let decision = policy.evaluate(context); + if decision.is_ready() { + self.finish_run( + runtime_id, + run_id, + RunStatus::Completed, + summary.into(), + None, + )?; + Ok(CompletionAttempt::Completed) + } else { + Ok(CompletionAttempt::Blocked(decision.blockers().to_vec())) + } + } + + pub fn fail_run( + &mut self, + runtime_id: &str, + run_id: &str, + summary: impl Into, + error: impl Into, + ) -> Result { + self.finish_run( + runtime_id, + run_id, + RunStatus::Failed, + summary.into(), + Some(error.into()), + ) + } + + pub fn cancel_run( + &mut self, + runtime_id: &str, + run_id: &str, + summary: impl Into, + ) -> Result { + self.finish_run( + runtime_id, + run_id, + RunStatus::Cancelled, + summary.into(), + None, + ) + } + + pub fn pause_run( + &mut self, + runtime_id: &str, + run_id: &str, + ) -> Result { + self.change_status( + runtime_id, + run_id, + &[RunStatus::Running], + RunStatus::Paused, + RuntimeEventKind::RunPaused, + ) + } + + pub fn resume_run( + &mut self, + runtime_id: &str, + run_id: &str, + ) -> Result { + self.change_status( + runtime_id, + run_id, + &[RunStatus::Paused], + RunStatus::Running, + RuntimeEventKind::RunResumed, + ) + } + + fn load_required(&self, runtime_id: &str) -> Result { + self.load(runtime_id)? + .ok_or_else(|| RuntimeError::not_found(format!("runtime 不存在:{runtime_id}"))) + } + + fn persist_observation( + &mut self, + runtime_id: &str, + run_id: &str, + action: &RuntimeAction, + output: ToolOutput, + ) -> Result { + let mut snapshot = self.load_required(runtime_id)?; + let now = self.clock.now_millis(); + let run = run_mut(&mut snapshot, run_id)?; + require_status( + run, + &[RunStatus::WaitingForAction, RunStatus::NeedsReconciliation], + "persist observation", + )?; + let record = run + .actions + .last_mut() + .filter(|record| { + record.action.id == action.id && record.status == ActionStatus::Executing + }) + .ok_or_else(|| RuntimeError::transition("executing action 与 observation 不匹配"))?; + record.status = ActionStatus::Observed; + record.updated_at_ms = now; + run.observations.push(RuntimeObservation { + action_id: action.id.clone(), + capability_id: action.capability_id.clone(), + status: output.status, + summary: output.summary.clone(), + output: output.output, + observed_at_ms: now, + }); + run.status = RunStatus::Running; + run.updated_at_ms = now; + self.commit_transition( + snapshot, + vec![PendingEvent { + kind: RuntimeEventKind::ActionObserved, + run_id: Some(run_id.to_string()), + detail: serde_json::json!({ + "actionId": action.id, + "status": output.status, + "summary": output.summary, + }), + }], + ) + } + + fn finish_run( + &mut self, + runtime_id: &str, + run_id: &str, + terminal_status: RunStatus, + summary: String, + error: Option, + ) -> Result { + if !terminal_status.is_terminal() { + return Err(RuntimeError::invalid("finish_run 需要终态 status")); + } + validate_description(&summary, "terminal summary")?; + if let Some(error) = &error { + validate_description(error, "terminal error")?; + } + if terminal_status == RunStatus::Failed && error.is_none() { + return Err(RuntimeError::invalid("failed run 必须携带 error")); + } + let mut snapshot = self.load_required(runtime_id)?; + let now = self.clock.now_millis(); + let run = run_mut(&mut snapshot, run_id)?; + if run.status.is_terminal() { + return Err(RuntimeError::transition(format!("run 已是终态:{run_id}"))); + } + if terminal_status == RunStatus::Completed && run.status != RunStatus::Running { + return Err(RuntimeError::transition( + "只有 running run 可以进入 completed", + )); + } + if matches!( + run.status, + RunStatus::WaitingForAction + | RunStatus::WaitingForChildren + | RunStatus::NeedsReconciliation + ) { + return Err(RuntimeError::transition( + "未收束 action/delegation 时不能结束 run", + )); + } + run.status = terminal_status; + run.terminal_summary = Some(summary); + run.terminal_error = error; + run.updated_at_ms = now; + let mut events = vec![PendingEvent { + kind: match terminal_status { + RunStatus::Completed => RuntimeEventKind::RunCompleted, + RunStatus::Failed => RuntimeEventKind::RunFailed, + RunStatus::Cancelled => RuntimeEventKind::RunCancelled, + _ => unreachable!(), + }, + run_id: Some(run_id.to_string()), + detail: Value::Object(Map::new()), + }]; + resolve_ready_joins(&mut snapshot, now, &mut events)?; + self.commit_transition(snapshot, events) + } + + fn change_status( + &mut self, + runtime_id: &str, + run_id: &str, + allowed: &[RunStatus], + next: RunStatus, + kind: RuntimeEventKind, + ) -> Result { + let mut snapshot = self.load_required(runtime_id)?; + let run = run_mut(&mut snapshot, run_id)?; + require_status(run, allowed, "change status")?; + run.status = next; + run.updated_at_ms = self.clock.now_millis(); + self.commit_transition( + snapshot, + vec![PendingEvent { + kind, + run_id: Some(run_id.to_string()), + detail: Value::Object(Map::new()), + }], + ) + } + + fn commit_transition( + &mut self, + mut snapshot: RuntimeSnapshot, + pending_events: Vec, + ) -> Result { + if pending_events.is_empty() { + return Err(RuntimeError::invalid( + "runtime transition 至少需要一个 event", + )); + } + let expected_revision = snapshot.revision; + snapshot.revision = expected_revision + .checked_add(1) + .ok_or_else(|| RuntimeError::conflict("runtime revision 溢出"))?; + let now = self.clock.now_millis(); + let events = pending_events + .into_iter() + .map(|event| RuntimeEvent { + runtime_id: snapshot.runtime_id.clone(), + revision: snapshot.revision, + occurred_at_ms: now, + kind: event.kind, + run_id: event.run_id, + detail: event.detail, + }) + .collect::>(); + self.store + .commit( + &snapshot.runtime_id, + Some(expected_revision), + &snapshot, + &events, + ) + .map_err(RuntimeError::store)?; + Ok(snapshot) + } +} + +fn run_mut<'a>( + snapshot: &'a mut RuntimeSnapshot, + run_id: &str, +) -> Result<&'a mut RunRecord, RuntimeError> { + snapshot + .runs + .iter_mut() + .find(|run| run.run_id == run_id) + .ok_or_else(|| RuntimeError::not_found(format!("run 不存在:{run_id}"))) +} + +fn require_status( + run: &RunRecord, + allowed: &[RunStatus], + operation: &str, +) -> Result<(), RuntimeError> { + if allowed.contains(&run.status) { + Ok(()) + } else { + Err(RuntimeError::transition(format!( + "{operation} 不允许 run {} 处于 {:?}", + run.run_id, run.status + ))) + } +} + +fn resolve_ready_joins( + snapshot: &mut RuntimeSnapshot, + now: u64, + events: &mut Vec, +) -> Result<(), RuntimeError> { + let ready = snapshot + .delegations + .iter() + .enumerate() + .filter(|(_, group)| { + !group.resolved + && group.child_run_ids.iter().all(|child_id| { + snapshot + .run(child_id) + .is_some_and(|run| run.status.is_terminal()) + }) + }) + .map(|(index, _)| index) + .collect::>(); + for index in ready { + let child_ids = snapshot.delegations[index].child_run_ids.clone(); + let results = child_ids + .iter() + .map(|child_id| { + let child = snapshot.run(child_id).expect("ready child must exist"); + RunTerminalResult { + run_id: child.run_id.clone(), + status: child.status, + summary: child.terminal_summary.clone(), + error: child.terminal_error.clone(), + } + }) + .collect::>(); + let parent_run_id = snapshot.delegations[index].parent_run_id.clone(); + let group_id = snapshot.delegations[index].id.clone(); + snapshot.delegations[index].resolved = true; + snapshot.delegations[index].results = results; + let parent = run_mut(snapshot, &parent_run_id)?; + require_status(parent, &[RunStatus::WaitingForChildren], "resolve join")?; + parent.status = RunStatus::Running; + parent.updated_at_ms = now; + events.push(PendingEvent { + kind: RuntimeEventKind::JoinResolved, + run_id: Some(parent_run_id), + detail: serde_json::json!({"groupId": group_id, "childRunIds": child_ids}), + }); + } + Ok(()) +} + +fn validate_runtime_snapshot( + snapshot: &RuntimeSnapshot, + expected_runtime_id: &str, +) -> Result<(), RuntimeError> { + if snapshot.schema_version != RUNTIME_SNAPSHOT_SCHEMA_VERSION { + return Err(RuntimeError::conflict(format!( + "runtime snapshot schemaVersion 不支持:{}", + snapshot.schema_version + ))); + } + validate_identifier(&snapshot.runtime_id, "runtime id")?; + if snapshot.runtime_id != expected_runtime_id || snapshot.revision == 0 { + return Err(RuntimeError::conflict( + "runtime snapshot identity/revision 与读取请求不一致", + )); + } + let mut run_ids = BTreeSet::new(); + let mut action_ids = BTreeSet::new(); + for run in &snapshot.runs { + validate_identifier(&run.run_id, "run id")?; + validate_identifier(&run.agent_id, "agent id")?; + validate_description(&run.task, "run task")?; + validate_metadata(&run.metadata, "run metadata")?; + if !run_ids.insert(run.run_id.as_str()) { + return Err(RuntimeError::conflict(format!( + "runtime snapshot run id 重复:{}", + run.run_id + ))); + } + for action in &run.actions { + validate_identifier(&action.action.id, "action id")?; + validate_identifier(&action.action.capability_id, "capability id")?; + if !action.action.input.is_object() || !action_ids.insert(action.action.id.as_str()) { + return Err(RuntimeError::conflict( + "runtime snapshot action input 非 object 或 action id 重复", + )); + } + } + let active_action = run + .actions + .last() + .filter(|action| action.status != ActionStatus::Observed); + match run.status { + RunStatus::WaitingForAction + if !active_action.is_some_and(|action| { + matches!( + action.status, + ActionStatus::Queued | ActionStatus::Executing + ) + }) => + { + return Err(RuntimeError::conflict( + "waiting-for-action run 缺少 queued/executing action", + )); + } + RunStatus::NeedsReconciliation + if !active_action + .is_some_and(|action| action.status == ActionStatus::Executing) => + { + return Err(RuntimeError::conflict( + "needs-reconciliation run 缺少 executing action", + )); + } + status + if !matches!( + status, + RunStatus::WaitingForAction | RunStatus::NeedsReconciliation + ) && active_action.is_some() => + { + return Err(RuntimeError::conflict( + "非 action 等待态不能保留未观察 action", + )); + } + _ => {} + } + } + let mut group_ids = BTreeSet::new(); + for group in &snapshot.delegations { + validate_identifier(&group.id, "delegation group id")?; + let unique_child_ids = + group.child_run_ids.iter().collect::>().len() == group.child_run_ids.len(); + if !group_ids.insert(group.id.as_str()) + || !run_ids.contains(group.parent_run_id.as_str()) + || group.child_run_ids.is_empty() + || !unique_child_ids + || group + .child_run_ids + .iter() + .any(|child_id| !run_ids.contains(child_id.as_str())) + { + return Err(RuntimeError::conflict( + "runtime snapshot delegation identity/reference 无效", + )); + } + if group.resolved && group.results.len() != group.child_run_ids.len() { + return Err(RuntimeError::conflict( + "resolved delegation results 数量不匹配", + )); + } + if group.resolved + && group + .results + .iter() + .zip(&group.child_run_ids) + .any(|(result, child_id)| { + result.run_id != *child_id + || !result.status.is_terminal() + || snapshot.run(child_id).is_none_or(|child| { + child.status != result.status + || child.terminal_summary != result.summary + || child.terminal_error != result.error + }) + }) + { + return Err(RuntimeError::conflict( + "resolved delegation result identity/order 无效", + )); + } + let parent = snapshot + .run(&group.parent_run_id) + .expect("validated parent reference"); + if !group.resolved && parent.status != RunStatus::WaitingForChildren { + return Err(RuntimeError::conflict( + "unresolved delegation parent 必须等待 children", + )); + } + for child_id in &group.child_run_ids { + let child = snapshot.run(child_id).expect("validated child reference"); + if child.parent_run_id.as_deref() != Some(group.parent_run_id.as_str()) + || child.delegation_group_id.as_deref() != Some(group.id.as_str()) + { + return Err(RuntimeError::conflict( + "delegation child parent/group binding 不匹配", + )); + } + } + } + for run in &snapshot.runs { + if run.status == RunStatus::WaitingForChildren + && snapshot + .delegations + .iter() + .filter(|group| group.parent_run_id == run.run_id && !group.resolved) + .count() + != 1 + { + return Err(RuntimeError::conflict( + "waiting-for-children run 必须绑定唯一 unresolved delegation", + )); + } + } + Ok(()) +} diff --git a/server-rs/crates/agent-runtime-core/tests/non_game_conformance.rs b/server-rs/crates/agent-runtime-core/tests/non_game_conformance.rs new file mode 100644 index 000000000..64fce693d --- /dev/null +++ b/server-rs/crates/agent-runtime-core/tests/non_game_conformance.rs @@ -0,0 +1,229 @@ +use agent_runtime_core::{ + AgentCatalog, AgentDescriptor, CapabilityDefinition, CapabilityRegistry, CompletionBlocker, + CompletionDecision, CompletionPolicy, ContractErrorKind, RunProfileCatalog, + RunProfileDefinition, +}; +use serde_json::json; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DocumentDispatch { + Read, + CheckCitations, +} + +struct DocumentReviewPolicy; + +struct DocumentReviewContext { + draft_exists: bool, + citations_checked: bool, +} + +impl CompletionPolicy for DocumentReviewPolicy { + fn id(&self) -> &str { + "document-review" + } + + fn evaluate(&self, context: &DocumentReviewContext) -> CompletionDecision { + let mut blockers = Vec::new(); + if !context.draft_exists { + blockers.push( + CompletionBlocker::try_new("draft-missing", "文档草稿尚未形成").expect("blocker"), + ); + } + if !context.citations_checked { + blockers.push( + CompletionBlocker::try_new("citations-unchecked", "引用尚未核验").expect("blocker"), + ); + } + if blockers.is_empty() { + CompletionDecision::ready() + } else { + CompletionDecision::blocked(blockers).expect("blocked decision") + } + } +} + +fn capability( + id: &str, + function_name: &str, + dispatch: DocumentDispatch, +) -> CapabilityDefinition { + CapabilityDefinition::try_new( + id, + function_name, + "文档处理能力", + json!({"type": "object", "properties": {}, "additionalProperties": false}), + dispatch, + ) + .expect("capability") +} + +#[test] +fn non_game_host_can_compose_catalog_profile_and_completion_policy() { + let capabilities = CapabilityRegistry::try_new([ + capability( + "document.read", + "runtime_tool_document_read", + DocumentDispatch::Read, + ), + capability( + "document.check-citations", + "runtime_tool_document_check_citations", + DocumentDispatch::CheckCitations, + ), + ]) + .expect("capabilities"); + let agents = AgentCatalog::try_new([AgentDescriptor::try_new( + "researcher", + "document-reviewer", + ["document.read", "document.check-citations"], + ) + .expect("agent")]) + .expect("agents"); + agents + .validate_capabilities(&capabilities) + .expect("agent capability references"); + let profiles = RunProfileCatalog::try_new([RunProfileDefinition::try_new( + "document-review", + ["document.read", "document.check-citations"], + "document-review", + ) + .expect("profile")]) + .expect("profiles"); + profiles + .validate_capabilities(&capabilities) + .expect("profile capability references"); + + let policy = DocumentReviewPolicy; + assert_eq!(policy.id(), "document-review"); + profiles + .validate_completion_policy_ids([policy.id()]) + .expect("profile completion policy references"); + assert!( + !policy + .evaluate(&DocumentReviewContext { + draft_exists: true, + citations_checked: false, + }) + .is_ready() + ); + assert!( + policy + .evaluate(&DocumentReviewContext { + draft_exists: true, + citations_checked: true, + }) + .is_ready() + ); +} + +#[test] +fn catalog_and_profile_fail_closed_on_unknown_capability() { + let capabilities = CapabilityRegistry::try_new([capability( + "document.read", + "runtime_tool_document_read", + DocumentDispatch::Read, + )]) + .expect("capabilities"); + let agents = AgentCatalog::try_new([AgentDescriptor::try_new( + "researcher", + "reviewer", + ["document.missing"], + ) + .expect("agent")]) + .expect("agents"); + assert_eq!( + agents + .validate_capabilities(&capabilities) + .expect_err("unknown capability") + .kind(), + ContractErrorKind::UnknownCapability + ); + + let profiles = RunProfileCatalog::try_new([RunProfileDefinition::try_new( + "document-review", + ["document.missing"], + "document-review", + ) + .expect("profile")]) + .expect("profiles"); + assert_eq!( + profiles + .validate_capabilities(&capabilities) + .expect_err("unknown capability") + .kind(), + ContractErrorKind::UnknownCapability + ); +} + +#[test] +fn agent_and_profile_contracts_reject_duplicate_identity_and_capability() { + let duplicate_capability = + AgentDescriptor::try_new("researcher", "reviewer", ["document.read", "document.read"]) + .expect_err("duplicate capability reference"); + assert_eq!( + duplicate_capability.kind(), + ContractErrorKind::DuplicateReference + ); + + let agent = + AgentDescriptor::try_new("researcher", "reviewer", ["document.read"]).expect("agent"); + assert_eq!( + AgentCatalog::try_new([agent.clone(), agent]) + .expect_err("duplicate agent id") + .kind(), + ContractErrorKind::DuplicateId + ); + + let profile = + RunProfileDefinition::try_new("document-review", ["document.read"], "document-review") + .expect("profile"); + assert_eq!( + RunProfileCatalog::try_new([profile.clone(), profile]) + .expect_err("duplicate profile id") + .kind(), + ContractErrorKind::DuplicateId + ); +} + +#[test] +fn public_contracts_reject_identity_whitespace_and_unknown_completion_policy() { + assert!(AgentDescriptor::try_new(" researcher", "reviewer", ["document.read"]).is_err()); + assert!(AgentDescriptor::try_new("researcher", "reviewer ", ["document.read"]).is_err()); + assert!( + CapabilityDefinition::try_new( + "document.read ", + "runtime_tool_document_read", + "读取文档", + json!({"type": "object"}), + DocumentDispatch::Read, + ) + .is_err() + ); + assert!( + CapabilityDefinition::try_new( + "document.read", + " runtime_tool_document_read", + "读取文档", + json!({"type": "object"}), + DocumentDispatch::Read, + ) + .is_err() + ); + + let profiles = RunProfileCatalog::try_new([RunProfileDefinition::try_new( + "document-review", + std::iter::empty::<&str>(), + "missing-policy", + ) + .expect("profile")]) + .expect("profiles"); + assert_eq!( + profiles + .validate_completion_policy_ids(["document-review"]) + .expect_err("unknown completion policy") + .kind(), + ContractErrorKind::UnknownCompletionPolicy + ); + assert!(CompletionDecision::blocked([]).is_err()); +} diff --git a/server-rs/crates/agent-runtime-core/tests/provider_registry.rs b/server-rs/crates/agent-runtime-core/tests/provider_registry.rs new file mode 100644 index 000000000..c7f2a3e69 --- /dev/null +++ b/server-rs/crates/agent-runtime-core/tests/provider_registry.rs @@ -0,0 +1,279 @@ +use std::future::Future; +use std::pin::pin; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll, Wake, Waker}; + +use agent_runtime_core::{ + ProviderAdapter, ProviderCapability, ProviderContentPart, ProviderDescriptor, ProviderError, + ProviderErrorKind, ProviderFuture, ProviderInstanceId, ProviderMessage, ProviderProtocolId, + ProviderReasoningEffort, ProviderRegistry, ProviderRequest, ProviderResponse, ProviderRole, + ProviderStreamEvent, ProviderStreamFuture, ProviderStreamSink, ProviderTarget, + ProviderTextVerbosity, ProviderToolChoice, ProviderToolDefinition, +}; +use serde_json::json; + +struct ImmediateAdapter { + descriptor: ProviderDescriptor, + calls: Arc, +} + +impl ProviderAdapter for ImmediateAdapter { + fn descriptor(&self) -> &ProviderDescriptor { + &self.descriptor + } + + fn invoke( + &self, + request: ProviderRequest, + ) -> ProviderFuture<'_, Result> { + self.calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async move { + ProviderResponse::try_new( + request.request_id(), + "neutral-test-model", + [ProviderContentPart::text("done")?], + [], + ) + }) + } + + fn stream<'a>( + &'a self, + request: ProviderRequest, + mut sink: Box, + ) -> ProviderStreamFuture<'a, Result> { + self.calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async move { + sink.emit(ProviderStreamEvent::TextDelta { + accumulated_text: "done".to_string(), + delta_text: "done".to_string(), + finish_reason: None, + })?; + ProviderResponse::try_new( + request.request_id(), + "neutral-test-model", + [ProviderContentPart::text("done")?], + [], + ) + }) + } +} + +struct RecordingSink(Arc>>); + +impl ProviderStreamSink for RecordingSink { + fn emit(&mut self, event: ProviderStreamEvent) -> Result<(), ProviderError> { + self.0.lock().expect("stream events").push(event); + Ok(()) + } +} + +fn id(value: &str) -> ProviderInstanceId { + ProviderInstanceId::try_new(value).expect("instance id") +} + +fn protocol(value: &str) -> ProviderProtocolId { + ProviderProtocolId::try_new(value).expect("protocol id") +} + +fn target(instance: &str, protocol_id: &str) -> ProviderTarget { + ProviderTarget::new(id(instance), protocol(protocol_id)) +} + +fn request() -> ProviderRequest { + ProviderRequest::try_new( + "request-1", + [ProviderMessage::try_new( + ProviderRole::User, + [ProviderContentPart::text("review the document").expect("text")], + ) + .expect("message")], + ) + .expect("request") +} + +fn adapter( + instance: &str, + protocol_id: &str, + capabilities: impl IntoIterator, + calls: Arc, +) -> Arc { + Arc::new(ImmediateAdapter { + descriptor: ProviderDescriptor::try_new(id(instance), protocol(protocol_id), capabilities) + .expect("descriptor"), + calls, + }) +} + +#[test] +fn instance_and_protocol_ids_are_distinct_validated_contracts() { + let descriptor = ProviderDescriptor::try_new( + id("primary"), + protocol("neutral-messages-v1"), + [ProviderCapability::Streaming], + ) + .expect("descriptor"); + assert_eq!(descriptor.instance_id().as_str(), "primary"); + assert_eq!(descriptor.protocol_id().as_str(), "neutral-messages-v1"); + assert!(ProviderInstanceId::try_new(" primary").is_err()); + assert!(serde_json::from_str::(r#""bad protocol""#).is_err()); + assert!( + ProviderDescriptor::try_new( + id("broken"), + protocol("neutral-v1"), + [ProviderCapability::RequiredToolChoice], + ) + .is_err() + ); + + let result = ProviderContentPart::tool_result("call-1", json!({"ok": true}), false) + .expect("tool result"); + let serialized = serde_json::to_value(result).expect("serialize tool result"); + assert_eq!(serialized["toolCallId"], "call-1"); + assert!(serialized.get("tool_call_id").is_none()); +} + +#[test] +fn registry_fails_closed_for_duplicate_unknown_and_protocol_mismatch() { + let calls = Arc::new(AtomicUsize::new(0)); + let first = adapter("primary", "neutral-v1", [], Arc::clone(&calls)); + let mut registry = ProviderRegistry::try_new([first]).expect("registry"); + let duplicate = registry + .register(adapter("primary", "neutral-v2", [], Arc::clone(&calls))) + .expect_err("duplicate instance"); + assert_eq!(duplicate.kind(), ProviderErrorKind::DuplicateInstanceId); + + let unknown = block_on(registry.invoke(&target("missing", "neutral-v1"), request())) + .expect_err("unknown provider"); + assert_eq!(unknown.kind(), ProviderErrorKind::UnknownInstanceId); + let mismatch = block_on(registry.invoke(&target("primary", "neutral-v2"), request())) + .expect_err("protocol mismatch"); + assert_eq!(mismatch.kind(), ProviderErrorKind::ProtocolMismatch); + assert_eq!(calls.load(Ordering::SeqCst), 0); +} + +#[test] +fn every_required_capability_mismatch_is_rejected_before_adapter_call() { + let calls = Arc::new(AtomicUsize::new(0)); + let registry = + ProviderRegistry::try_new([adapter("limited", "neutral-v1", [], Arc::clone(&calls))]) + .expect("registry"); + let limited = target("limited", "neutral-v1"); + let tool = || { + ProviderToolDefinition::try_new( + "document_read", + "Read a document", + json!({"type": "object", "properties": {}}), + ) + .expect("tool") + }; + + let requests = [ + request() + .with_tools([tool()], ProviderToolChoice::Auto) + .expect("function tools"), + request() + .with_tools([tool()], ProviderToolChoice::Required) + .expect("required tool"), + ProviderRequest::try_new( + "request-tool-result", + [ProviderMessage::try_new( + ProviderRole::Tool, + [ + ProviderContentPart::tool_result("call-1", json!({"result": "done"}), false) + .expect("tool result"), + ], + ) + .expect("tool message")], + ) + .expect("tool result request"), + ProviderRequest::try_new( + "request-image", + [ProviderMessage::try_new( + ProviderRole::User, + [ + ProviderContentPart::image(json!({"mediaType": "image/png", "data": "opaque"})) + .expect("image"), + ], + ) + .expect("image message")], + ) + .expect("image request"), + request().with_web_search(true), + request().with_reasoning_effort(ProviderReasoningEffort::High), + request().with_text_verbosity(ProviderTextVerbosity::Low), + ]; + for request in requests { + let error = block_on(registry.invoke(&limited, request)).expect_err("capability mismatch"); + assert_eq!(error.kind(), ProviderErrorKind::CapabilityMismatch); + } + let stream_error = block_on(registry.stream( + &limited, + request(), + Box::new(RecordingSink(Arc::new(Mutex::new(Vec::new())))), + )) + .expect_err("streaming mismatch"); + assert_eq!(stream_error.kind(), ProviderErrorKind::CapabilityMismatch); + assert_eq!(calls.load(Ordering::SeqCst), 0); +} + +#[test] +fn fully_capable_arc_adapter_supports_invoke_and_stream() { + let calls = Arc::new(AtomicUsize::new(0)); + let all_capabilities = [ + ProviderCapability::Streaming, + ProviderCapability::FunctionTools, + ProviderCapability::RequiredToolChoice, + ProviderCapability::ImageInput, + ProviderCapability::WebSearch, + ProviderCapability::ReasoningEffort, + ProviderCapability::TextVerbosity, + ]; + let registry = ProviderRegistry::try_new([adapter( + "full", + "neutral-v1", + all_capabilities, + Arc::clone(&calls), + )]) + .expect("registry"); + let full = target("full", "neutral-v1"); + let response = block_on(registry.invoke(&full, request())).expect("invoke"); + assert_eq!(response.request_id(), "request-1"); + + let events = Arc::new(Mutex::new(Vec::new())); + block_on(registry.stream( + &full, + request(), + Box::new(RecordingSink(Arc::clone(&events))), + )) + .expect("stream"); + assert_eq!(events.lock().expect("events").len(), 1); + assert_eq!(calls.load(Ordering::SeqCst), 2); + let full_id = id("full"); + assert_eq!( + registry + .adapter(&full_id) + .expect("adapter") + .descriptor() + .protocol_id() + .as_str(), + "neutral-v1" + ); +} + +fn block_on(future: F) -> F::Output { + struct NoopWake; + impl Wake for NoopWake { + fn wake(self: Arc) {} + } + let waker = Waker::from(Arc::new(NoopWake)); + let mut context = Context::from_waker(&waker); + let mut future = pin!(future); + loop { + match future.as_mut().poll(&mut context) { + Poll::Ready(output) => return output, + Poll::Pending => std::thread::yield_now(), + } + } +} diff --git a/server-rs/crates/agent-runtime-core/tests/runtime_execution_conformance.rs b/server-rs/crates/agent-runtime-core/tests/runtime_execution_conformance.rs new file mode 100644 index 000000000..1368785e4 --- /dev/null +++ b/server-rs/crates/agent-runtime-core/tests/runtime_execution_conformance.rs @@ -0,0 +1,518 @@ +use std::cell::Cell; + +use agent_runtime_core::{ + ActionExecutionOutcome, CapabilityDefinition, CapabilityRegistry, CompletionAttempt, + CompletionBlocker, CompletionDecision, CompletionPolicy, RunSpec, RunStatus, RuntimeAction, + RuntimeClock, RuntimeEngine, RuntimeErrorKind, RuntimeEvent, RuntimeEventKind, RuntimeSnapshot, + RuntimeStore, ToolExecution, ToolHost, ToolOutput, +}; +use serde_json::json; + +#[derive(Default)] +struct MemoryStore { + snapshot: Option, + events: Vec, + fail_once_on: Option, +} + +impl RuntimeStore for MemoryStore { + fn load(&self, runtime_id: &str) -> Result, String> { + Ok(self + .snapshot + .as_ref() + .filter(|snapshot| snapshot.runtime_id() == runtime_id) + .cloned()) + } + + fn commit( + &mut self, + runtime_id: &str, + expected_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> Result<(), String> { + let current_revision = self.snapshot.as_ref().map(RuntimeSnapshot::revision); + if current_revision != expected_revision { + return Err(format!( + "revision conflict: expected={expected_revision:?} actual={current_revision:?}" + )); + } + if snapshot.runtime_id() != runtime_id + || snapshot.revision() != expected_revision.unwrap_or(0) + 1 + { + return Err("invalid snapshot identity/revision".to_string()); + } + if self + .fail_once_on + .is_some_and(|kind| events.iter().any(|event| event.kind() == kind)) + { + self.fail_once_on = None; + return Err("injected commit failure".to_string()); + } + self.snapshot = Some(snapshot.clone()); + self.events.extend_from_slice(events); + Ok(()) + } +} + +struct TestClock(Cell); + +impl TestClock { + fn new() -> Self { + Self(Cell::new(1_000)) + } +} + +impl RuntimeClock for TestClock { + fn now_millis(&self) -> u64 { + let current = self.0.get(); + self.0.set(current + 1); + current + } +} + +#[derive(Default)] +struct CountingToolHost { + executions: usize, +} + +#[derive(Default)] +struct UnknownToolHost { + executions: usize, +} + +impl ToolHost for UnknownToolHost { + fn execute( + &mut self, + _run: &agent_runtime_core::RunRecord, + _action: &RuntimeAction, + ) -> ToolExecution { + self.executions += 1; + ToolExecution::Unknown + } +} + +impl ToolHost for CountingToolHost { + fn execute( + &mut self, + _run: &agent_runtime_core::RunRecord, + action: &RuntimeAction, + ) -> ToolExecution { + self.executions += 1; + ToolExecution::Observed( + ToolOutput::completed( + "文档读取完成", + json!({"actionId": action.id(), "sections": 3}), + ) + .expect("tool output"), + ) + } +} + +struct ReviewPolicy; + +struct ReviewContext { + ready: bool, +} + +impl CompletionPolicy for ReviewPolicy { + fn id(&self) -> &str { + "document-review" + } + + fn evaluate(&self, context: &ReviewContext) -> CompletionDecision { + if context.ready { + CompletionDecision::ready() + } else { + CompletionDecision::blocked([CompletionBlocker::try_new( + "review-pending", + "文档审查尚未完成", + ) + .expect("blocker")]) + .expect("blocked") + } + } +} + +fn run(run_id: &str, agent_id: &str, task: &str) -> RunSpec { + RunSpec::try_new(run_id, agent_id, task).expect("run spec") +} + +fn capabilities() -> CapabilityRegistry<()> { + CapabilityRegistry::try_new([ + CapabilityDefinition::try_new( + "document.read", + "runtime_tool_document_read", + "读取文档", + json!({"type": "object"}), + (), + ) + .expect("read capability"), + CapabilityDefinition::try_new( + "document.check-citations", + "runtime_tool_document_check_citations", + "核验文档引用", + json!({"type": "object"}), + (), + ) + .expect("citation capability"), + CapabilityDefinition::try_new( + "document.external-check", + "runtime_tool_document_external_check", + "执行外部文档检查", + json!({"type": "object"}), + (), + ) + .expect("external capability"), + ]) + .expect("capability registry") +} + +#[test] +fn document_runtime_survives_unknown_tool_outcome_and_resolves_all_join() { + let mut engine = RuntimeEngine::new(MemoryStore::default(), TestClock::new()); + engine.create_runtime("document-runtime").expect("runtime"); + engine + .add_run( + "document-runtime", + run("review-root", "editor", "完成文档审查并整合子任务"), + ) + .expect("root"); + assert_eq!( + engine.schedule_ready("document-runtime").expect("schedule"), + vec!["review-root"] + ); + + engine + .enqueue_action( + "document-runtime", + "review-root", + &capabilities(), + RuntimeAction::try_new("read-draft", "document.read", json!({"path": "draft.md"})) + .expect("action"), + ) + .expect("enqueue"); + let mut host = CountingToolHost::default(); + assert_eq!( + engine + .execute_next_action("document-runtime", "review-root", &mut host) + .expect("execute"), + ActionExecutionOutcome::Observed + ); + + engine + .spawn( + "document-runtime", + "review-root", + "review-group", + [ + run("citation-child", "citation-reviewer", "核验文档引用"), + run("style-child", "style-reviewer", "检查文档表达与结构"), + ], + ) + .expect("spawn"); + assert_eq!( + engine + .complete_if_ready( + "document-runtime", + "review-root", + &ReviewPolicy, + &ReviewContext { ready: true }, + "不能提前完成", + ) + .expect_err("parent cannot complete before all-join") + .kind(), + RuntimeErrorKind::InvalidTransition + ); + assert_eq!( + engine.schedule_ready("document-runtime").expect("children"), + vec!["citation-child", "style-child"] + ); + + engine + .complete_if_ready( + "document-runtime", + "style-child", + &ReviewPolicy, + &ReviewContext { ready: true }, + "表达检查完成", + ) + .expect("style complete"); + + engine + .enqueue_action( + "document-runtime", + "citation-child", + &capabilities(), + RuntimeAction::try_new( + "check-citations", + "document.check-citations", + json!({"path": "draft.md"}), + ) + .expect("citation action"), + ) + .expect("enqueue citation"); + engine.store_mut().fail_once_on = Some(RuntimeEventKind::ActionObserved); + let error = engine + .execute_next_action("document-runtime", "citation-child", &mut host) + .expect_err("observation commit must fail"); + assert_eq!(error.kind(), RuntimeErrorKind::Store); + assert_eq!(host.executions, 2); + + let serialized = serde_json::to_string( + engine + .store() + .snapshot + .as_ref() + .expect("persisted executing snapshot"), + ) + .expect("serialize snapshot"); + let recovered_snapshot = + serde_json::from_str::(&serialized).expect("reload snapshot"); + let recovered_store = MemoryStore { + snapshot: Some(recovered_snapshot), + events: engine.store().events.clone(), + fail_once_on: None, + }; + let mut recovered = RuntimeEngine::new(recovered_store, TestClock::new()); + assert_eq!( + recovered + .execute_next_action("document-runtime", "citation-child", &mut host) + .expect("recovery classification"), + ActionExecutionOutcome::NeedsReconciliation + ); + assert_eq!(host.executions, 2, "executing action must never replay"); + recovered + .reconcile_action( + "document-runtime", + "citation-child", + ToolOutput::completed("引用核验结果已由宿主确认", json!({"citations": 5})) + .expect("reconciled output"), + ) + .expect("reconcile"); + recovered + .complete_if_ready( + "document-runtime", + "citation-child", + &ReviewPolicy, + &ReviewContext { ready: true }, + "引用核验完成", + ) + .expect("citation complete"); + + let joined = recovered + .load("document-runtime") + .expect("load") + .expect("snapshot"); + assert_eq!( + joined.run("review-root").map(|run| run.status()), + Some(RunStatus::Running) + ); + let group = joined.delegations().first().expect("delegation"); + assert!(group.is_resolved()); + assert_eq!( + group + .results() + .iter() + .map(|result| result.run_id()) + .collect::>(), + vec!["citation-child", "style-child"], + "join order follows child registration, not completion order" + ); + + assert!(matches!( + recovered + .complete_if_ready( + "document-runtime", + "review-root", + &ReviewPolicy, + &ReviewContext { ready: false }, + "文档审查完成", + ) + .expect("blocked completion"), + CompletionAttempt::Blocked(blockers) if blockers.len() == 1 + )); + assert_eq!( + recovered + .complete_if_ready( + "document-runtime", + "review-root", + &ReviewPolicy, + &ReviewContext { ready: true }, + "文档审查完成", + ) + .expect("root complete"), + CompletionAttempt::Completed + ); + assert_eq!( + recovered + .load("document-runtime") + .expect("load completed") + .expect("completed snapshot") + .run("review-root") + .map(|run| run.status()), + Some(RunStatus::Completed) + ); +} + +#[test] +fn agent_lanes_are_stable_and_terminal_runs_reject_new_actions() { + let mut engine = RuntimeEngine::new(MemoryStore::default(), TestClock::new()); + engine.create_runtime("lane-runtime").expect("runtime"); + engine + .add_run("lane-runtime", run("first", "reviewer", "第一个任务")) + .expect("first"); + engine + .add_run("lane-runtime", run("second", "reviewer", "第二个任务")) + .expect("second"); + engine + .add_run("lane-runtime", run("parallel", "writer", "并行任务")) + .expect("parallel"); + assert_eq!( + engine.schedule_ready("lane-runtime").expect("schedule"), + vec!["first", "parallel"] + ); + engine + .complete_if_ready( + "lane-runtime", + "first", + &ReviewPolicy, + &ReviewContext { ready: true }, + "第一个任务完成", + ) + .expect("complete first"); + assert_eq!( + engine.schedule_ready("lane-runtime").expect("reschedule"), + vec!["second"] + ); + let revision = engine + .load("lane-runtime") + .expect("load") + .expect("snapshot") + .revision(); + assert_eq!( + engine + .enqueue_action( + "lane-runtime", + "second", + &capabilities(), + RuntimeAction::try_new("unknown-action", "document.unknown", json!({})) + .expect("unknown action shape"), + ) + .expect_err("unregistered capability must fail before persistence") + .kind(), + RuntimeErrorKind::InvalidInput + ); + assert_eq!( + engine + .load("lane-runtime") + .expect("load unchanged") + .expect("snapshot") + .revision(), + revision + ); + let error = engine + .enqueue_action( + "lane-runtime", + "first", + &capabilities(), + RuntimeAction::try_new("late-action", "document.read", json!({})).expect("late action"), + ) + .expect_err("terminal run must reject action"); + assert_eq!(error.kind(), RuntimeErrorKind::InvalidTransition); +} + +#[test] +fn unknown_execution_is_stable_across_repeated_resume_and_can_be_reconciled() { + let mut engine = RuntimeEngine::new(MemoryStore::default(), TestClock::new()); + engine.create_runtime("unknown-runtime").expect("runtime"); + engine + .add_run( + "unknown-runtime", + run("unknown-run", "reviewer", "执行可能产生外部副作用的检查"), + ) + .expect("run"); + engine.schedule_ready("unknown-runtime").expect("schedule"); + engine + .enqueue_action( + "unknown-runtime", + "unknown-run", + &capabilities(), + RuntimeAction::try_new("external-check", "document.external-check", json!({})) + .expect("action"), + ) + .expect("enqueue"); + let mut host = UnknownToolHost::default(); + assert_eq!( + engine + .execute_next_action("unknown-runtime", "unknown-run", &mut host) + .expect("unknown outcome"), + ActionExecutionOutcome::NeedsReconciliation + ); + let revision = engine + .load("unknown-runtime") + .expect("load") + .expect("snapshot") + .revision(); + assert_eq!( + engine + .execute_next_action("unknown-runtime", "unknown-run", &mut host) + .expect("repeat resume"), + ActionExecutionOutcome::NeedsReconciliation + ); + assert_eq!(host.executions, 1); + assert_eq!( + engine + .load("unknown-runtime") + .expect("load again") + .expect("snapshot") + .revision(), + revision, + "repeated reconciliation resume must not append duplicate events" + ); + engine + .reconcile_action( + "unknown-runtime", + "unknown-run", + ToolOutput::completed("宿主已核对外部结果", json!({"verified": true})).expect("output"), + ) + .expect("reconcile"); + assert_eq!( + engine + .load("unknown-runtime") + .expect("load reconciled") + .expect("snapshot") + .run("unknown-run") + .map(|run| run.status()), + Some(RunStatus::Running) + ); +} + +#[test] +fn store_cas_and_snapshot_identity_fail_closed() { + let mut engine = RuntimeEngine::new(MemoryStore::default(), TestClock::new()); + let snapshot = engine.create_runtime("cas-runtime").expect("runtime"); + let stale_commit = engine + .store_mut() + .commit("cas-runtime", Some(0), &snapshot, &[]); + assert!(stale_commit.is_err()); + + let mut tampered = serde_json::to_value(&snapshot).expect("snapshot json"); + tampered["schemaVersion"] = json!("agent-runtime-core-snapshot.v999"); + let tampered = serde_json::from_value::(tampered).expect("tampered snapshot"); + let engine = RuntimeEngine::new( + MemoryStore { + snapshot: Some(tampered), + events: Vec::new(), + fail_once_on: None, + }, + TestClock::new(), + ); + assert_eq!( + engine + .load("cas-runtime") + .expect_err("tampered schema must fail") + .kind(), + RuntimeErrorKind::Conflict + ); +} diff --git a/server-rs/crates/platform-llm/Cargo.toml b/server-rs/crates/platform-llm/Cargo.toml index 7228bb0e8..312631f45 100644 --- a/server-rs/crates/platform-llm/Cargo.toml +++ b/server-rs/crates/platform-llm/Cargo.toml @@ -5,6 +5,7 @@ version.workspace = true license.workspace = true [dependencies] +agent-runtime-core = { path = "../agent-runtime-core" } log = { workspace = true } reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots", "stream"] } serde = { workspace = true } diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index 216fe26b2..207614051 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -13,6 +13,17 @@ use reqwest::{Client, StatusCode, redirect::Policy}; use serde::{Deserialize, Serialize}; use tokio::time::sleep; +mod provider_adapter; + +pub use provider_adapter::{ + ANTHROPIC_PROVIDER_INSTANCE_ID, ANTHROPIC_PROVIDER_PROTOCOL_ID, AnthropicProviderAdapter, + OPENAI_CHAT_PROVIDER_INSTANCE_ID, OPENAI_CHAT_PROVIDER_PROTOCOL_ID, + OPENAI_RESPONSES_PROVIDER_INSTANCE_ID, OPENAI_RESPONSES_PROVIDER_PROTOCOL_ID, + OpenAiChatProviderAdapter, OpenAiResponsesProviderAdapter, PlatformLlmProviderRegistryBuilder, + build_platform_llm_provider_registry, build_platform_llm_provider_registry_for_api_kind, + llm_response_from_provider_response, provider_request_from_llm_request, +}; + pub const DEFAULT_ARK_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/v3"; pub const EDITOR_AGENT_GPT5_MODEL: &str = "gpt-5.4-mini"; pub const DEFAULT_REQUEST_TIMEOUT_MS: u64 = 30_000; diff --git a/server-rs/crates/platform-llm/src/provider_adapter.rs b/server-rs/crates/platform-llm/src/provider_adapter.rs new file mode 100644 index 000000000..0b7990a38 --- /dev/null +++ b/server-rs/crates/platform-llm/src/provider_adapter.rs @@ -0,0 +1,1122 @@ +use std::sync::Arc; + +use agent_runtime_core::{ + ProviderAdapter, ProviderCapability, ProviderContentPart, ProviderDescriptor, ProviderError, + ProviderFuture, ProviderInstanceId, ProviderMessage, ProviderProtocolId, ProviderRegistry, + ProviderRequest, ProviderResponse, ProviderRole, ProviderStreamEvent, ProviderStreamFuture, + ProviderStreamSink, ProviderTarget, ProviderTextVerbosity, ProviderToolCall, + ProviderToolChoice, ProviderToolDefinition, ProviderUsage, +}; +use serde_json::Value; + +use crate::{ + LlmApiKind, LlmClient, LlmFunctionTool, LlmMessage, LlmMessageContentPart, LlmMessageRole, + LlmResponseReasoningEffort, LlmResponseTextVerbosity, LlmRunRequest, LlmRunResponse, + LlmToolCall, LlmToolChoice, +}; + +pub const OPENAI_RESPONSES_PROVIDER_INSTANCE_ID: &str = "platform-llm.openai-responses"; +pub const OPENAI_RESPONSES_PROVIDER_PROTOCOL_ID: &str = "openai-responses"; +pub const OPENAI_CHAT_PROVIDER_INSTANCE_ID: &str = "platform-llm.openai-chat"; +pub const OPENAI_CHAT_PROVIDER_PROTOCOL_ID: &str = "openai-chat"; +pub const ANTHROPIC_PROVIDER_INSTANCE_ID: &str = "platform-llm.anthropic"; +pub const ANTHROPIC_PROVIDER_PROTOCOL_ID: &str = "anthropic-messages"; + +#[derive(Clone, Debug)] +struct PlatformLlmProviderAdapter { + descriptor: ProviderDescriptor, + client: LlmClient, + api_kind: LlmApiKind, +} + +impl PlatformLlmProviderAdapter { + fn try_new( + client: LlmClient, + api_kind: LlmApiKind, + instance_id: &str, + protocol_id: &str, + capabilities: impl IntoIterator, + display_name: &str, + ) -> Result { + let descriptor = ProviderDescriptor::try_new( + ProviderInstanceId::try_new(instance_id)?, + ProviderProtocolId::try_new(protocol_id)?, + capabilities, + )? + .with_display_name(display_name)? + .with_metadata(serde_json::json!({ + "apiKind": api_kind_name(api_kind), + "transportOwner": "platform-llm", + "parserOwner": "platform-llm" + }))?; + Ok(Self { + descriptor, + client, + api_kind, + }) + } + + fn descriptor(&self) -> &ProviderDescriptor { + &self.descriptor + } + + fn invoke( + &self, + request: ProviderRequest, + ) -> ProviderFuture<'_, Result> { + Box::pin(async move { + let request_id = request.request_id().to_string(); + let request = provider_request_to_llm_request(&request, self.api_kind)?; + let response = self.client.run(request).await.map_err(map_llm_error)?; + llm_response_to_provider_response(&request_id, response) + }) + } + + fn stream<'a>( + &'a self, + request: ProviderRequest, + mut sink: Box, + ) -> ProviderStreamFuture<'a, Result> { + Box::pin(async move { + let request_id = request.request_id().to_string(); + let request = provider_request_to_llm_request(&request, self.api_kind)?; + let mut sink_error = None; + let response = self + .client + .stream_run(request, |delta| { + if sink_error.is_some() { + return; + } + if let Err(error) = sink.emit(ProviderStreamEvent::TextDelta { + accumulated_text: delta.accumulated_text.clone(), + delta_text: delta.delta_text.clone(), + finish_reason: delta.finish_reason.clone(), + }) { + sink_error = Some(error); + } + }) + .await + .map_err(map_llm_error)?; + if let Some(error) = sink_error { + return Err(error); + } + if let Some(usage) = response.usage.as_ref() { + sink.emit(ProviderStreamEvent::Usage { + usage: ProviderUsage::new( + usage.prompt_tokens, + usage.completion_tokens, + usage.total_tokens, + ), + })?; + } + llm_response_to_provider_response(&request_id, response) + }) + } +} + +macro_rules! protocol_adapter { + ($name:ident, $api_kind:expr, $instance_id:expr, $protocol_id:expr, $display_name:expr, [$($capability:expr),* $(,)?]) => { + #[derive(Clone, Debug)] + pub struct $name { + inner: PlatformLlmProviderAdapter, + } + + impl $name { + pub fn try_new(client: LlmClient) -> Result { + Self::try_new_with_instance_id($instance_id, client) + } + + pub fn try_new_with_instance_id( + instance_id: &str, + client: LlmClient, + ) -> Result { + Ok(Self { + inner: PlatformLlmProviderAdapter::try_new( + client, + $api_kind, + instance_id, + $protocol_id, + [$($capability),*], + $display_name, + )?, + }) + } + } + + impl ProviderAdapter for $name { + fn descriptor(&self) -> &ProviderDescriptor { + self.inner.descriptor() + } + + fn invoke( + &self, + request: ProviderRequest, + ) -> ProviderFuture<'_, Result> { + self.inner.invoke(request) + } + + fn stream<'a>( + &'a self, + request: ProviderRequest, + sink: Box, + ) -> ProviderStreamFuture<'a, Result> { + self.inner.stream(request, sink) + } + } + }; +} + +protocol_adapter!( + OpenAiResponsesProviderAdapter, + LlmApiKind::OpenAiResponses, + OPENAI_RESPONSES_PROVIDER_INSTANCE_ID, + OPENAI_RESPONSES_PROVIDER_PROTOCOL_ID, + "OpenAI Responses", + [ + ProviderCapability::Streaming, + ProviderCapability::FunctionTools, + ProviderCapability::RequiredToolChoice, + ProviderCapability::ImageInput, + ProviderCapability::WebSearch, + ProviderCapability::ReasoningEffort, + ProviderCapability::TextVerbosity, + ] +); + +protocol_adapter!( + OpenAiChatProviderAdapter, + LlmApiKind::OpenAiChat, + OPENAI_CHAT_PROVIDER_INSTANCE_ID, + OPENAI_CHAT_PROVIDER_PROTOCOL_ID, + "OpenAI Chat Completions", + [ + ProviderCapability::Streaming, + ProviderCapability::FunctionTools, + ProviderCapability::RequiredToolChoice, + ProviderCapability::ImageInput, + ProviderCapability::WebSearch, + ProviderCapability::ReasoningEffort, + ] +); + +protocol_adapter!( + AnthropicProviderAdapter, + LlmApiKind::Anthropic, + ANTHROPIC_PROVIDER_INSTANCE_ID, + ANTHROPIC_PROVIDER_PROTOCOL_ID, + "Anthropic Messages", + [ + ProviderCapability::Streaming, + ProviderCapability::FunctionTools, + ProviderCapability::RequiredToolChoice, + ] +); + +#[derive(Default)] +pub struct PlatformLlmProviderRegistryBuilder { + adapters: Vec>, +} + +impl PlatformLlmProviderRegistryBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn with_openai_responses(mut self, client: LlmClient) -> Result { + self.adapters + .push(Arc::new(OpenAiResponsesProviderAdapter::try_new(client)?)); + Ok(self) + } + + pub fn with_openai_responses_instance( + mut self, + instance_id: &str, + client: LlmClient, + ) -> Result { + self.adapters.push(Arc::new( + OpenAiResponsesProviderAdapter::try_new_with_instance_id(instance_id, client)?, + )); + Ok(self) + } + + pub fn with_openai_chat(mut self, client: LlmClient) -> Result { + self.adapters + .push(Arc::new(OpenAiChatProviderAdapter::try_new(client)?)); + Ok(self) + } + + pub fn with_openai_chat_instance( + mut self, + instance_id: &str, + client: LlmClient, + ) -> Result { + self.adapters.push(Arc::new( + OpenAiChatProviderAdapter::try_new_with_instance_id(instance_id, client)?, + )); + Ok(self) + } + + pub fn with_anthropic(mut self, client: LlmClient) -> Result { + self.adapters + .push(Arc::new(AnthropicProviderAdapter::try_new(client)?)); + Ok(self) + } + + pub fn with_anthropic_instance( + mut self, + instance_id: &str, + client: LlmClient, + ) -> Result { + self.adapters.push(Arc::new( + AnthropicProviderAdapter::try_new_with_instance_id(instance_id, client)?, + )); + Ok(self) + } + + pub fn build(self) -> Result { + ProviderRegistry::try_new(self.adapters) + } +} + +pub fn build_platform_llm_provider_registry( + client: LlmClient, +) -> Result { + PlatformLlmProviderRegistryBuilder::new() + .with_openai_responses(client.clone())? + .with_openai_chat(client.clone())? + .with_anthropic(client)? + .build() +} + +pub fn build_platform_llm_provider_registry_for_api_kind( + instance_id: &str, + client: LlmClient, + api_kind: LlmApiKind, +) -> Result<(ProviderRegistry, ProviderTarget), ProviderError> { + let (adapter, protocol_id): (Arc, &str) = match api_kind { + LlmApiKind::OpenAiResponses => ( + Arc::new(OpenAiResponsesProviderAdapter::try_new_with_instance_id( + instance_id, + client, + )?), + OPENAI_RESPONSES_PROVIDER_PROTOCOL_ID, + ), + LlmApiKind::OpenAiChat => ( + Arc::new(OpenAiChatProviderAdapter::try_new_with_instance_id( + instance_id, + client, + )?), + OPENAI_CHAT_PROVIDER_PROTOCOL_ID, + ), + LlmApiKind::Anthropic => ( + Arc::new(AnthropicProviderAdapter::try_new_with_instance_id( + instance_id, + client, + )?), + ANTHROPIC_PROVIDER_PROTOCOL_ID, + ), + }; + let target = ProviderTarget::new( + ProviderInstanceId::try_new(instance_id)?, + ProviderProtocolId::try_new(protocol_id)?, + ); + Ok((ProviderRegistry::try_new([adapter])?, target)) +} + +pub fn provider_request_from_llm_request( + request_id: &str, + request: LlmRunRequest, +) -> Result { + let messages = request + .messages + .into_iter() + .map(llm_message_to_provider_message) + .collect::, _>>()?; + let mut output = ProviderRequest::try_new(request_id, messages)?; + let tools = request + .function_tools + .into_iter() + .map(|tool| { + Ok( + ProviderToolDefinition::try_new(tool.name, tool.description, tool.parameters)? + .with_strict(tool.strict), + ) + }) + .collect::, ProviderError>>()?; + if !tools.is_empty() { + let tool_choice = match request.tool_choice.unwrap_or(LlmToolChoice::Auto) { + LlmToolChoice::Auto => ProviderToolChoice::Auto, + LlmToolChoice::Required => ProviderToolChoice::Required, + }; + output = output.with_tools(tools, tool_choice)?; + } else if request.tool_choice.is_some() { + return Err(ProviderError::invalid_request( + "platform-llm toolChoice 必须与 function tools 一起使用", + )); + } + if let Some(model) = request.model { + output = output.with_model(model)?; + } + if let Some(max_output_tokens) = request.max_output_tokens { + output = output.with_max_output_tokens(max_output_tokens)?; + } + if let Some(request_timeout_ms) = request.request_timeout_ms { + output = output.with_request_timeout_ms(request_timeout_ms)?; + } + if request.enable_web_search { + output = output.with_web_search(true); + } + if let Some(effort) = request.response_reasoning_effort { + output = output.with_reasoning_effort(match effort { + LlmResponseReasoningEffort::Low => agent_runtime_core::ProviderReasoningEffort::Low, + LlmResponseReasoningEffort::Medium => { + agent_runtime_core::ProviderReasoningEffort::Medium + } + LlmResponseReasoningEffort::High => agent_runtime_core::ProviderReasoningEffort::High, + }); + } + if let Some(verbosity) = request.response_text_verbosity { + output = output.with_text_verbosity(match verbosity { + LlmResponseTextVerbosity::Low => ProviderTextVerbosity::Low, + LlmResponseTextVerbosity::Medium => ProviderTextVerbosity::Medium, + LlmResponseTextVerbosity::High => ProviderTextVerbosity::High, + }); + } + Ok(output) +} + +pub fn llm_response_from_provider_response( + provider: crate::LlmProvider, + response: ProviderResponse, +) -> Result { + let mut text_parts = Vec::new(); + for part in response.content() { + match part { + ProviderContentPart::Text { text } => text_parts.push(text.as_str()), + ProviderContentPart::Image { .. } | ProviderContentPart::ToolResult { .. } => { + return Err(ProviderError::deserialize( + "platform-llm 文本响应不能包含 image/tool-result content", + )); + } + } + } + let tool_calls = response + .tool_calls() + .iter() + .map(|call| LlmToolCall { + id: call.id().to_string(), + name: call.name().to_string(), + arguments: call.arguments().to_string(), + }) + .collect(); + Ok(LlmRunResponse { + provider, + model: response.model().to_string(), + text: text_parts.join(""), + finish_reason: response.finish_reason().map(str::to_string), + response_id: response.response_id().map(str::to_string), + usage: response.usage().map(|usage| crate::LlmTokenUsage { + prompt_tokens: usage.input_tokens(), + completion_tokens: usage.output_tokens(), + total_tokens: usage.total_tokens(), + }), + tool_calls, + }) +} + +fn llm_message_to_provider_message(message: LlmMessage) -> Result { + let role = match message.role { + LlmMessageRole::System => ProviderRole::System, + LlmMessageRole::User => ProviderRole::User, + LlmMessageRole::Assistant => ProviderRole::Assistant, + }; + let parts = if message.content_parts.is_empty() { + vec![ProviderContentPart::text(message.content)?] + } else { + message + .content_parts + .into_iter() + .map(|part| match part { + LlmMessageContentPart::InputText { text } => ProviderContentPart::text(text), + LlmMessageContentPart::InputImage { image_url } => { + ProviderContentPart::image(serde_json::json!({ "url": image_url })) + } + }) + .collect::, _>>()? + }; + ProviderMessage::try_new(role, parts) +} + +fn provider_request_to_llm_request( + request: &ProviderRequest, + api_kind: LlmApiKind, +) -> Result { + let messages = request + .messages() + .iter() + .map(provider_message_to_llm_message) + .collect::, _>>()?; + let tools = request + .tools() + .iter() + .map(|tool| { + LlmFunctionTool::new(tool.name(), tool.description(), tool.input_schema().clone()) + .with_strict(tool.strict()) + }) + .collect::>(); + let mut output = LlmRunRequest::new(messages) + .with_api_kind(api_kind) + .with_web_search(request.web_search()) + .with_function_tools(tools); + output = match request.tool_choice() { + ProviderToolChoice::Auto if request.tools().is_empty() => output, + ProviderToolChoice::Auto => output.with_tool_choice(LlmToolChoice::Auto), + ProviderToolChoice::Required => output.with_tool_choice(LlmToolChoice::Required), + ProviderToolChoice::None => { + return Err(ProviderError::invalid_request( + "platform-llm adapter 暂不支持 toolChoice=none", + )); + } + ProviderToolChoice::Specific(_) => { + return Err(ProviderError::invalid_request( + "platform-llm adapter 暂不支持指定单个 function tool", + )); + } + }; + if let Some(model) = request.model() { + output = output.with_model(model); + } + if let Some(max_output_tokens) = request.max_output_tokens() { + output = output.with_max_output_tokens(max_output_tokens); + } + if let Some(request_timeout_ms) = request.request_timeout_ms() { + output = output.with_request_timeout_ms(request_timeout_ms); + } + if let Some(effort) = request.reasoning_effort() { + output = output.with_response_reasoning_effort(match effort { + agent_runtime_core::ProviderReasoningEffort::Minimal => { + return Err(ProviderError::invalid_request( + "platform-llm 暂不支持 reasoningEffort=minimal", + )); + } + agent_runtime_core::ProviderReasoningEffort::Low => LlmResponseReasoningEffort::Low, + agent_runtime_core::ProviderReasoningEffort::Medium => { + LlmResponseReasoningEffort::Medium + } + agent_runtime_core::ProviderReasoningEffort::High => LlmResponseReasoningEffort::High, + agent_runtime_core::ProviderReasoningEffort::XHigh => { + return Err(ProviderError::invalid_request( + "platform-llm 暂不支持 reasoningEffort=x-high", + )); + } + }); + } + if let Some(verbosity) = request.text_verbosity() { + output = output.with_response_text_verbosity(match verbosity { + ProviderTextVerbosity::Low => LlmResponseTextVerbosity::Low, + ProviderTextVerbosity::Medium => LlmResponseTextVerbosity::Medium, + ProviderTextVerbosity::High => LlmResponseTextVerbosity::High, + }); + } + Ok(output) +} + +fn provider_message_to_llm_message(message: &ProviderMessage) -> Result { + let role = match message.role() { + ProviderRole::System => LlmMessageRole::System, + ProviderRole::User => LlmMessageRole::User, + ProviderRole::Assistant => LlmMessageRole::Assistant, + ProviderRole::Tool => { + return Err(ProviderError::invalid_request( + "platform-llm 现有消息 DTO 暂不支持 tool role", + )); + } + }; + if let [ProviderContentPart::Text { text }] = message.content() { + return Ok(LlmMessage { + role, + content: text.clone(), + content_parts: Vec::new(), + }); + } + let mut content_parts = Vec::new(); + for part in message.content() { + match part { + ProviderContentPart::Text { text } => { + content_parts.push(LlmMessageContentPart::InputText { text: text.clone() }); + } + ProviderContentPart::Image { source } => { + let image_url = source + .get("url") + .or_else(|| source.get("imageUrl")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + ProviderError::invalid_request( + "platform-llm image source 必须包含非空 url 或 imageUrl", + ) + })?; + content_parts.push(LlmMessageContentPart::InputImage { + image_url: image_url.to_string(), + }); + } + ProviderContentPart::ToolResult { .. } => { + return Err(ProviderError::invalid_request( + "platform-llm 现有消息 DTO 暂不支持 tool result content", + )); + } + } + } + Ok(LlmMessage::multimodal(role, content_parts)) +} + +fn llm_response_to_provider_response( + request_id: &str, + response: LlmRunResponse, +) -> Result { + let content = if response.text.trim().is_empty() { + Vec::new() + } else { + vec![ProviderContentPart::text(response.text).map_err(|error| { + ProviderError::deserialize(format!("转换 platform-llm response 正文失败:{error}")) + })?] + }; + let tool_calls = response + .tool_calls + .into_iter() + .map(|call| { + ProviderToolCall::try_new(call.id, call.name, call.arguments).map_err(|error| { + ProviderError::deserialize(format!("转换 platform-llm tool call 失败:{error}")) + }) + }) + .collect::, _>>()?; + let mut output = ProviderResponse::try_new(request_id, response.model, content, tool_calls) + .map_err(|error| { + ProviderError::deserialize(format!("转换 platform-llm response 失败:{error}")) + })?; + if let Some(response_id) = response.response_id { + output = output.with_response_id(response_id).map_err(|error| { + ProviderError::deserialize(format!("转换 platform-llm response id 失败:{error}")) + })?; + } + if let Some(reason) = response + .finish_reason + .map(|reason| reason.trim().to_string()) + .filter(|reason| !reason.is_empty()) + { + output = output.with_finish_reason(reason).map_err(|error| { + ProviderError::deserialize(format!("转换 platform-llm finish reason 失败:{error}")) + })?; + } + if let Some(usage) = response.usage { + output = output.with_usage(ProviderUsage::new( + usage.prompt_tokens, + usage.completion_tokens, + usage.total_tokens, + )); + } + Ok(output) +} + +fn api_kind_name(api_kind: LlmApiKind) -> &'static str { + match api_kind { + LlmApiKind::OpenAiChat => "openai_chat", + LlmApiKind::OpenAiResponses => "openai_responses", + LlmApiKind::Anthropic => "anthropic", + } +} + +fn map_llm_error(error: crate::LlmError) -> ProviderError { + let detail = error.to_string(); + match error { + crate::LlmError::InvalidConfig(_) => ProviderError::invalid_config(detail), + crate::LlmError::InvalidRequest(_) => ProviderError::invalid_request(detail), + crate::LlmError::Timeout { attempts } => ProviderError::timeout(attempts, detail), + crate::LlmError::Connectivity { attempts, .. } => { + ProviderError::connectivity(attempts, detail) + } + crate::LlmError::Upstream { status_code, .. } => { + ProviderError::upstream(status_code, detail) + } + crate::LlmError::StreamUnavailable => ProviderError::stream_unavailable(detail), + crate::LlmError::EmptyResponse => ProviderError::empty_response(detail), + crate::LlmError::Transport(_) => ProviderError::transport(detail), + crate::LlmError::Deserialize(_) => ProviderError::deserialize(detail), + } +} + +#[cfg(test)] +mod tests { + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + use agent_runtime_core::{ProviderErrorKind, ProviderReasoningEffort, ProviderToolDefinition}; + + use super::*; + use crate::{LlmConfig, LlmProvider, LlmTokenUsage, LlmToolCall}; + + fn client() -> LlmClient { + LlmClient::new( + LlmConfig::new( + LlmProvider::OpenAiCompatible, + "https://example.test/v1".to_string(), + "test-key".to_string(), + "test-model".to_string(), + 1_000, + 0, + 1, + ) + .expect("config"), + ) + .expect("client") + } + + fn core_request() -> ProviderRequest { + ProviderRequest::try_new( + "request-1", + [ProviderMessage::try_new( + ProviderRole::User, + [ProviderContentPart::text("你好").expect("text")], + ) + .expect("message")], + ) + .expect("request") + } + + #[test] + fn registry_builder_registers_three_protocol_adapters() { + let registry = build_platform_llm_provider_registry(client()).expect("registry"); + assert_eq!(registry.len(), 3); + for (instance_id, protocol_id) in [ + ( + OPENAI_RESPONSES_PROVIDER_INSTANCE_ID, + OPENAI_RESPONSES_PROVIDER_PROTOCOL_ID, + ), + ( + OPENAI_CHAT_PROVIDER_INSTANCE_ID, + OPENAI_CHAT_PROVIDER_PROTOCOL_ID, + ), + ( + ANTHROPIC_PROVIDER_INSTANCE_ID, + ANTHROPIC_PROVIDER_PROTOCOL_ID, + ), + ] { + let instance_id = ProviderInstanceId::try_new(instance_id).expect("instance id"); + let descriptor = registry.descriptor(&instance_id).expect("descriptor"); + assert_eq!(descriptor.protocol_id().as_str(), protocol_id); + assert!(descriptor.supports(ProviderCapability::Streaming)); + assert!(descriptor.supports(ProviderCapability::FunctionTools)); + } + } + + #[test] + fn registry_accepts_multiple_isolated_instances_of_the_same_protocol() { + let registry = PlatformLlmProviderRegistryBuilder::new() + .with_openai_chat_instance("tenant-a", client()) + .expect("tenant a") + .with_openai_chat_instance("tenant-b", client()) + .expect("tenant b") + .build() + .expect("registry"); + assert_eq!(registry.len(), 2); + for instance_id in ["tenant-a", "tenant-b"] { + let descriptor = registry + .descriptor(&ProviderInstanceId::try_new(instance_id).expect("instance id")) + .expect("descriptor"); + assert_eq!( + descriptor.protocol_id().as_str(), + OPENAI_CHAT_PROVIDER_PROTOCOL_ID + ); + } + } + + #[test] + fn descriptor_capabilities_match_existing_protocol_support() { + let responses = OpenAiResponsesProviderAdapter::try_new(client()).expect("responses"); + assert!( + responses + .descriptor() + .supports(ProviderCapability::TextVerbosity) + ); + assert!( + responses + .descriptor() + .supports(ProviderCapability::ImageInput) + ); + let chat = OpenAiChatProviderAdapter::try_new(client()).expect("chat"); + assert!(chat.descriptor().supports(ProviderCapability::WebSearch)); + assert!( + !chat + .descriptor() + .supports(ProviderCapability::TextVerbosity) + ); + let anthropic = AnthropicProviderAdapter::try_new(client()).expect("anthropic"); + assert!( + !anthropic + .descriptor() + .supports(ProviderCapability::ImageInput) + ); + assert!( + !anthropic + .descriptor() + .supports(ProviderCapability::WebSearch) + ); + } + + #[test] + fn neutral_request_maps_to_existing_llm_dto_without_rebuilding_provider_parser() { + let tool = ProviderToolDefinition::try_new( + "lookup_weather", + "查询天气", + serde_json::json!({ + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + "additionalProperties": false + }), + ) + .expect("tool") + .with_strict(true); + let request = core_request() + .with_tools([tool], ProviderToolChoice::Required) + .expect("tools") + .with_web_search(true) + .with_reasoning_effort(ProviderReasoningEffort::Medium) + .with_text_verbosity(ProviderTextVerbosity::High) + .with_model("override-model") + .expect("model") + .with_max_output_tokens(2048) + .expect("max output tokens") + .with_request_timeout_ms(3000) + .expect("request timeout"); + let mapped = provider_request_to_llm_request(&request, LlmApiKind::OpenAiResponses) + .expect("mapped request"); + assert_eq!(mapped.api_kind, LlmApiKind::OpenAiResponses); + assert_eq!(mapped.model.as_deref(), Some("override-model")); + assert_eq!(mapped.max_output_tokens, Some(2048)); + assert_eq!(mapped.request_timeout_ms, Some(3000)); + assert!(mapped.enable_web_search); + assert_eq!(mapped.function_tools.len(), 1); + assert!(mapped.function_tools[0].strict); + assert_eq!(mapped.tool_choice, Some(LlmToolChoice::Required)); + } + + #[test] + fn neutral_image_source_maps_to_existing_multimodal_message() { + let request = ProviderRequest::try_new( + "request-image", + [ProviderMessage::try_new( + ProviderRole::User, + [ + ProviderContentPart::text("检查图片").expect("text"), + ProviderContentPart::image(serde_json::json!({ + "url": "data:image/png;base64,AAAA" + })) + .expect("image"), + ], + ) + .expect("message")], + ) + .expect("request"); + let mapped = provider_request_to_llm_request(&request, LlmApiKind::OpenAiResponses) + .expect("mapped request"); + assert_eq!(mapped.messages[0].content_parts.len(), 2); + assert!(matches!( + &mapped.messages[0].content_parts[1], + LlmMessageContentPart::InputImage { image_url } + if image_url == "data:image/png;base64,AAAA" + )); + } + + #[test] + fn unsupported_neutral_message_shapes_fail_at_adapter_boundary() { + let tool_message = ProviderRequest::try_new( + "request-tool", + [ProviderMessage::try_new( + ProviderRole::Tool, + [ProviderContentPart::tool_result( + "call-1", + serde_json::json!({"ok": true}), + false, + ) + .expect("tool result")], + ) + .expect("message")], + ) + .expect("request"); + let error = provider_request_to_llm_request(&tool_message, LlmApiKind::OpenAiResponses) + .expect_err("tool result is unsupported"); + assert_eq!(error.kind(), ProviderErrorKind::InvalidRequest); + + let specific = core_request() + .with_tools( + [ProviderToolDefinition::try_new( + "lookup_weather", + "查询天气", + serde_json::json!({"type": "object"}), + ) + .expect("tool")], + ProviderToolChoice::Specific("lookup_weather".to_string()), + ) + .expect("specific request"); + let error = provider_request_to_llm_request(&specific, LlmApiKind::OpenAiChat) + .expect_err("specific tool is unsupported"); + assert_eq!(error.kind(), ProviderErrorKind::InvalidRequest); + } + + #[test] + fn existing_llm_response_maps_to_neutral_response_identity() { + let response = LlmRunResponse { + provider: LlmProvider::OpenAiCompatible, + model: "model-1".to_string(), + text: "完成".to_string(), + finish_reason: Some("stop".to_string()), + response_id: Some("upstream-response".to_string()), + usage: Some(LlmTokenUsage { + prompt_tokens: 7, + completion_tokens: 3, + total_tokens: 10, + }), + tool_calls: vec![LlmToolCall { + id: "call-1".to_string(), + name: "lookup_weather".to_string(), + arguments: r#"{"city":"北京"}"#.to_string(), + }], + }; + let mapped = + llm_response_to_provider_response("request-1", response).expect("mapped response"); + assert_eq!(mapped.request_id(), "request-1"); + assert_eq!(mapped.model(), "model-1"); + assert_eq!(mapped.tool_calls()[0].arguments(), r#"{"city":"北京"}"#); + assert_eq!(mapped.response_id(), Some("upstream-response")); + assert_eq!(mapped.usage().expect("usage").total_tokens(), 10); + } + + #[test] + fn existing_llm_request_round_trips_through_neutral_contract() { + let request = LlmRunRequest::single_turn("系统", "用户") + .with_model("round-trip-model") + .with_max_output_tokens(512) + .with_request_timeout_ms(2_000) + .with_response_reasoning_effort(LlmResponseReasoningEffort::High) + .with_function_tools(vec![ + LlmFunctionTool::new( + "lookup_weather", + "查询天气", + serde_json::json!({"type": "object", "properties": {}}), + ) + .with_strict(true), + ]) + .with_tool_choice(LlmToolChoice::Required); + let neutral = + provider_request_from_llm_request("round-trip", request).expect("neutral request"); + let mapped = provider_request_to_llm_request(&neutral, LlmApiKind::OpenAiResponses) + .expect("mapped request"); + assert_eq!(mapped.model.as_deref(), Some("round-trip-model")); + assert_eq!(mapped.max_output_tokens, Some(512)); + assert_eq!(mapped.request_timeout_ms, Some(2_000)); + assert_eq!(mapped.tool_choice, Some(LlmToolChoice::Required)); + assert!(mapped.function_tools[0].strict); + assert!( + mapped + .messages + .iter() + .all(|message| message.content_parts.is_empty()) + ); + } + + #[test] + fn llm_error_kinds_map_to_stable_provider_error_kinds() { + for (error, expected) in [ + ( + crate::LlmError::InvalidRequest("bad".to_string()), + ProviderErrorKind::InvalidRequest, + ), + ( + crate::LlmError::Timeout { attempts: 1 }, + ProviderErrorKind::Timeout, + ), + ( + crate::LlmError::Connectivity { + attempts: 1, + message: "offline".to_string(), + }, + ProviderErrorKind::Connectivity, + ), + ( + crate::LlmError::Upstream { + status_code: 429, + message: "limited".to_string(), + }, + ProviderErrorKind::Upstream, + ), + ( + crate::LlmError::StreamUnavailable, + ProviderErrorKind::StreamUnavailable, + ), + ( + crate::LlmError::EmptyResponse, + ProviderErrorKind::EmptyResponse, + ), + ( + crate::LlmError::Deserialize("bad json".to_string()), + ProviderErrorKind::Deserialize, + ), + ] { + assert_eq!(map_llm_error(error).kind(), expected); + } + assert_eq!( + map_llm_error(crate::LlmError::Timeout { attempts: 3 }).attempts(), + Some(3) + ); + assert_eq!( + map_llm_error(crate::LlmError::Upstream { + status_code: 429, + message: "limited".to_string(), + }) + .status_code(), + Some(429) + ); + } + + struct RecordingSink(Arc>>); + + impl ProviderStreamSink for RecordingSink { + fn emit(&mut self, event: ProviderStreamEvent) -> Result<(), ProviderError> { + self.0.lock().expect("stream events").push(event); + Ok(()) + } + } + + #[tokio::test] + async fn registry_reaches_existing_http_and_stream_parsers() { + let (base_url, server) = spawn_registry_loopback_server(); + let client = LlmClient::new( + LlmConfig::new( + LlmProvider::OpenAiCompatible, + base_url, + "loopback-key".to_string(), + "loopback-model".to_string(), + 2_000, + 0, + 1, + ) + .expect("config"), + ) + .expect("client"); + let (registry, target) = build_platform_llm_provider_registry_for_api_kind( + "loopback-instance", + client, + LlmApiKind::OpenAiChat, + ) + .expect("registry"); + let request = |request_id| { + provider_request_from_llm_request( + request_id, + LlmRunRequest::single_turn("system", "user").with_openai_chat(), + ) + .expect("neutral request") + }; + + let response = registry + .invoke(&target, request("loopback-invoke")) + .await + .expect("invoke response"); + assert_eq!(response.content().len(), 1); + assert_eq!(response.response_id(), Some("loopback-non-stream")); + + let events = Arc::new(Mutex::new(Vec::new())); + let response = registry + .stream( + &target, + request("loopback-stream"), + Box::new(RecordingSink(Arc::clone(&events))), + ) + .await + .expect("stream response"); + assert_eq!(response.response_id(), Some("loopback-stream")); + let events = events.lock().expect("events"); + assert!(events.iter().any(|event| matches!( + event, + ProviderStreamEvent::TextDelta { accumulated_text, .. } + if accumulated_text == "可用" + ))); + server.join().expect("loopback server"); + } + + fn spawn_registry_loopback_server() -> (String, std::thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("loopback listener"); + let address = listener.local_addr().expect("loopback address"); + let server = std::thread::spawn(move || { + for _ in 0..2 { + let (mut socket, _) = listener.accept().expect("accept request"); + socket + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("read timeout"); + let request = read_http_request(&mut socket); + assert!(request.starts_with("POST /chat/completions HTTP/1.1")); + let streaming = request.contains(r#""stream":true"#); + let (content_type, body) = if streaming { + ( + "text/event-stream", + concat!( + "data: {\"id\":\"loopback-stream\",\"choices\":[{\"delta\":{\"content\":\"可\"},\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"loopback-stream\",\"choices\":[{\"delta\":{\"content\":\"用\"},\"finish_reason\":\"stop\"}]}\n\n", + "data: [DONE]\n\n" + ), + ) + } else { + ( + "application/json", + r#"{"id":"loopback-non-stream","model":"loopback-model","choices":[{"message":{"content":"可用"},"finish_reason":"stop"}]}"#, + ) + }; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nX-Request-Id: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + if streaming { + "loopback-stream" + } else { + "loopback-non-stream" + }, + body.len() + ); + socket + .write_all(response.as_bytes()) + .expect("write response"); + } + }); + (format!("http://{address}"), server) + } + + fn read_http_request(socket: &mut std::net::TcpStream) -> String { + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 2048]; + loop { + let count = socket.read(&mut buffer).expect("read request"); + if count == 0 { + break; + } + bytes.extend_from_slice(&buffer[..count]); + let Some(header_end) = bytes.windows(4).position(|item| item == b"\r\n\r\n") else { + continue; + }; + let header_end = header_end + 4; + let headers = String::from_utf8_lossy(&bytes[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .and_then(|value| value.trim().parse::().ok()) + }) + .unwrap_or(0); + if bytes.len() >= header_end + content_length { + break; + } + } + String::from_utf8(bytes).expect("utf8 request") + } +}