diff --git a/docs/architecture/agent-runtime-services-design.md b/docs/architecture/agent-runtime-services-design.md index 618173f083..fe94c4e1e3 100644 --- a/docs/architecture/agent-runtime-services-design.md +++ b/docs/architecture/agent-runtime-services-design.md @@ -11,9 +11,10 @@ CLI Agent 体验边界见 [`cli-product-line-design.md`](cli-product-line-design 本文中的接口片段只说明依赖方向和职责,不自动构成当前 API 或实施承诺。当前接口名称、字段和消费方以代码为准; 新增公共类型前必须有真实生产调用方、版本边界和验证路径。现有 Agent Runtime SDK 仍是 v1 preview,CLI、ACP、 -Desktop 仍保留 `bitfun-core/product-full` 兼容 owner。CLI 与 CLI 托管的 ACP server 已消费各自的产品组装结果和 -SDK 端口,但这不等于协调器、调度器、持久化或工具执行 owner 已迁移;ACP 的完整持久化历史、模型/模式、MCP、 -客户端路径与 Desktop 入口仍保留明确的兼容边界。 +Desktop 仍保留 `bitfun-core/product-full` 兼容 owner。CLI 与 CLI 托管的 ACP server 已消费各自的产品组装结果; +Desktop 主交互只消费由现有 Core owner 构造的窄口径 SDK 门面,尚未组装完整 Desktop profile。这些接入都不等于 +协调器、调度器、持久化或工具执行 owner 已迁移;ACP 的完整持久化历史、模型/模式、MCP、客户端路径与 Desktop +的其余入口仍保留明确的兼容边界。 阅读路径:第 1 节确认 SDK、内核、产品特性、扩展接口和 crate 边界;第 2-3 节说明稳定接口、 运行时服务、内核、工具和工作流;第 4 节说明产品组装与扩展注册;第 5 节作为质量保护和 @@ -704,8 +705,9 @@ pub struct HarnessExecutionContext { 通过 `assemble(ProductAssemblyInput)` 产生 `ProductRuntimeParts`,本文件不再为它定义第二套目标接口。 当前 CLI 与 CLI 托管的 ACP server 已使用类型化 `RuntimeServices`,分别以 `DeliveryProfile::Cli` 和 -`DeliveryProfile::Acp` 构造 `ProductRuntimeParts`。CLI 通过一个调用级上下文把 -Agent Runtime SDK、Harness、能力注册、调用级权限和 Agentic 事件广播交给 TUI、Exec、Session、Usage 与 +`DeliveryProfile::Acp` 构造 `ProductRuntimeParts`。Desktop 主交互直接从现有协调器和调度器端口构造窄口径 +Agent Runtime SDK 门面,不注册未实现的 `RuntimeServices` 能力,也不宣称完整 Desktop profile 可用。CLI 通过 +一个调用级上下文把 Agent Runtime SDK、Harness、能力注册、调用级权限和 Agentic 事件广播交给 TUI、Exec、Session、Usage 与 交互模式下的 Peer Host。SDK 已承接会话创建/列举/删除/恢复、类型化转录读取、轮次提交/取消,以及 CLI/TUI 的工具确认、拒绝和用户问题回答;Peer Host 当前只消费同一 SDK 的工具确认与拒绝。SDK v1 尚未覆盖的固定 ID 会话创建、模型更新、分支、用量、快照和 @@ -715,8 +717,10 @@ Core 的 Network、Git 和 MCP Catalog 当前仍含兼容 marker,因此该诊 该切换仍是 `product-full` 兼容组装,不是 owner 迁移。协调器、调度器、持久化、工具管线和 Agentic Event Queue 仍由 Core 唯一持有;CLI 与 ACP 不复制这些状态。ACP 服务端通过 SDK 处理会话创建/列举、轮次、取消、交互响应和事件订阅, 但完整持久化历史回放、模型/模式、模型目录/配置和 MCP 仍走单一 Core 兼容门面;ACP stdio、连接和协议投影仍在 -`interfaces/acp`。Desktop 尚未接入组装结果,Server 仅提供健康检查、信息与 ping 路由。未接入入口的 profile、 -枚举分支和单元测试仍不能证明对应产品形态可用。 +`interfaces/acp`。Desktop 复用同一 Core owner 构造一个窄口径 SDK 门面,主界面的轮次提交/取消、工具确认/拒绝和 +用户问题回答已通过该门面;会话 CRUD/恢复视图、模型、MCP、MiniApp、Cron、远程连接、Tauri 窗口与平台资源 +仍保留在 Desktop/Core 兼容入口。Server 仅提供健康检查、信息与 ping 路由。未接入入口的 profile、枚举分支和 +单元测试仍不能证明对应产品形态可用。 职责: @@ -1063,14 +1067,19 @@ Product 测试: 全局有界 broadcast 继续服务 CLI/TUI,活动 ACP prompt 使用固定容量、仅接收本会话事件的临时通道,并在最后一个订阅者 释放时立即回收。CLI 宿主进程只保留一个旧消费队列排空任务,不增加每会话转发任务或第二套事件 schema。ACP 组装入口使用独立的 轮次提交适配器,在会话锁内拒绝忙碌会话的第二个 prompt;CLI/TUI、Desktop 和远程入口的既有排队策略不变。 +- Desktop 主交互已从现有协调器与调度器端口构造窄口径 Agent Runtime SDK 门面;Tauri 命令只负责保留现有 DTO、 + 补全图片载荷并映射类型化请求。ACP 取消分支继续优先处理,Desktop 平台生命周期和未迁移服务不进入 SDK,也不创建 + 第二套 owner 或事件 schema。完整 `DeliveryProfile::Desktop` 必须等待真实 Desktop `RuntimeServices` 提供方和事件 + 消费/投影路径齐备后再组装;当前切片不注册 `Events` 能力,也不以失败占位端口或无人消费的内存通道伪装可用。 仍需完成: - 把 embedded relay 的 TCP bind、静态 fallback 和任务生命周期移出 assembly;room/device 状态、account/sync 存储、asset store 与 HTTP/WebSocket router 已归属 `services/relay-service`,Cargo 反向边已删除并由通用边界检查保护。 - 继续缩小 CLI 的 Core 兼容门面;只有稳定端口、真实生产调用方和行为等价测试齐备时才迁移 owner。 -- 继续按真实复用需求缩小 ACP 的完整持久化历史、模型/模式、MCP 与客户端兼容路径;Desktop 入口另行接入产品组装并 - 证明行为等价。ACP 生命周期和 Desktop 平台资源仍留在各自入口。 +- 继续按真实复用需求缩小 ACP 的完整持久化历史、模型/模式、MCP 与客户端兼容路径;Desktop 仅继续迁移存在稳定端口和 + 行为等价测试的入口。完整 Desktop 产品组装需先补齐真实必需服务与事件消费路径,不以桩实现提前声明能力;ACP 生命周期 + 和 Desktop 平台资源仍留在各自入口。 - 为 Agent Runtime SDK 增加至少一个非 `bitfun-core` 的真实嵌入方;预览 facade 和单元测试不等于外部可用 SDK。 - 仅在真实端到端切片中接入插件主机;外部插件先转换为类型化工具、Hook、事件、权限请求或诊断, 不把生态对象带入 Agent Runtime。 diff --git a/docs/plans/product-architecture-evolution-plan.md b/docs/plans/product-architecture-evolution-plan.md index aeddfa2a76..a6f7bdd9d4 100644 --- a/docs/plans/product-architecture-evolution-plan.md +++ b/docs/plans/product-architecture-evolution-plan.md @@ -32,7 +32,7 @@ | CLI/TUI | `ShortcutsConfig` 已加载但真实按键分发仍硬编码;Slash、Palette、帮助和执行不是同一来源 | 先统一宿主 action 声明和键位解析,不重写 renderer | | OpenCode | 只有来源确认和静态工具名预览,没有 JS/TS `execute` 或真实工具注册 | 先做一个无外部依赖、遵循官方公开契约的 standalone custom tool 端到端样例 | | HarmonyOS PC | 未来平台目标,当前未实现 | 目标、问题、风险和旧设计闭环见平台规约;具体工作后续分别立项 | -| 入口迁移 | CLI 已消费 Runtime Parts,但 CLI/ACP/Desktop 仍直接依赖 `bitfun-core/product-full` | 保持单一 owner,按 CLI → ACP → Desktop 的独立行为等价切片推进 | +| 入口迁移 | CLI 已消费 Runtime Parts;Desktop 主交互消费由现有 owner 构造的窄口径 Runtime SDK 门面,完整 Desktop Runtime Parts 尚未组装;CLI/ACP/Desktop 仍按需保留 `bitfun-core/product-full` 兼容 owner | 保持单一 owner,按真实端口逐项迁移,不批量删除兼容门面或用桩服务提前声明能力 | ## 3. 工作流一:边界与依赖可信 @@ -104,7 +104,9 @@ HarmonyOS 手机 Remote App 不在该平台执行范围内。 更新、分支、用量、快照和持久化维护等操作在补齐端口与行为测试前继续由 现有单一兼容路径转发。 - ACP:CLI 行为稳定后单独迁移会话、权限和事件投影;ACP stdio 生命周期留在接口入口。 -- Desktop:按服务簇迁移,Tauri、窗口和 app-local 资源留在 Desktop。 +- Desktop:主界面轮次提交/取消、工具确认/拒绝和用户问题回答已通过现有协调器与调度器端口构造的窄口径 SDK 门面; + 完整产品组装需等待真实必需服务与事件消费路径。会话 CRUD/恢复视图、模型、MCP、MiniApp、Cron、远程连接、 + Tauri 窗口和 app-local 资源仍留在原入口。 - SDK/Server/Remote:只有真实独立调用方出现后才增加;枚举、空计划或测试替身不构成发布能力。 每个入口都必须证明生产行为、错误、取消和恢复等价后再删除旧路径。迁移期间不能在新旧路径同时写同一状态, @@ -138,7 +140,7 @@ native/translated/degraded;未实现能力保持 unsupported/experimental; | CLI action/快捷键 | 当前 CLI 行为和配置 fixture | OpenCode standalone tool、入口 API 迁移 | | OpenCode standalone tool | OpenCode adapter 内的单一 source resolver、冻结版本/样例 | CLI action | | OpenCode package/Hook/TUI | 前一切片稳定且有真实阻塞样例;TUI action 另等 action registry | 入口迁移 | -| ACP/Desktop 迁移 | 前一入口行为等价 | OpenCode 深兼容 | +| Desktop 主交互迁移 | 已完成窄口径 SDK 门面接入;完整 Desktop profile 与剩余入口需分别证明服务可用和行为等价 | ACP 与其他非扩展架构工作 | | 一个能力对外复用 | 现有能力 owner、具名试点/用例/验收 owner、冻结宿主版本和最小权限/取消语义 | OpenCode standalone tool、单入口迁移 | 这些依赖表示开始条件,不要求放在同一个 PR,也不形成统一大版本。 diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index 1d83639afc..a75bfc2cd9 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -20,6 +20,7 @@ serde_json = { workspace = true } [dependencies] # Internal crates bitfun-core = { path = "../../crates/assembly/core", default-features = false, features = ["product-full"] } +bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime" } bitfun-product-domains = { path = "../../crates/contracts/product-domains", default-features = false } bitfun-services-integrations = { path = "../../crates/services/services-integrations", default-features = false, features = ["canvas-runtime"] } bitfun-agent-tools = { path = "../../crates/execution/tool-contracts" } diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 4f17595936..aca03d414f 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -9,7 +9,12 @@ use tauri::{AppHandle, State}; use crate::api::app_state::AppState; use crate::api::session_storage_path::desktop_effective_session_storage_path; +use crate::runtime::DesktopRuntimeContext; use crate::startup_trace::DesktopStartupTrace; +use bitfun_agent_runtime::sdk::{ + AgentDialogTurnRequest, AgentInputAttachment, AgentSubmissionSource, + AgentToolConfirmationRequest, AgentToolRejectionRequest, AgentTurnCancellationRequest, +}; use bitfun_core::agentic::agents::AgentSource; use bitfun_core::agentic::coordination::{ AssistantBootstrapBlockReason, AssistantBootstrapEnsureOutcome, AssistantBootstrapSkipReason, @@ -989,10 +994,26 @@ pub async fn ensure_coordinator_session( #[tauri::command] pub async fn start_dialog_turn( _app: AppHandle, - _coordinator: State<'_, Arc>, - scheduler: State<'_, Arc>, + runtime: State<'_, DesktopRuntimeContext>, request: StartDialogTurnRequest, ) -> Result { + let runtime_request = desktop_dialog_turn_request(request)?; + + runtime + .agent_runtime() + .submit_dialog_turn(runtime_request) + .await + .map_err(|error| format!("Failed to start dialog turn: {}", error.into_message()))?; + + Ok(StartDialogTurnResponse { + success: true, + message: "Dialog turn started".to_string(), + }) +} + +fn desktop_dialog_turn_request( + request: StartDialogTurnRequest, +) -> Result { let StartDialogTurnRequest { session_id, user_input, @@ -1007,40 +1028,68 @@ pub async fn start_dialog_turn( } = request; let policy = DialogSubmissionPolicy::for_source(DialogTriggerSource::DesktopUi); - let resolved_images = if let Some(image_contexts) = image_contexts - .as_ref() - .filter(|images| !images.is_empty()) - .cloned() - { - Some(resolve_missing_image_payloads(image_contexts)?) - } else { - None + let attachments = match image_contexts.filter(|images| !images.is_empty()) { + Some(images) => resolve_missing_image_payloads(images)? + .into_iter() + .map(desktop_image_attachment) + .collect(), + None => Vec::new(), }; + let metadata = desktop_user_message_metadata(user_message_metadata); - scheduler - .submit( - session_id, - user_input, - original_user_input, - turn_id, - agent_type, - workspace_path, - remote_connection_id, - remote_ssh_host, - policy, - None, - user_message_metadata, - resolved_images, - ) - .await - .map_err(|e| format!("Failed to start dialog turn: {}", e))?; - - Ok(StartDialogTurnResponse { - success: true, - message: "Dialog turn started".to_string(), + Ok(AgentDialogTurnRequest { + session_id, + message: user_input, + original_message: original_user_input, + turn_id, + agent_type, + workspace_path, + remote_connection_id, + remote_ssh_host, + policy, + reply_route: None, + prepended_reminders: Vec::new(), + attachments, + metadata, }) } +fn desktop_user_message_metadata( + metadata: Option, +) -> serde_json::Map { + match metadata { + Some(serde_json::Value::Object(metadata)) => metadata, + Some(metadata) => serde_json::Map::from_iter([("raw_metadata".to_string(), metadata)]), + None => serde_json::Map::new(), + } +} + +fn desktop_image_attachment(image: ImageContextData) -> AgentInputAttachment { + let mut metadata = serde_json::Map::new(); + if let Some(image_path) = image.image_path { + metadata.insert( + "imagePath".to_string(), + serde_json::Value::String(image_path), + ); + } + if let Some(data_url) = image.data_url { + metadata.insert("dataUrl".to_string(), serde_json::Value::String(data_url)); + } + metadata.insert( + "mimeType".to_string(), + serde_json::Value::String(image.mime_type), + ); + if let Some(image_metadata) = image.metadata { + metadata.insert("metadata".to_string(), image_metadata); + } + + AgentInputAttachment { + kind: "remote_image".to_string(), + id: image.id, + metadata, + } +} + #[tauri::command] pub async fn compact_session( coordinator: State<'_, Arc>, @@ -1573,7 +1622,7 @@ fn resolve_missing_image_payloads( #[tauri::command] pub async fn cancel_dialog_turn( - coordinator: State<'_, Arc>, + runtime: State<'_, DesktopRuntimeContext>, app_state: State<'_, AppState>, request: CancelDialogTurnRequest, ) -> Result<(), String> { @@ -1596,8 +1645,16 @@ pub async fn cancel_dialog_turn( } } - coordinator - .cancel_dialog_turn(&request.session_id, &request.dialog_turn_id) + runtime + .agent_runtime() + .cancel_turn(AgentTurnCancellationRequest { + session_id: request.session_id.clone(), + turn_id: Some(request.dialog_turn_id.clone()), + source: Some(AgentSubmissionSource::DesktopUi), + requester_session_id: None, + reason: None, + wait_timeout_ms: None, + }) .await .map_err(|e| { log::error!( @@ -1606,8 +1663,9 @@ pub async fn cancel_dialog_turn( request.dialog_turn_id, e ); - format!("Failed to cancel dialog turn: {}", e) + format!("Failed to cancel dialog turn: {}", e.into_message()) }) + .map(|_| ()) } #[tauri::command] @@ -2242,28 +2300,36 @@ pub async fn list_sessions( #[tauri::command] pub async fn confirm_tool_execution( - coordinator: State<'_, Arc>, + runtime: State<'_, DesktopRuntimeContext>, request: ConfirmToolRequest, ) -> Result<(), String> { - coordinator - .confirm_tool(&request.tool_id, request.updated_input) + runtime + .agent_runtime() + .confirm_tool(AgentToolConfirmationRequest { + tool_id: request.tool_id, + updated_input: request.updated_input, + }) .await - .map_err(|e| format!("Confirm tool failed: {}", e)) + .map_err(|error| format!("Confirm tool failed: {}", error.into_message())) } #[tauri::command] pub async fn reject_tool_execution( - coordinator: State<'_, Arc>, + runtime: State<'_, DesktopRuntimeContext>, request: RejectToolRequest, ) -> Result<(), String> { let reason = request .reason .unwrap_or_else(|| "User rejected".to_string()); - coordinator - .reject_tool(&request.tool_id, reason) + runtime + .agent_runtime() + .reject_tool(AgentToolRejectionRequest { + tool_id: request.tool_id, + reason, + }) .await - .map_err(|e| format!("Reject tool failed: {}", e)) + .map_err(|error| format!("Reject tool failed: {}", error.into_message())) } #[tauri::command] @@ -2433,6 +2499,156 @@ mod tests { }; use serde_json::json; + #[test] + fn desktop_dialog_turn_request_preserves_runtime_contract() { + let request: StartDialogTurnRequest = serde_json::from_value(json!({ + "sessionId": "session-1", + "userInput": "resolved input", + "originalUserInput": "original input", + "agentType": "agentic", + "workspacePath": "/workspace/project", + "remoteConnectionId": "connection-1", + "remoteSshHost": "host-1", + "turnId": "turn-1", + "imageContexts": [{ + "id": "image-1", + "image_path": "/workspace/clip.png", + "data_url": "data:image/png;base64,abc", + "mime_type": "image/png", + "metadata": { + "name": "clip.png", + "source": "upload" + } + }], + "userMessageMetadata": { + "surface": "flow_chat", + "requestId": "request-1" + } + })) + .expect("current Tauri request shape"); + + let runtime_request = + desktop_dialog_turn_request(request).expect("Desktop runtime request"); + + assert_eq!(runtime_request.session_id, "session-1"); + assert_eq!(runtime_request.message, "resolved input"); + assert_eq!( + runtime_request.original_message.as_deref(), + Some("original input") + ); + assert_eq!(runtime_request.turn_id.as_deref(), Some("turn-1")); + assert_eq!(runtime_request.agent_type, "agentic"); + assert_eq!( + runtime_request.workspace_path.as_deref(), + Some("/workspace/project") + ); + assert_eq!( + runtime_request.remote_connection_id.as_deref(), + Some("connection-1") + ); + assert_eq!(runtime_request.remote_ssh_host.as_deref(), Some("host-1")); + assert_eq!( + runtime_request.policy.trigger_source, + AgentSubmissionSource::DesktopUi + ); + assert_eq!( + runtime_request.policy, + DialogSubmissionPolicy::for_source(DialogTriggerSource::DesktopUi) + ); + assert!(runtime_request.reply_route.is_none()); + assert!(runtime_request.prepended_reminders.is_empty()); + assert_eq!(runtime_request.attachments.len(), 1); + let attachment = &runtime_request.attachments[0]; + assert_eq!(attachment.kind, "remote_image"); + assert_eq!(attachment.id, "image-1"); + assert_eq!( + attachment.metadata.get("imagePath"), + Some(&json!("/workspace/clip.png")) + ); + assert_eq!( + attachment.metadata.get("dataUrl"), + Some(&json!("data:image/png;base64,abc")) + ); + assert_eq!( + attachment.metadata.get("mimeType"), + Some(&json!("image/png")) + ); + assert_eq!( + attachment + .metadata + .get("metadata") + .and_then(|value| value.get("source")), + Some(&json!("upload")) + ); + assert_eq!( + runtime_request.metadata.get("surface"), + Some(&json!("flow_chat")) + ); + assert_eq!( + runtime_request.metadata.get("requestId"), + Some(&json!("request-1")) + ); + } + + #[test] + fn desktop_interaction_dtos_keep_existing_camel_case_shape() { + let cancel: CancelDialogTurnRequest = serde_json::from_value(json!({ + "sessionId": "session-1", + "dialogTurnId": "turn-1" + })) + .expect("cancel request"); + let confirm: ConfirmToolRequest = serde_json::from_value(json!({ + "sessionId": "session-1", + "toolId": "tool-1", + "updatedInput": { "path": "updated.txt" } + })) + .expect("confirm request"); + let reject: RejectToolRequest = serde_json::from_value(json!({ + "sessionId": "session-1", + "toolId": "tool-1", + "reason": "Use a read-only path" + })) + .expect("reject request"); + + assert_eq!(cancel.session_id, "session-1"); + assert_eq!(cancel.dialog_turn_id, "turn-1"); + assert_eq!(confirm.tool_id, "tool-1"); + assert_eq!( + confirm.updated_input, + Some(json!({ "path": "updated.txt" })) + ); + assert_eq!(reject.reason.as_deref(), Some("Use a read-only path")); + assert_eq!( + serde_json::to_value(StartDialogTurnResponse { + success: true, + message: "Dialog turn started".to_string(), + }) + .expect("response"), + json!({ + "success": true, + "message": "Dialog turn started" + }) + ); + } + + #[test] + fn desktop_dialog_turn_accepts_and_normalizes_legacy_non_object_metadata() { + let request = serde_json::from_value::(json!({ + "sessionId": "session-1", + "userInput": "hello", + "agentType": "agentic", + "userMessageMetadata": "not-an-object" + })) + .expect("legacy metadata request"); + let runtime_request = + desktop_dialog_turn_request(request).expect("Desktop runtime request"); + + assert_eq!( + runtime_request.metadata.get("raw_metadata"), + Some(&json!("not-an-object")) + ); + } + fn idempotent_create_request() -> CreateSessionRequest { CreateSessionRequest { session_id: Some("review_child_request-1".to_string()), diff --git a/src/apps/desktop/src/api/tool_api.rs b/src/apps/desktop/src/api/tool_api.rs index d0f261e51e..5ab103c701 100644 --- a/src/apps/desktop/src/api/tool_api.rs +++ b/src/apps/desktop/src/api/tool_api.rs @@ -5,7 +5,9 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; +use tauri::State; +use bitfun_agent_runtime::sdk::AgentUserAnswersRequest; use bitfun_core::agentic::{ tools::framework::ToolUseContext, tools::{get_all_tools, get_readonly_tools}, @@ -18,6 +20,8 @@ use bitfun_core::service::remote_ssh::workspace_state::{ }; use bitfun_core::util::elapsed_ms_u64; +use crate::runtime::DesktopRuntimeContext; + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ToolExecutionRequest { @@ -429,19 +433,49 @@ pub async fn execute_tool(request: ToolExecutionRequest) -> Result, tool_id: String, answers: serde_json::Value, ) -> Result<(), String> { - use bitfun_core::agentic::tools::user_input_manager::get_user_input_manager; - let manager = get_user_input_manager(); + runtime + .agent_runtime() + .submit_user_answers(AgentUserAnswersRequest { + tool_id: tool_id.clone(), + answers, + }) + .await + .map_err(|error| { + let error = desktop_user_answers_error_message(error.into_message()); + error!( + "Failed to send user answer: tool_id={}, error={}", + tool_id, error + ); + error + }) +} - manager.send_answer(&tool_id, answers).map_err(|e| { - error!( - "Failed to send user answer: tool_id={}, error={}", - tool_id, e - ); - e.to_string() - })?; +fn desktop_user_answers_error_message(message: String) -> String { + message + .strip_prefix("Tool error: ") + .unwrap_or(&message) + .to_string() +} - Ok(()) +#[cfg(test)] +mod tests { + use super::desktop_user_answers_error_message; + + #[test] + fn user_answers_errors_keep_the_existing_desktop_text() { + assert_eq!( + desktop_user_answers_error_message( + "Tool error: Waiting channel not found: tool-1".to_string(), + ), + "Waiting channel not found: tool-1" + ); + assert_eq!( + desktop_user_answers_error_message("Runtime unavailable".to_string()), + "Runtime unavailable" + ); + } } diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index d47daafd54..d9cab24c84 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -6,6 +6,7 @@ pub mod computer_use; pub mod crash_diagnostics; pub mod logging; pub mod macos_menubar; +pub mod runtime; pub mod startup_trace; pub mod theme; pub mod tray; @@ -384,6 +385,22 @@ pub async fn run() { startup_timings.record_elapsed("initialize_app_state", step_started); startup_trace.record_elapsed_step("native_pre_tauri", "initialize_app_state", step_started); + let step_started = Instant::now(); + let desktop_runtime = + match runtime::DesktopRuntimeContext::build(coordinator.clone(), scheduler.clone()) { + Ok(runtime) => runtime, + Err(error) => { + log::error!("Failed to initialize Desktop Agent Runtime: {}", error); + return; + } + }; + startup_timings.record_elapsed("initialize_desktop_agent_runtime", step_started); + startup_trace.record_elapsed_step( + "native_pre_tauri", + "initialize_desktop_agent_runtime", + step_started, + ); + let coordinator_state = CoordinatorState { coordinator: coordinator.clone(), }; @@ -423,6 +440,7 @@ pub async fn run() { .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .manage(app_state) + .manage(desktop_runtime) .manage(coordinator_state) .manage(scheduler_state) .manage(path_manager) diff --git a/src/apps/desktop/src/runtime/mod.rs b/src/apps/desktop/src/runtime/mod.rs new file mode 100644 index 0000000000..5db2da7700 --- /dev/null +++ b/src/apps/desktop/src/runtime/mod.rs @@ -0,0 +1,73 @@ +use std::sync::Arc; + +use bitfun_agent_runtime::sdk::{ + AgentDialogTurnPort, AgentInteractionResponsePort, AgentRuntime, AgentRuntimeBuilder, + AgentSubmissionPort, AgentTurnCancellationPort, RuntimeBuildError, +}; +use bitfun_core::agentic::coordination::{ConversationCoordinator, DialogScheduler}; + +/// Desktop-owned access to the Agent Runtime SDK interaction facade. +/// +/// Core remains the sole owner of the coordinator, scheduler, sessions, tool +/// pipeline, and Agentic event queue. This context exposes only the interaction +/// ports used by current Tauri commands; it does not claim that the complete +/// Desktop delivery profile or its product services have been assembled. +pub struct DesktopRuntimeContext { + agent_runtime: AgentRuntime, +} + +impl DesktopRuntimeContext { + pub(crate) fn build( + coordinator: Arc, + scheduler: Arc, + ) -> Result { + let submission: Arc = coordinator.clone(); + let interaction_response: Arc = coordinator; + let dialog_turn: Arc = scheduler.clone(); + let cancellation: Arc = scheduler; + let agent_runtime = AgentRuntimeBuilder::new() + .with_submission_port(submission) + .with_dialog_turn_port(dialog_turn) + .with_cancellation_port(cancellation) + .with_interaction_response_port(interaction_response) + .build()?; + + Ok(Self { agent_runtime }) + } + + pub(crate) fn agent_runtime(&self) -> &AgentRuntime { + &self.agent_runtime + } +} + +#[cfg(test)] +mod tests { + #[test] + fn desktop_runtime_wiring_reuses_existing_core_owners() { + let runtime_source = include_str!("mod.rs"); + let coordinator_constructor = ["ConversationCoordinator", "::new"].concat(); + let scheduler_constructor = ["DialogScheduler", "::new"].concat(); + assert!(!runtime_source.contains(&coordinator_constructor)); + assert!(!runtime_source.contains(&scheduler_constructor)); + + let app_source = include_str!("../lib.rs"); + assert!(app_source.contains("DesktopRuntimeContext::build(")); + assert!(app_source.contains(".manage(desktop_runtime)")); + + assert!(runtime_source.contains("with_dialog_turn_port")); + assert!(runtime_source.contains("with_cancellation_port")); + assert!(runtime_source.contains("with_interaction_response_port")); + } + + #[test] + fn desktop_interaction_runtime_does_not_claim_unimplemented_product_services() { + let runtime_source = include_str!("mod.rs"); + let product_assembler = ["Product", "Assembler"].concat(); + let runtime_services = ["Runtime", "Services"].concat(); + let desktop_services_provider = ["DesktopRuntime", "ServicesProvider"].concat(); + + assert!(!runtime_source.contains(&product_assembler)); + assert!(!runtime_source.contains(&runtime_services)); + assert!(!runtime_source.contains(&desktop_services_provider)); + } +}