From 1a76b2aded8e63b95aba6e52169a601d0343f6c2 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 31 Oct 2026 17:24:14 +0800 Subject: [PATCH 1/4] fix(remote-connect): route bot model, assistant, and resume calls to the selected device With a remote device selected, the IM bot still read and changed models through this host's session store ("Session storage is unavailable"), told assistant-mode users that no assistants existed, created assistant sessions in the device's open project workspace (or failed when none was open), and resumed sessions without the transcript preview. Route /model through get_model_catalog and set_session_model on the device, list and select the device's assistants, choose session workspaces per mode (selected assistant, selected project, then the device's open project), let the device place an unselected assistant session in its default assistant, remember where new sessions land, and preview resumed sessions from the device transcript. Remote error replies now surface as errors. --- .../remote_connect/bot/command_router.rs | 686 +++++++++++++++++- .../src/remote_connect/bot/locale.rs | 4 + 2 files changed, 653 insertions(+), 37 deletions(-) diff --git a/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs b/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs index 69b944beb4..a3fffd88e5 100644 --- a/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs +++ b/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs @@ -388,8 +388,14 @@ async fn start_switch_model(state: &mut BotChatState, s: &'static BotStrings) -> // A bot menu reads the configured models and the session selection only: the // models.dev bodies belong to the host's own settings surface, and a chat // controller never offers provider or reasoning-catalog editing. - let catalog = match CoreServiceAgentRuntime::load_remote_model_catalog(Some(&session_id)).await - { + // The session lives on the routed device, so its catalog and selection + // are read there; this host's session store does not know the session. + let catalog = if state.active_remote_device.is_some() { + load_remote_device_model_catalog(state, &session_id).await + } else { + CoreServiceAgentRuntime::load_remote_model_catalog(Some(&session_id)).await + }; + let catalog = match catalog { Ok(c) => c, Err(e) => { return result_from_menu( @@ -421,12 +427,47 @@ async fn start_switch_model(state: &mut BotChatState, s: &'static BotStrings) -> result_from_menu(state, view) } +async fn load_remote_device_model_catalog( + state: &BotChatState, + session_id: &str, +) -> Result { + use crate::service::remote_connect::remote_server::RemoteResponse; + let command = serde_json::json!({ "cmd": "get_model_catalog", "session_id": session_id }); + match exec_remote_command(state, &command).await? { + RemoteResponse::ModelCatalog { catalog } => Ok(catalog), + _ => Err("Unexpected remote model catalog response".to_string()), + } +} + +fn model_applied_result( + state: &mut BotChatState, + model_name: &str, + s: &'static BotStrings, +) -> HandleResult { + let body = format!("{}{}", s.switch_model_applied_prefix, model_name); + let view = main_menu_view(state, s).with_body(body); + result_from_menu(state, view) +} + +fn model_failed_result( + state: &mut BotChatState, + error: impl std::fmt::Display, + s: &'static BotStrings, +) -> HandleResult { + result_from_menu( + state, + MenuView::plain(format!("{}{error}", s.switch_model_failed_prefix)) + .with_items(vec![MenuItem::default(s.item_back, "/menu")]), + ) +} + async fn select_model( state: &mut BotChatState, model_id: &str, model_name: &str, s: &'static BotStrings, ) -> HandleResult { + use crate::service::remote_connect::remote_server::RemoteResponse; use crate::service_agent_runtime::CoreServiceAgentRuntime; let session_id = match state.current_session_id.clone() { Some(id) => id, @@ -435,6 +476,23 @@ async fn select_model( } }; + if state.active_remote_device.is_some() { + // `reasoning_preset` is omitted on purpose: an explicit null clears + // the session's preset, while the local path leaves it unchanged. + let command = serde_json::json!({ + "cmd": "set_session_model", + "session_id": session_id, + "model_id": model_id, + }); + return match exec_remote_command(state, &command).await { + Ok(RemoteResponse::SessionModelUpdated { .. }) => { + model_applied_result(state, model_name, s) + } + Ok(_) => model_failed_result(state, "Unexpected remote model update response", s), + Err(error) => model_failed_result(state, error, s), + }; + } + let coordinator = match crate::agentic::coordination::get_global_coordinator() { Some(c) => c, None => { @@ -466,17 +524,8 @@ async fn select_model( ) .await { - Ok(_) => { - let body = format!("{}{}", s.switch_model_applied_prefix, model_name); - let mut view = main_menu_view(state, s); - view = view.with_body(body); - result_from_menu(state, view) - } - Err(e) => result_from_menu( - state, - MenuView::plain(format!("{}{e}", s.switch_model_failed_prefix)) - .with_items(vec![MenuItem::default(s.item_back, "/menu")]), - ), + Ok(_) => model_applied_result(state, model_name, s), + Err(e) => model_failed_result(state, e, s), } } @@ -981,6 +1030,28 @@ async fn exec_remote_rpc(state: &BotChatState, command_json: &str) -> Result Result { + let raw = exec_remote_rpc(state, &command.to_string()).await?; + parse_remote_response(&raw) +} + +fn parse_remote_response( + raw: &str, +) -> Result { + use crate::service::remote_connect::remote_server::RemoteResponse; + match serde_json::from_str::(raw) { + Ok(RemoteResponse::Error { message }) => Err(message), + Ok(response) => Ok(response), + Err(error) => Err(format!("Unexpected remote device response: {error}")), + } +} + fn menu_or_welcome(state: &mut BotChatState, s: &'static BotStrings) -> HandleResult { if state.paired { result_from_menu(state, main_menu_view(state, s)) @@ -1204,11 +1275,7 @@ async fn start_switch(state: &mut BotChatState, s: &'static BotStrings) -> Handl // Remote device catalogs never fall back to this host's workspaces. if state.active_remote_device.is_some() { if state.display_mode != BotDisplayMode::Pro { - return result_from_menu( - state, - MenuView::plain(s.switch_no_assistants) - .with_items(vec![MenuItem::default(s.item_back, "/menu")]), - ); + return start_remote_assistant_switch(state, s).await; } return match load_remote_workspace_choices(state).await { Ok(options) => show_workspace_choices(state, options, s), @@ -1543,6 +1610,120 @@ async fn select_local_workspace( } } +/// Assistants on the active remote device, keyed by workspace ID. A legacy +/// device that reports no ID is keyed by its path so it stays selectable. +async fn load_remote_assistants( + state: &BotChatState, +) -> Result, String> { + use crate::service::remote_connect::remote_server::RemoteResponse; + match exec_remote_command(state, &serde_json::json!({ "cmd": "list_assistants" })).await? { + RemoteResponse::AssistantList { assistants } => Ok(assistants), + _ => Err("Unexpected remote assistant list response".to_string()), + } +} + +fn remote_assistant_key( + assistant: &crate::service::remote_connect::remote_server::AssistantEntry, +) -> String { + assistant + .workspace_id + .clone() + .unwrap_or_else(|| assistant.path.clone()) +} + +fn remote_assistant_options( + assistants: &[crate::service::remote_connect::remote_server::AssistantEntry], +) -> Vec<(String, String)> { + assistants + .iter() + .map(|assistant| (remote_assistant_key(assistant), assistant.name.clone())) + .collect() +} + +/// Record a remote assistant as this chat's selection. The device's own +/// active workspace is left alone; session RPCs carry the workspace ID. +fn adopt_remote_assistant( + state: &mut BotChatState, + assistant: &crate::service::remote_connect::remote_server::AssistantEntry, +) { + state.current_assistant = Some(assistant.path.clone()); + state.current_assistant_id = assistant.workspace_id.clone(); + state.current_assistant_name = Some(assistant.name.clone()); +} + +async fn start_remote_assistant_switch( + state: &mut BotChatState, + s: &'static BotStrings, +) -> HandleResult { + let assistants = match load_remote_assistants(state).await { + Ok(assistants) => assistants, + Err(error) => { + return result_from_menu( + state, + MenuView::plain(format!("{}{error}", s.workspace_open_failed_prefix)) + .with_items(vec![MenuItem::default(s.item_back, "/menu")]), + ) + } + }; + let options = remote_assistant_options(&assistants); + if options.is_empty() { + return result_from_menu( + state, + MenuView::plain(s.switch_no_assistants) + .with_items(vec![MenuItem::default(s.item_back, "/menu")]), + ); + } + let view = assistant_selection_view(state, &options, s); + state.set_pending(PendingAction::SelectAssistant { options }); + result_from_menu(state, view) +} + +async fn select_remote_assistant( + state: &mut BotChatState, + key: &str, + s: &'static BotStrings, +) -> HandleResult { + let assistants = match load_remote_assistants(state).await { + Ok(assistants) => assistants, + Err(error) => { + return result_from_menu( + state, + MenuView::plain(format!("{}{error}", s.workspace_open_failed_prefix)) + .with_items(vec![MenuItem::default(s.item_back, "/menu")]), + ) + } + }; + let Some(assistant) = assistants + .iter() + .find(|assistant| remote_assistant_key(assistant) == key) + else { + let result = start_remote_assistant_switch(state, s).await; + return workspace_list_changed_result(state, result, s); + }; + adopt_remote_assistant(state, assistant); + state.current_session_id = None; + let body = format!("{}: {}", s.current_assistant_label, assistant.name); + let view = main_menu_view(state, s).with_body(body); + result_from_menu(state, view) +} + +/// An assistant-mode chat with no selection adopts the device's only +/// assistant. Several assistants need an explicit choice; none means the user +/// must create one. Never guesses between assistants. +async fn adopt_sole_remote_assistant( + state: &mut BotChatState, +) -> Result, String> { + let assistants = load_remote_assistants(state).await?; + let [assistant] = assistants.as_slice() else { + return Ok(None); + }; + adopt_remote_assistant(state, assistant); + Ok(state + .assistant_workspace_ref() + .as_ref() + .map(RemoteDeviceWorkspaceFacts::from_ref)) +} + async fn select_assistant( state: &mut BotChatState, workspace_id: &str, @@ -1550,6 +1731,10 @@ async fn select_assistant( ) -> HandleResult { use crate::service::workspace::get_global_workspace_service; + if state.active_remote_device.is_some() { + return select_remote_assistant(state, workspace_id, s).await; + } + let ws_service = match get_global_workspace_service() { Some(svc) => svc, None => { @@ -1713,6 +1898,38 @@ impl RemoteDeviceWorkspaceFacts { } } +impl RemoteDeviceWorkspaceFacts { + fn from_ref(workspace: &BotWorkspaceRef) -> Self { + Self { + workspace_id: workspace.workspace_id.clone(), + path: workspace.path.clone(), + remote_connection_id: workspace.remote_connection_id.clone(), + remote_ssh_host: workspace.remote_ssh_host.clone(), + } + } +} + +/// Workspace for a session-level RPC on the active remote device, chosen the +/// same way the local host chooses: assistant sessions use the selected +/// assistant, expert sessions the selected project. `None` for an assistant +/// session means "let the device use its default assistant". Only an expert +/// session with no selection falls back to the device's open workspace. +async fn remote_session_workspace( + state: &BotChatState, + assistant: bool, +) -> Result, String> { + if assistant { + return Ok(state + .assistant_workspace_ref() + .as_ref() + .map(RemoteDeviceWorkspaceFacts::from_ref)); + } + if let Some(workspace) = state.current_workspace.as_ref() { + return Ok(Some(RemoteDeviceWorkspaceFacts::from_ref(workspace))); + } + query_remote_device_workspace(state).await.map(Some) +} + async fn query_remote_device_workspace( state: &BotChatState, ) -> Result { @@ -1766,12 +1983,32 @@ async fn start_resume( ) -> HandleResult { // ── Remote device branch ── if state.active_remote_device.is_some() { - let workspace = match query_remote_device_workspace(state).await { - Ok(workspace) => workspace, + let assistant = state.display_mode != BotDisplayMode::Pro; + let workspace = match remote_session_workspace(state, assistant).await { + Ok(Some(workspace)) => workspace, + Ok(None) => match adopt_sole_remote_assistant(state).await { + Ok(Some(workspace)) => workspace, + Ok(None) => { + return result_from_menu( + state, + MenuView::plain(s.no_assistant).with_items(vec![ + MenuItem::primary(s.item_switch_assistant, "/switch"), + MenuItem::default(s.item_back, "/menu"), + ]), + ); + } + Err(error) => { + return result_from_menu( + state, + MenuView::plain(format!("{}{error}", s.resume_list_failed_prefix)) + .with_items(vec![MenuItem::default(s.item_back, "/menu")]), + ); + } + }, Err(error) => { return result_from_menu( state, - MenuView::plain(format!("{}{error}", s.session_create_failed_prefix)) + MenuView::plain(format!("{}{error}", s.resume_list_failed_prefix)) .with_items(vec![MenuItem::default(s.item_back, "/menu")]), ); } @@ -1790,7 +2027,7 @@ async fn start_resume( Err(e) => { return result_from_menu( state, - MenuView::plain(format!("{}{e}", s.session_create_failed_prefix)), + MenuView::plain(format!("{}{e}", s.resume_list_failed_prefix)), ); } }; @@ -1798,7 +2035,7 @@ async fn start_resume( let message = val .get("message") .and_then(|value| value.as_str()) - .unwrap_or(s.session_create_failed_prefix); + .unwrap_or(s.resume_list_failed_prefix); return result_from_menu( state, MenuView::plain(message.to_string()) @@ -1887,7 +2124,7 @@ async fn start_resume( Err(e) => { return result_from_menu( state, - MenuView::plain(format!("{}{e}", s.session_create_failed_prefix)) + MenuView::plain(format!("{}{e}", s.resume_list_failed_prefix)) .with_items(vec![MenuItem::default(s.item_back, "/menu")]), ); } @@ -1931,7 +2168,7 @@ async fn start_resume( state, MenuView::plain(format!( "{}{}", - s.session_create_failed_prefix, + s.resume_list_failed_prefix, "Failed to resolve session storage for the current workspace" )), ); @@ -1945,7 +2182,7 @@ async fn start_resume( Err(e) => { return result_from_menu( state, - MenuView::plain(format!("{}{e}", s.session_create_failed_prefix)), + MenuView::plain(format!("{}{e}", s.resume_list_failed_prefix)), ); } }; @@ -1954,7 +2191,7 @@ async fn start_resume( Err(e) => { return result_from_menu( state, - MenuView::plain(format!("{}{e}", s.session_create_failed_prefix)), + MenuView::plain(format!("{}{e}", s.resume_list_failed_prefix)), ); } }; @@ -1963,7 +2200,7 @@ async fn start_resume( Err(e) => { return result_from_menu( state, - MenuView::plain(format!("{}{e}", s.session_create_failed_prefix)), + MenuView::plain(format!("{}{e}", s.resume_list_failed_prefix)), ); } }; @@ -2037,8 +2274,13 @@ async fn select_session( state.current_session_id = Some(session_id.to_string()); info!("Bot resumed session: {session_id}"); - let last_pair = - load_last_dialog_pair_from_turns(state.current_workspace.as_ref(), session_id).await; + // A remote session's transcript lives on the device, never in this + // host's session store. + let last_pair = if state.active_remote_device.is_some() { + load_remote_last_dialog_pair(state, session_id).await + } else { + load_last_dialog_pair_from_turns(state.current_workspace.as_ref(), session_id).await + }; let mut body = format!("{}{}\n", s.resume_resumed_prefix, session_name); if let Some((user_text, ai_text)) = last_pair { body.push('\n'); @@ -2058,13 +2300,60 @@ async fn select_session( result_from_menu(state, view) } +const RESUME_PREVIEW_USER_CHARS: usize = 200; +const RESUME_PREVIEW_AI_CHARS: usize = 400; + +async fn load_remote_last_dialog_pair( + state: &BotChatState, + session_id: &str, +) -> Option<(String, String)> { + use crate::service::remote_connect::remote_server::RemoteResponse; + let command = serde_json::json!({ + "cmd": "get_session_messages", + "session_id": session_id, + "limit": null, + "before_message_id": null, + }); + match exec_remote_command(state, &command).await { + Ok(RemoteResponse::Messages { messages, .. }) => last_dialog_pair_from_messages(&messages), + Ok(_) => None, + Err(error) => { + warn!("Bot could not load the remote resume preview: {error}"); + None + } + } +} + +/// Last user message and the assistant replies after it. +fn last_dialog_pair_from_messages( + messages: &[crate::service::remote_connect::remote_server::ChatMessage], +) -> Option<(String, String)> { + let user_index = messages + .iter() + .rposition(|message| message.role == "user")?; + let user_text = strip_user_message_tags(&messages[user_index].content); + if user_text.is_empty() { + return None; + } + let ai_text = messages[user_index + 1..] + .iter() + .filter(|message| message.role == "assistant" && !message.content.trim().is_empty()) + .map(|message| message.content.trim()) + .collect::>() + .join("\n"); + if ai_text.is_empty() { + return None; + } + Some(( + truncate_text(&user_text, RESUME_PREVIEW_USER_CHARS), + truncate_text(&ai_text, RESUME_PREVIEW_AI_CHARS), + )) +} + async fn load_last_dialog_pair_from_turns( workspace: Option<&BotWorkspaceRef>, session_id: &str, ) -> Option<(String, String)> { - const MAX_USER_LEN: usize = 200; - const MAX_AI_LEN: usize = 400; - let workspace = workspace?; let storage_path = resolve_bot_session_storage_path_for_ref(workspace).await?; let coordinator = crate::agentic::coordination::get_global_coordinator()?; @@ -2097,8 +2386,8 @@ async fn load_last_dialog_pair_from_turns( return None; } Some(( - truncate_text(&user_text, MAX_USER_LEN), - truncate_text(&ai_text, MAX_AI_LEN), + truncate_text(&user_text, RESUME_PREVIEW_USER_CHARS), + truncate_text(&ai_text, RESUME_PREVIEW_AI_CHARS), )) } @@ -2162,6 +2451,53 @@ async fn guarded_new( create_session(state, agent_type).await } +/// Remember where the device placed a new session when this chat had no +/// selection, so later resume and model calls target the same workspace. +async fn adopt_created_remote_workspace(state: &mut BotChatState, is_claw: bool, reply: &Value) { + let text = |key: &str| { + reply + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }; + let Some(path) = text("workspace_path") else { + return; + }; + let workspace_id = text("workspace_id"); + if is_claw { + if state.assistant_workspace_ref().is_some() { + return; + } + let name = match load_remote_assistants(state).await { + Ok(assistants) => assistants + .into_iter() + .find(|assistant| { + workspace_id.is_some() && assistant.workspace_id == workspace_id + || assistant.path == path + }) + .map(|assistant| assistant.name), + Err(error) => { + warn!("Bot could not name the remote default assistant: {error}"); + None + } + }; + state.current_assistant = Some(path); + state.current_assistant_id = workspace_id; + state.current_assistant_name = name; + } else if state.current_workspace.is_none() { + state.current_workspace = Some( + BotWorkspaceRef::with_identity( + path, + text("remote_connection_id"), + text("remote_ssh_host"), + ) + .with_workspace_id(workspace_id), + ); + } +} + async fn create_session(state: &mut BotChatState, agent_type: &str) -> HandleResult { let language = current_bot_language().await; let s = strings_for(language); @@ -2174,7 +2510,8 @@ async fn create_session(state: &mut BotChatState, agent_type: &str) -> HandleRes } else { "Remote Session" }; - let workspace = match query_remote_device_workspace(state).await { + let is_claw = agent_type == "Claw"; + let workspace = match remote_session_workspace(state, is_claw).await { Ok(workspace) => workspace, Err(error) => { return result_from_menu( @@ -2189,7 +2526,11 @@ async fn create_session(state: &mut BotChatState, agent_type: &str) -> HandleRes "agent_type": agent_type, "session_name": session_name, }); - workspace.apply_to_command(&mut cmd); + // An assistant session with no selection omits the workspace so the + // device places it in its own default assistant. + if let Some(workspace) = workspace.as_ref() { + workspace.apply_to_command(&mut cmd); + } let cmd_json = serde_json::to_string(&cmd).unwrap_or_default(); match exec_remote_rpc(state, &cmd_json).await { Ok(resp) => { @@ -2206,6 +2547,7 @@ async fn create_session(state: &mut BotChatState, agent_type: &str) -> HandleRes ); } if let Some(sid) = val.get("session_id").and_then(|v| v.as_str()) { + adopt_created_remote_workspace(state, is_claw, &val).await; state.current_session_id = Some(sid.to_string()); let body = format!( "{}{}\n\n{}", @@ -5004,3 +5346,273 @@ mod handle_chat_tests { ); } } + +#[cfg(test)] +mod remote_device_flow_tests { + use super::*; + use std::sync::{Arc, Mutex}; + + type Responder = Arc Value + Send + Sync>; + + /// Loopback relay that answers encrypted device RPCs for `device-a` with + /// `respond`, recording every decrypted command. + async fn spawn_device_fixture(respond: Responder) -> (String, Vec, Arc>>) { + use openbitfun_services_integrations::remote_connect::{device_crypto, encryption}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay_url = format!("http://{}", listener.local_addr().unwrap()); + let calls = Arc::new(Mutex::new(Vec::::new())); + let observed = calls.clone(); + let peer_secret = [11u8; 32]; + let local_secret = [7u8; 32]; + let public = device_crypto::public_key_base64(&peer_secret); + let key = device_crypto::derive_message_key( + &peer_secret, + &device_crypto::public_key(&local_secret), + ) + .unwrap(); + tokio::spawn(async move { + use futures::{SinkExt, StreamExt}; + use tokio_tungstenite::tungstenite::{ + handshake::derive_accept_key, protocol::Role, Message, + }; + loop { + let (mut socket, _) = listener.accept().await.unwrap(); + let public = public.clone(); + let observed = observed.clone(); + let respond = respond.clone(); + tokio::spawn(async move { + let mut bytes = Vec::new(); + let header_end = loop { + let mut block = [0u8; 4096]; + let count = socket.read(&mut block).await.unwrap(); + if count == 0 { + return; + } + bytes.extend_from_slice(&block[..count]); + if let Some(end) = bytes.windows(4).position(|v| v == b"\r\n\r\n") { + break end + 4; + } + }; + let header = std::str::from_utf8(&bytes[..header_end]).unwrap(); + if header.starts_with("GET /api/devices/device-a/key ") { + let reply = serde_json::json!({"device_id":"device-a","public_key":public}) + .to_string(); + let http = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", reply.len(), reply); + socket.write_all(http.as_bytes()).await.unwrap(); + return; + } + assert!(header.starts_with("GET /v1/updates/"), "{header}"); + let websocket_key = header + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("sec-websocket-key") + .then_some(value.trim()) + }) + .unwrap(); + let accept = derive_accept_key(websocket_key.as_bytes()); + socket.write_all(format!("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n").as_bytes()).await.unwrap(); + let mut ws = tokio_tungstenite::WebSocketStream::from_partially_read( + socket, + bytes[header_end..].to_vec(), + Role::Server, + None, + ) + .await; + ws.send(Message::Text(r#"0{"sid":"fixture","upgrades":[],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000}"#.into())).await.unwrap(); + while let Some(frame) = ws.next().await { + let Ok(Message::Text(text)) = frame else { + break; + }; + if text.starts_with("40") { + ws.send(Message::Text(r#"40{"sid":"fixture"}"#.into())) + .await + .unwrap(); + ws.send(Message::Text( + r#"42["auth-ok",{"userId":"test-user","deviceId":"controller"}]"# + .into(), + )) + .await + .unwrap(); + continue; + } + if text == "3" { + continue; + } + let array_start = text.find('[').unwrap(); + let ack_id = &text[2..array_start]; + let event: Value = serde_json::from_str(&text[array_start..]).unwrap(); + let envelope = &event[1]["params"]; + let plaintext = encryption::decrypt_from_base64( + &key, + envelope["encrypted_data"].as_str().unwrap(), + envelope["nonce"].as_str().unwrap(), + ) + .unwrap(); + let command: Value = serde_json::from_str(&plaintext).unwrap(); + observed.lock().unwrap().push(command.clone()); + let response = respond(&command); + let (encrypted_data, nonce) = + encryption::encrypt_to_base64(&key, &response.to_string()).unwrap(); + let response = serde_json::json!([{"ok":true,"result":{"encrypted_data":encrypted_data,"nonce":nonce}}]); + ws.send(Message::Text(format!("43{ack_id}{response}").into())) + .await + .unwrap(); + } + }); + } + }); + (relay_url, local_secret.to_vec(), calls) + } + + fn remote_state(relay_url: String, secret: Vec, mode: BotDisplayMode) -> BotChatState { + let mut state = BotChatState::new("chat".into()); + state.paired = true; + state.display_mode = mode; + state.relay_url = Some(relay_url); + state.set_delegated_identity("test-token".into(), secret); + state.active_remote_device = Some(RemoteDeviceTarget { + device_id: "device-a".into(), + device_name: "Device A".into(), + }); + state + } + + fn assistants() -> Value { + serde_json::json!({"resp":"assistant_list","assistants":[ + {"workspace_id":"ws-claw","path":"/home/a/claw","name":"Xiaofu","assistant_id":"a1"}, + {"workspace_id":"ws-other","path":"/home/a/other","name":"Other","assistant_id":"a2"} + ]}) + } + + fn device_responder() -> Responder { + Arc::new(|command: &Value| match command["cmd"].as_str().unwrap() { + "get_model_catalog" => { + assert_eq!(command["session_id"], "remote-session"); + serde_json::json!({"resp":"model_catalog","catalog":{ + "version":1, + "models":[ + {"id":"m-a","name":"Provider A","provider":"openai","base_url":"https://a","model_name":"alpha","enabled":true,"capabilities":["text_chat"]}, + {"id":"m-off","name":"Provider B","provider":"openai","base_url":"https://b","model_name":"off","enabled":false,"capabilities":["text_chat"]} + ], + "default_models":{"primary":"m-a","fast":null,"search":null,"image_understanding":null,"image_generation":null,"speech_recognition":null}, + "session_model_id":"m-a" + }}) + } + "set_session_model" => serde_json::json!({"resp":"session_model_updated", + "session_id":command["session_id"],"model_id":command["model_id"],"reasoning_preset":null}), + "list_assistants" => assistants(), + "list_sessions" => serde_json::json!({"resp":"session_list","sessions":[ + {"session_id":"s-old","name":"Earlier","agent_type":"Claw","message_count":2} + ],"has_more":false}), + "get_session_messages" => { + serde_json::json!({"resp":"messages","session_id":"s-old","has_more":false,"messages":[ + {"id":"u1","role":"user","content":"hello","timestamp":"0","metadata":null}, + {"id":"a1","role":"assistant","content":"hi there","timestamp":"0","metadata":null} + ]}) + } + "create_session" => serde_json::json!({"resp":"session_created","session_id":"s-new", + "workspace_id":"ws-claw","workspace_path":"/home/a/claw"}), + other => panic!("Unexpected command: {other}"), + }) + } + + #[tokio::test] + async fn remote_model_switch_reads_and_updates_the_device_session() { + let (relay_url, secret, calls) = spawn_device_fixture(device_responder()).await; + let s = strings_for(BotLanguage::EnUS); + let mut state = remote_state(relay_url, secret, BotDisplayMode::Assistant); + state.current_session_id = Some("remote-session".into()); + + let result = start_switch_model(&mut state, s).await; + let text = result.menu.render_plain_text(BotLanguage::EnUS); + assert!(text.contains("alpha · Provider A"), "{text}"); + assert!(!text.contains("off · Provider B"), "{text}"); + let Some(PendingAction::SelectModel { options }) = state.pending_action.clone() else { + panic!("expected a model selection"); + }; + + let result = + route_pending(&mut state, PendingAction::SelectModel { options }, "2", s).await; + assert!( + result.reply.contains(s.switch_model_applied_prefix), + "{}", + result.reply + ); + + let calls = calls.lock().unwrap(); + let update = calls + .iter() + .find(|call| call["cmd"] == "set_session_model") + .unwrap(); + assert_eq!(update["model_id"], "m-a"); + // An explicit null would clear the session's reasoning preset. + assert!(update.get("reasoning_preset").is_none(), "{update}"); + } + + #[tokio::test] + async fn remote_assistant_mode_selects_resumes_and_creates_in_device_assistants() { + let (relay_url, secret, calls) = spawn_device_fixture(device_responder()).await; + let s = strings_for(BotLanguage::EnUS); + let mut state = remote_state(relay_url, secret, BotDisplayMode::Assistant); + + // Creating with no selection lets the device pick its default + // assistant, and the chat remembers where the session landed. + let created = create_session(&mut state, "Claw").await; + assert_eq!( + state.current_session_id.as_deref(), + Some("s-new"), + "{}", + created.reply + ); + assert_eq!(state.current_assistant_id.as_deref(), Some("ws-claw")); + assert_eq!(state.current_assistant_name.as_deref(), Some("Xiaofu")); + + // Switching lists the device's assistants instead of "none found". + let switch = start_switch(&mut state, s).await; + let text = switch.menu.render_plain_text(BotLanguage::EnUS); + assert!(text.contains("Xiaofu") && text.contains("Other"), "{text}"); + let pending = state.pending_action.clone().unwrap(); + route_pending(&mut state, pending, "2", s).await; + assert_eq!(state.current_assistant_id.as_deref(), Some("ws-other")); + assert!(state.current_session_id.is_none()); + + // Resume lists sessions in the selected assistant and previews the + // device transcript. + start_resume(&mut state, 0, s).await; + let pending = state.pending_action.clone().unwrap(); + let resumed = route_pending(&mut state, pending, "1", s).await; + assert_eq!(state.current_session_id.as_deref(), Some("s-old")); + assert!( + resumed.reply.contains("hello") && resumed.reply.contains("hi there"), + "{}", + resumed.reply + ); + + let calls = calls.lock().unwrap(); + let create = calls + .iter() + .find(|call| call["cmd"] == "create_session") + .unwrap(); + assert!(create.get("workspace_id").is_none(), "{create}"); + assert!(create.get("workspace_path").is_none(), "{create}"); + let list = calls + .iter() + .find(|call| call["cmd"] == "list_sessions") + .unwrap(); + assert_eq!(list["workspace_id"], "ws-other"); + assert!(!calls.iter().any(|call| call["cmd"] == "device_query_info")); + assert!(!calls.iter().any(|call| call["cmd"] == "set_assistant")); + } + + #[test] + fn remote_error_replies_are_errors_not_empty_results() { + assert_eq!( + parse_remote_response(r#"{"resp":"error","message":"Session storage is unavailable"}"#) + .unwrap_err(), + "Session storage is unavailable" + ); + assert!(parse_remote_response("not json").is_err()); + } +} diff --git a/src/crates/services/services-integrations/src/remote_connect/bot/locale.rs b/src/crates/services/services-integrations/src/remote_connect/bot/locale.rs index 06f9c34682..9eef3b4d8a 100644 --- a/src/crates/services/services-integrations/src/remote_connect/bot/locale.rs +++ b/src/crates/services/services-integrations/src/remote_connect/bot/locale.rs @@ -116,6 +116,7 @@ pub struct BotStrings { pub resume_you_label: &'static str, pub resume_continue_hint: &'static str, pub resume_first_message_hint: &'static str, + pub resume_list_failed_prefix: &'static str, pub switch_model_title: &'static str, pub switch_model_pick: &'static str, @@ -284,6 +285,7 @@ const STRINGS_ZH: BotStrings = BotStrings { resume_you_label: "你", resume_continue_hint: "可以继续对话。", resume_first_message_hint: "发送一条消息即可开始。", + resume_list_failed_prefix: "读取会话列表失败:", switch_model_title: "选择模型", switch_model_pick: "请选择要使用的模型:", @@ -451,6 +453,7 @@ const STRINGS_ZH_TW: BotStrings = BotStrings { resume_you_label: "你", resume_continue_hint: "可以繼續對話。", resume_first_message_hint: "發送一條消息即可開始。", + resume_list_failed_prefix: "讀取會話列表失敗:", switch_model_title: "選擇模型", switch_model_pick: "請選擇要使用的模型:", @@ -619,6 +622,7 @@ Open Remote Connect in OpenBitFun Desktop and send the 6-digit pairing code here resume_you_label: "You", resume_continue_hint: "You can continue the conversation.", resume_first_message_hint: "Send a message to start.", + resume_list_failed_prefix: "Failed to list sessions: ", switch_model_title: "Select Model", switch_model_pick: "Pick a model to use:", From 8c7c0443ba555044cadd35308454c21aac304230 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 31 Oct 2026 18:03:19 +0800 Subject: [PATCH 2/4] feat(dispatch): let the Agent list account devices and dispatch on Desktop and CLI Detached Dispatch was reachable only through Desktop UI flows: the product control catalog had no executable dispatch operations, CLI marked the whole capability as lacking a presentation surface, and no operation returned the account's device list, so the Agent could neither count the user's devices nor dispatch work. Add the detached-dispatch product-control provider on feature.detached-dispatch (list-targets, submit, status, list-jobs, cancel) with a closed request contract. Both hosts bind it to a new Core executor, execute_dispatch_control, which the CLI dispatch outbound command now also uses. list-targets returns every same-account device with isCurrentDevice plus saved SSH targets; submit resolves a device name, alias, device:, or ssh:, never targets this host, and never falls back to a local run. Detached dispatch workers may observe but not submit further jobs. Desktop runs dispatch operations outside the settings transaction because a submit can spend minutes uploading its baseline. --- docs/architecture/detached-task-dispatch.md | 10 + docs/interactive-capabilities/README.md | 12 +- .../capabilities.json | 543 ++++++++++++++--- .../capabilities/feature.detached-dispatch.md | 30 +- .../technical/product-control-open-audit.json | 81 +-- .../technical/tauri-command-map.json | 8 +- scripts/generate-interactive-capabilities.mjs | 14 + src/apps/cli/src/dispatch/outbound.rs | 389 ++---------- src/apps/cli/src/main.rs | 2 +- src/apps/cli/src/remote_control.rs | 77 ++- src/apps/desktop/src/api/dispatch_api.rs | 63 ++ .../desktop/src/openbitfun_control_host.rs | 27 +- .../src/service/dispatch/agent_control.rs | 559 +++++++++++++++++ .../assembly/core/src/service/dispatch/mod.rs | 15 + .../src/detached_dispatch_control.rs | 196 ++++++ .../generated/product-control-catalog.json | 568 +++++++++++++++--- .../contracts/product-domains/src/lib.rs | 1 + .../src/product_control_owner_registry.rs | 52 +- .../interactive-capabilities/catalog.json | 125 +++- .../generated/interactive-capabilities.json | 543 ++++++++++++++--- .../api/generated/productControl.ts | 4 +- 21 files changed, 2598 insertions(+), 721 deletions(-) create mode 100644 src/crates/assembly/core/src/service/dispatch/agent_control.rs create mode 100644 src/crates/contracts/product-domains/src/detached_dispatch_control.rs diff --git a/docs/architecture/detached-task-dispatch.md b/docs/architecture/detached-task-dispatch.md index 612bd4911a..673eaa1144 100644 --- a/docs/architecture/detached-task-dispatch.md +++ b/docs/architecture/detached-task-dispatch.md @@ -27,6 +27,16 @@ both controllers over the same Core controller. The account-device `host_invoke` envelope is shared through `AccountRuntimeDeviceDispatchRpc` and its encode/decode helpers, so the two hosts cannot drift. +The Agent reaches the same controller through the `detached-dispatch` +product-control provider on `feature.detached-dispatch` (`list-targets`, +`submit`, `status`, `list-jobs`, `cancel`). Both hosts bind it to Core's +`execute_dispatch_control`, which `dispatch outbound` also uses; hosts supply +only their account session and SSH manager. `list-targets` returns every +same-account device with `isCurrentDevice` and the saved SSH targets, so the +Agent never infers the device count from Remote Connect status. `submit` is +refused inside a detached dispatch worker, so a job cannot fan out further +dispatches. + The controller is never a runtime or filesystem proxy for a non-local job. It stores only an outbound observer record under `~/.openbitfun/dispatch/outbound/`; it must not create the target session in the diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index d2555b6ddb..8a069a5710 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -1,9 +1,9 @@ # OpenBitFun 功能与设置目录 / OpenBitFun Features & Settings -OpenBitFun Playbook 当前包含 **22 个功能**和 **22 个设置页**,共 **44 个**用户可理解的条目、**336 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、OpenBitFun 全局搜索和 `OpenBitFunControl` 智能体工具。 +OpenBitFun Playbook 当前包含 **22 个功能**和 **22 个设置页**,共 **44 个**用户可理解的条目、**337 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、OpenBitFun 全局搜索和 `OpenBitFunControl` 智能体工具。 -OpenBitFun Playbook currently contains **22 features**, **22 settings pages**, and **336** source-backed sub-capabilities across **44** user-facing entries. Every entry has its own Markdown page and directly powers the website, in-app global search, and the `OpenBitFunControl` agent tool. +OpenBitFun Playbook currently contains **22 features**, **22 settings pages**, and **337** source-backed sub-capabilities across **44** user-facing entries. Every entry has its own Markdown page and directly powers the website, in-app global search, and the `OpenBitFunControl` agent tool. ## 唯一事实源 / Single source of truth @@ -33,14 +33,14 @@ Docs, website, search, and agents see only features, settings, and documented su ## 控制边界 / Control boundary -- 每个子能力都明确标记为直接控制、委托给专用智能体工具、需交互打开或不支持;“打开页面”不会再被统计成“智能体已控制”。当前覆盖:直接 **58**、委托 **72**、需交互 **206**、不支持 **0**。 +- 每个子能力都明确标记为直接控制、委托给专用智能体工具、需交互打开或不支持;“打开页面”不会再被统计成“智能体已控制”。当前覆盖:直接 **62**、委托 **72**、需交互 **203**、不支持 **0**。 - 稳定行为声明为带 JSON 输入契约的 `operations` 或 `options`,并绑定原生产品控制 Provider;智能体不接触原始 Tauri Command。 -- `OpenBitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 336 项子能力都不会写入 system prompt。 +- `OpenBitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 337 项子能力都不会写入 system prompt。 - 目录发现与契约读取不依赖 React 或可见窗口。普通配置型 option 统一由 Product Assembly 的共享 ConfigService 执行器读、写并回读,因此 Desktop、CLI 与 Headless 表面走同一份实现;只有宿主原生 operation/provider option 和界面导航按表面注册适配器,缺失时必须明确返回不可用,禁止静默回退本机。只读智能体只能发现和读取目录。 -- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **58 direct**, **72 delegated**, **206 interactive**, and **0 unsupported**. +- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **62 direct**, **72 delegated**, **203 interactive**, and **0 unsupported**. - Stable behavior becomes a typed `operation` or `option` with a JSON input contract and a native product-control provider. Agents never receive raw Tauri commands. -- `OpenBitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 336 documented items enters the system prompt. +- `OpenBitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 337 documented items enters the system prompt. - Discovery and contract lookup do not depend on React or a visible window. Ordinary config-backed options are read, written, and read back by one Product Assembly ConfigService executor shared by Desktop, CLI, and headless surfaces. Only host-native operations/provider options and presentation routes install surface adapters; missing adapters return explicit unavailability without local fallback. Read-only agents may only discover and inspect entries. ## 防腐化门禁 / Anti-drift gates diff --git a/docs/interactive-capabilities/capabilities.json b/docs/interactive-capabilities/capabilities.json index 8ba33cd4e6..bc8aa707c3 100644 --- a/docs/interactive-capabilities/capabilities.json +++ b/docs/interactive-capabilities/capabilities.json @@ -4,8 +4,8 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "2eb272d6820970e0c8ac677714ca03495729fa4a9cd458f1b89a4de726971107", - "ownerDigest": "df28e5eed6e3190741899ebf236bc5fb426d2469187e437839a349a7136c7c34", + "digest": "a4e451cd4268edcf15c00d41b4e5c8c6a476eb407e1324698d3db2b3f2f1ad34", + "ownerDigest": "0b2d30952efd57108d9e566dc0bf5a176f2b3d753abfb736bfc32b2c1fa3f7b7", "searchAcceptance": [ { "id": "native-image-generation", @@ -175,11 +175,11 @@ "features": 22, "settings": 22, "userFacing": 44, - "documentedItems": 336, + "documentedItems": 337, "controlCoverage": { - "direct": 58, + "direct": 62, "delegated": 72, - "interactive": 206, + "interactive": 203, "unsupported": 0 } }, @@ -9775,6 +9775,7 @@ "status-list", "transcript", "permission-continuation", + "cancel", "result-sync" ], "kind": "query", @@ -9820,46 +9821,49 @@ } }, { - "id": "feature.detached-dispatch:open:targets", + "id": "feature.detached-dispatch:operation:list-targets", "capabilityId": "feature.detached-dispatch", "itemIds": [ "targets" ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", + "kind": "operation", + "risk": "read", + "executionHost": "productHost", "availability": { "desktop": { "available": true }, "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true }, "peer": { "available": true, "requiredCapabilities": [ "product_control_v1", - "product_control_presentation_v1" + "product_control_native_v1" ] }, "remoteControl": { "available": true }, "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true } }, "inputSchema": { - "type": "object", - "additionalProperties": false + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object" }, "outputSchema": { "type": "object", "additionalProperties": true }, - "openReason": "unstructuredInteraction", + "valueSource": { + "kind": "provider", + "providerId": "detached-dispatch" + }, "presentationTarget": { "kind": "event", "eventName": "openbitfun:open-remote-connect", @@ -9869,27 +9873,26 @@ } }, { - "id": "feature.detached-dispatch:open:provision", + "id": "feature.detached-dispatch:operation:submit", "capabilityId": "feature.detached-dispatch", "itemIds": [ - "provision" + "submit" ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", + "kind": "operation", + "risk": "execute", + "executionHost": "productHost", "availability": { "desktop": { "available": true }, "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true }, "peer": { "available": true, "requiredCapabilities": [ "product_control_v1", - "product_control_presentation_v1" + "product_control_native_v1" ] }, "remoteControl": { @@ -9897,18 +9900,137 @@ }, "detachedDispatch": { "available": false, - "reason": "This delivery profile has no live presentation surface" + "reason": "A detached dispatch job cannot dispatch further work" } }, "inputSchema": { + "additionalProperties": false, + "properties": { + "agent": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "approvalPolicy": { + "enum": [ + "auto", + "reject-and-report" + ], + "type": "string" + }, + "baseRef": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "includeUncommitted": { + "type": "boolean" + }, + "model": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "prompt": { + "maxLength": 32768, + "minLength": 1, + "type": "string" + }, + "target": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "title": { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "workspacePath": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "target", + "prompt", + "approvalPolicy", + "workspacePath" + ], + "type": "object" + }, + "outputSchema": { "type": "object", - "additionalProperties": false + "additionalProperties": true + }, + "valueSource": { + "kind": "provider", + "providerId": "detached-dispatch" + }, + "presentationTarget": { + "kind": "event", + "eventName": "openbitfun:open-remote-connect", + "detail": { + "group": "account" + } + } + }, + { + "id": "feature.detached-dispatch:operation:status", + "capabilityId": "feature.detached-dispatch", + "itemIds": [ + "status-list" + ], + "kind": "operation", + "risk": "read", + "executionHost": "productHost", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": true + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_native_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": true + } + }, + "inputSchema": { + "additionalProperties": false, + "properties": { + "cursor": { + "minimum": 0, + "type": "integer" + }, + "jobId": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "jobId" + ], + "type": "object" }, "outputSchema": { "type": "object", "additionalProperties": true }, - "openReason": "unstructuredInteraction", + "valueSource": { + "kind": "provider", + "providerId": "detached-dispatch" + }, "presentationTarget": { "kind": "event", "eventName": "openbitfun:open-remote-connect", @@ -9918,46 +10040,115 @@ } }, { - "id": "feature.detached-dispatch:open:model-sync", + "id": "feature.detached-dispatch:operation:list-jobs", "capabilityId": "feature.detached-dispatch", "itemIds": [ - "model-sync" + "status-list" ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", + "kind": "operation", + "risk": "read", + "executionHost": "productHost", "availability": { "desktop": { "available": true }, "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true }, "peer": { "available": true, "requiredCapabilities": [ "product_control_v1", - "product_control_presentation_v1" + "product_control_native_v1" ] }, "remoteControl": { "available": true }, "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true } }, "inputSchema": { + "additionalProperties": false, + "properties": { + "target": { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "outputSchema": { "type": "object", - "additionalProperties": false + "additionalProperties": true + }, + "valueSource": { + "kind": "provider", + "providerId": "detached-dispatch" + }, + "presentationTarget": { + "kind": "event", + "eventName": "openbitfun:open-remote-connect", + "detail": { + "group": "account" + } + } + }, + { + "id": "feature.detached-dispatch:operation:cancel", + "capabilityId": "feature.detached-dispatch", + "itemIds": [ + "cancel" + ], + "kind": "operation", + "risk": "destructive", + "executionHost": "productHost", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": true + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_native_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": true + } + }, + "inputSchema": { + "additionalProperties": false, + "properties": { + "jobId": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "jobId" + ], + "type": "object" }, "outputSchema": { "type": "object", "additionalProperties": true }, - "openReason": "unstructuredInteraction", + "valueSource": { + "kind": "provider", + "providerId": "detached-dispatch" + }, "presentationTarget": { "kind": "event", "eventName": "openbitfun:open-remote-connect", @@ -9967,10 +10158,10 @@ } }, { - "id": "feature.detached-dispatch:open:submit", + "id": "feature.detached-dispatch:open:provision", "capabilityId": "feature.detached-dispatch", "itemIds": [ - "submit" + "provision" ], "kind": "open", "risk": "ui", @@ -10016,10 +10207,10 @@ } }, { - "id": "feature.detached-dispatch:open:status-list", + "id": "feature.detached-dispatch:open:model-sync", "capabilityId": "feature.detached-dispatch", "itemIds": [ - "status-list" + "model-sync" ], "kind": "open", "risk": "ui", @@ -25378,8 +25569,8 @@ "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "“列出、连接、查看设备型号与系统、设置别名和删除同账户设备”需要选择设备并确认操作。设备目录使用控制端的账户身份,不随 Peer Device 切换到目标主机。别名优先于技术名称显示,但不修改技术名称;型号与系统信息由设备自身上报。仅支持对应能力的 Relay 可更新或清除别名,旧 Relay 明确显示不支持。智能体打开设备管理入口,由用户确认后续交互。", - "reasonEn": "“List, connect, inspect model and OS, set aliases, and remove same-account devices” requires device selection and confirmation. The device directory uses the controller's account identity, not the selected Peer host. Aliases take display priority without changing technical names; model and OS metadata come from the device itself. Alias updates and clearing require Relay capability support; older Relays explicitly remain unsupported. The Agent opens device management and leaves subsequent interaction to the user." + "reasonZh": "“列出、连接、查看设备型号与系统、设置别名和删除同账户设备”需要选择设备并确认操作。设备目录使用控制端的账户身份,不随 Peer Device 切换到目标主机。别名优先于技术名称显示,但不修改技术名称;型号与系统信息由设备自身上报。仅支持对应能力的 Relay 可更新或清除别名,旧 Relay 明确显示不支持。智能体打开设备管理入口,由用户确认后续交互。读取设备列表请调用 feature.detached-dispatch 的 list-targets。", + "reasonEn": "“List, connect, inspect model and OS, set aliases, and remove same-account devices” requires device selection and confirmation. The device directory uses the controller's account identity, not the selected Peer host. Aliases take display priority without changing technical names; model and OS metadata come from the device itself. Alias updates and clearing require Relay capability support; older Relays explicitly remain unsupported. The Agent opens device management and leaves subsequent interaction to the user. To read the device list, call list-targets on feature.detached-dispatch." } }, { @@ -25669,25 +25860,26 @@ "offline job" ], "highlightsZh": [ - "发现与探测目标主机", - "派发、继续或取消任务", + "列出同账户设备与 SSH 目标", + "派发、查询或取消任务", "断线后重新加载事件与执行记录" ], "highlightsEn": [ - "Discover and probe target hosts", - "Dispatch, continue, or cancel jobs", + "List same-account devices and SSH targets", + "Dispatch, inspect, or cancel jobs", "Reload events and execution history after reconnecting" ], "items": [ { "id": "targets", - "titleZh": "列出可派发目标并探测版本、连接和运行能力", - "titleEn": "List dispatch targets and probe their version, connectivity, and runtime capabilities", + "titleZh": "列出同账户设备与已保存的 SSH 目标", + "titleEn": "List same-account devices and saved SSH targets", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“列出可派发目标并探测版本、连接和运行能力”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“List dispatch targets and probe their version, connectivity, and runtime capabilities” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "direct", + "operations": [ + "list-targets" + ], + "options": [] } }, { @@ -25714,13 +25906,14 @@ }, { "id": "submit", - "titleZh": "把包含工作区、会话、模型和权限策略的持久任务提交到目标", - "titleEn": "Submit a durable job with workspace, session, model, and permission policy to the target", + "titleZh": "从当前 Git 工作区把持久任务提交到同账户设备或 SSH 目标", + "titleEn": "Submit a durable job from the current Git workspace to a same-account device or SSH target", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“把包含工作区、会话、模型和权限策略的持久任务提交到目标”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Submit a durable job with workspace, session, model, and permission policy to the target” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "direct", + "operations": [ + "submit" + ], + "options": [] } }, { @@ -25728,10 +25921,12 @@ "titleZh": "列出派发任务并查询运行状态、结果和错误", "titleEn": "List dispatched jobs and query their status, result, and errors", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“列出派发任务并查询运行状态、结果和错误”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“List dispatched jobs and query their status, result, and errors” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "direct", + "operations": [ + "list-jobs", + "status" + ], + "options": [] } }, { @@ -25747,13 +25942,25 @@ }, { "id": "permission-continuation", - "titleZh": "远程回答权限请求,并继续或取消目标端任务", - "titleEn": "Answer permission requests remotely and continue or cancel target-side work", + "titleZh": "远程回答权限请求并继续目标端任务", + "titleEn": "Answer permission requests remotely and continue target-side work", "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "“远程回答权限请求,并继续或取消目标端任务”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Answer permission requests remotely and continue or cancel target-side work” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "reasonZh": "“远程回答权限请求并继续目标端任务”需要用户逐条确认权限与后续输入,目前没有能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", + "reasonEn": "“Answer permission requests remotely and continue target-side work” needs the user to confirm each permission and follow-up input and has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + } + }, + { + "id": "cancel", + "titleZh": "取消本机派发的任务", + "titleEn": "Cancel a job submitted from this host", + "control": { + "kind": "direct", + "operations": [ + "cancel" + ], + "options": [] } }, { @@ -25779,12 +25986,16 @@ "Submit the work and follow its job status" ], "agentExamplesZh": [ - "打开设备管理,我想把任务派发到另一台机器", - "带我配置远程任务派发" + "我有几台设备?", + "把这个任务派发到我的 MacBook 上跑", + "看看刚才派发的任务跑完没有", + "打开设备管理,我想把任务派发到另一台机器" ], "agentExamplesEn": [ - "Open device management so I can dispatch work to another machine", - "Take me to remote task dispatch setup" + "How many devices do I have?", + "Dispatch this task to my MacBook", + "Check whether the job I dispatched has finished", + "Open device management so I can dispatch work to another machine" ], "destination": { "kind": "event", @@ -25793,7 +26004,157 @@ "group": "account" } }, - "operations": [], + "operations": [ + { + "id": "list-targets", + "titleZh": "列出派发目标", + "titleEn": "List dispatch targets", + "descriptionZh": "读取同账户下所有 OpenBitFun 设备(含本机,带 isCurrentDevice 标记、在线状态、型号与系统)以及本机已保存的 SSH 连接。回答“有几台设备”或派发前先调用;未登录账户时 devices 为空并说明原因。", + "descriptionEn": "Read every OpenBitFun device on this account (including this one, marked isCurrentDevice, with online state, model and OS) plus this host's saved SSH connections. Call it before stating how many devices exist or before submitting; when signed out, devices is empty with a reason.", + "risk": "read", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object" + } + }, + { + "id": "submit", + "titleZh": "派发任务", + "titleEn": "Submit a dispatch job", + "descriptionZh": "把任务提交到另一台同账户设备(名称、别名或 device:)或 SSH 目标(ssh:)。目标在自己的 Git worktree 中从 baseRef(默认 HEAD)开始执行,本机可以断开。仅在用户明确要求派发时调用,并明确选择 approvalPolicy;离线设备直接报错,不会在本机执行。", + "descriptionEn": "Submit work to another same-account device (name, alias, or device:) or SSH target (ssh:). The target runs in its own Git worktree branched from baseRef (default HEAD) and this host may disconnect. Call only when the user asked to dispatch, and choose approvalPolicy explicitly; an offline device is an error, never a local run.", + "risk": "execute", + "inputSchema": { + "additionalProperties": false, + "properties": { + "agent": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "approvalPolicy": { + "enum": [ + "auto", + "reject-and-report" + ], + "type": "string" + }, + "baseRef": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "includeUncommitted": { + "type": "boolean" + }, + "model": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "prompt": { + "maxLength": 32768, + "minLength": 1, + "type": "string" + }, + "target": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "title": { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "workspacePath": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "target", + "prompt", + "approvalPolicy", + "workspacePath" + ], + "type": "object" + }, + "argumentScopes": { + "workspacePath": "productHostLocal" + } + }, + { + "id": "status", + "titleZh": "查询派发任务", + "titleEn": "Read dispatch job status", + "descriptionZh": "按 jobId 读取本机派发任务的状态与增量事件;cursor 传上次返回值以只取新事件。", + "descriptionEn": "Read state and incremental events of a job submitted from this host; pass the previous cursor to read only new events.", + "risk": "read", + "inputSchema": { + "additionalProperties": false, + "properties": { + "cursor": { + "minimum": 0, + "type": "integer" + }, + "jobId": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "jobId" + ], + "type": "object" + } + }, + { + "id": "list-jobs", + "titleZh": "列出派发任务", + "titleEn": "List dispatch jobs", + "descriptionZh": "不带 target 时列出本机提交过的任务;带 target 时直接询问该目标正在持有的任务。", + "descriptionEn": "Without target, list jobs submitted from this host; with target, ask that target for the jobs it owns.", + "risk": "read", + "inputSchema": { + "additionalProperties": false, + "properties": { + "target": { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + }, + "required": [], + "type": "object" + } + }, + { + "id": "cancel", + "titleZh": "取消派发任务", + "titleEn": "Cancel a dispatch job", + "descriptionZh": "取消本机提交的指定任务;目标端停止执行并保留记录。", + "descriptionEn": "Cancel a job submitted from this host; the target stops it and keeps its record.", + "risk": "destructive", + "inputSchema": { + "additionalProperties": false, + "properties": { + "jobId": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "jobId" + ], + "type": "object" + } + } + ], "options": [], "searchTerms": [ "feature.detached-dispatch", @@ -25810,32 +26171,38 @@ "remote execution", "target host", "offline job", - "发现与探测目标主机", - "派发、继续或取消任务", + "列出同账户设备与 SSH 目标", + "派发、查询或取消任务", "断线后重新加载事件与执行记录", - "Discover and probe target hosts", - "Dispatch, continue, or cancel jobs", + "List same-account devices and SSH targets", + "Dispatch, inspect, or cancel jobs", "Reload events and execution history after reconnecting", - "列出可派发目标并探测版本、连接和运行能力", - "List dispatch targets and probe their version, connectivity, and runtime capabilities", + "列出同账户设备与已保存的 SSH 目标", + "List same-account devices and saved SSH targets", "为目标主机配置运行环境并安装或更新 OpenBitFun CLI", "Provision the target runtime and install or update the OpenBitFun CLI", "在提交任务前同步目标所需的模型配置", "Synchronize required model configuration before submitting a job", - "把包含工作区、会话、模型和权限策略的持久任务提交到目标", - "Submit a durable job with workspace, session, model, and permission policy to the target", + "从当前 Git 工作区把持久任务提交到同账户设备或 SSH 目标", + "Submit a durable job from the current Git workspace to a same-account device or SSH target", "列出派发任务并查询运行状态、结果和错误", "List dispatched jobs and query their status, result, and errors", "保存、追加和重新加载可断线恢复的执行记录", "Save, append, and reload reconnectable execution transcripts", - "远程回答权限请求,并继续或取消目标端任务", - "Answer permission requests remotely and continue or cancel target-side work", + "远程回答权限请求并继续目标端任务", + "Answer permission requests remotely and continue target-side work", + "取消本机派发的任务", + "Cancel a job submitted from this host", "将目标端结果同步回控制端,而不把控制端当作远程文件系统代理", "Synchronize target-side results back without treating the controller as a remote-filesystem proxy", + "我有几台设备?", + "把这个任务派发到我的 MacBook 上跑", + "看看刚才派发的任务跑完没有", "打开设备管理,我想把任务派发到另一台机器", - "带我配置远程任务派发", - "Open device management so I can dispatch work to another machine", - "Take me to remote task dispatch setup" + "How many devices do I have?", + "Dispatch this task to my MacBook", + "Check whether the job I dispatched has finished", + "Open device management so I can dispatch work to another machine" ], "docsUrl": "https://playbook.openbitfun.com/capabilities/feature.detached-dispatch/" }, diff --git a/docs/interactive-capabilities/capabilities/feature.detached-dispatch.md b/docs/interactive-capabilities/capabilities/feature.detached-dispatch.md index 5ed12dc328..2b3d081bfd 100644 --- a/docs/interactive-capabilities/capabilities/feature.detached-dispatch.md +++ b/docs/interactive-capabilities/capabilities/feature.detached-dispatch.md @@ -17,20 +17,22 @@ Dispatch durable work to another OpenBitFun host; the controller may disconnect ## 完整功能清单 / Everything included -- **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 列出可派发目标并探测版本、连接和运行能力 - - List dispatch targets and probe their version, connectivity, and runtime capabilities +- **智能体可直接控制 / Direct Agent control** · 列出同账户设备与已保存的 SSH 目标 + - List same-account devices and saved SSH targets - **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 为目标主机配置运行环境并安装或更新 OpenBitFun CLI - Provision the target runtime and install or update the OpenBitFun CLI - **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 在提交任务前同步目标所需的模型配置 - Synchronize required model configuration before submitting a job -- **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 把包含工作区、会话、模型和权限策略的持久任务提交到目标 - - Submit a durable job with workspace, session, model, and permission policy to the target -- **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 列出派发任务并查询运行状态、结果和错误 +- **智能体可直接控制 / Direct Agent control** · 从当前 Git 工作区把持久任务提交到同账户设备或 SSH 目标 + - Submit a durable job from the current Git workspace to a same-account device or SSH target +- **智能体可直接控制 / Direct Agent control** · 列出派发任务并查询运行状态、结果和错误 - List dispatched jobs and query their status, result, and errors - **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 保存、追加和重新加载可断线恢复的执行记录 - Save, append, and reload reconnectable execution transcripts -- **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 远程回答权限请求,并继续或取消目标端任务 - - Answer permission requests remotely and continue or cancel target-side work +- **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 远程回答权限请求并继续目标端任务 + - Answer permission requests remotely and continue target-side work +- **智能体可直接控制 / Direct Agent control** · 取消本机派发的任务 + - Cancel a job submitted from this host - **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 将目标端结果同步回控制端,而不把控制端当作远程文件系统代理 - Synchronize target-side results back without treating the controller as a remote-filesystem proxy @@ -49,7 +51,11 @@ Dispatch durable work to another OpenBitFun host; the controller may disconnect | 操作 / Action | 中文说明 | English description | | --- | --- | --- | -| 打开对应界面 / Open the UI | 进入 OpenBitFun 中对应的功能界面。 | Open the matching feature in OpenBitFun. | +| 列出派发目标 / List dispatch targets | 读取同账户下所有 OpenBitFun 设备(含本机,带 isCurrentDevice 标记、在线状态、型号与系统)以及本机已保存的 SSH 连接。回答“有几台设备”或派发前先调用;未登录账户时 devices 为空并说明原因。 | Read every OpenBitFun device on this account (including this one, marked isCurrentDevice, with online state, model and OS) plus this host's saved SSH connections. Call it before stating how many devices exist or before submitting; when signed out, devices is empty with a reason. | +| 派发任务 / Submit a dispatch job | 把任务提交到另一台同账户设备(名称、别名或 device:)或 SSH 目标(ssh:)。目标在自己的 Git worktree 中从 baseRef(默认 HEAD)开始执行,本机可以断开。仅在用户明确要求派发时调用,并明确选择 approvalPolicy;离线设备直接报错,不会在本机执行。 | Submit work to another same-account device (name, alias, or device:) or SSH target (ssh:). The target runs in its own Git worktree branched from baseRef (default HEAD) and this host may disconnect. Call only when the user asked to dispatch, and choose approvalPolicy explicitly; an offline device is an error, never a local run. | +| 查询派发任务 / Read dispatch job status | 按 jobId 读取本机派发任务的状态与增量事件;cursor 传上次返回值以只取新事件。 | Read state and incremental events of a job submitted from this host; pass the previous cursor to read only new events. | +| 列出派发任务 / List dispatch jobs | 不带 target 时列出本机提交过的任务;带 target 时直接询问该目标正在持有的任务。 | Without target, list jobs submitted from this host; with target, ask that target for the jobs it owns. | +| 取消派发任务 / Cancel a dispatch job | 取消本机提交的指定任务;目标端停止执行并保留记录。 | Cancel a job submitted from this host; the target stops it and keeps its record. | ## 可配置选项 / Configurable options @@ -59,10 +65,14 @@ Dispatch durable work to another OpenBitFun host; the controller may disconnect ## 可以直接对智能体说 / Try saying +- “我有几台设备?” + - “How many devices do I have?” +- “把这个任务派发到我的 MacBook 上跑” + - “Dispatch this task to my MacBook” +- “看看刚才派发的任务跑完没有” + - “Check whether the job I dispatched has finished” - “打开设备管理,我想把任务派发到另一台机器” - “Open device management so I can dispatch work to another machine” -- “带我配置远程任务派发” - - “Take me to remote task dispatch setup” 智能体会先查找相关功能或设置,确认目标后再替你打开、执行或修改。完整能力目录不会预先塞进对话上下文。 diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json index d1f625fa37..c07ca6b736 100644 --- a/docs/interactive-capabilities/technical/product-control-open-audit.json +++ b/docs/interactive-capabilities/technical/product-control-open-audit.json @@ -1,12 +1,12 @@ { "schemaVersion": 1, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "2eb272d6820970e0c8ac677714ca03495729fa4a9cd458f1b89a4de726971107", - "count": 206, + "catalogDigest": "a4e451cd4268edcf15c00d41b4e5c8c6a476eb407e1324698d3db2b3f2f1ad34", + "count": 203, "reasonCounts": { "externalAuth": 4, "secretEntry": 5, - "unstructuredInteraction": 172, + "unstructuredInteraction": 169, "visualSelection": 25 }, "entries": [ @@ -2010,8 +2010,8 @@ "titleZh": "列出、连接、查看设备型号与系统、设置别名和删除同账户设备", "titleEn": "List, connect, inspect model and OS, set aliases, and remove same-account devices", "reasonCode": "unstructuredInteraction", - "reasonZh": "“列出、连接、查看设备型号与系统、设置别名和删除同账户设备”需要选择设备并确认操作。设备目录使用控制端的账户身份,不随 Peer Device 切换到目标主机。别名优先于技术名称显示,但不修改技术名称;型号与系统信息由设备自身上报。仅支持对应能力的 Relay 可更新或清除别名,旧 Relay 明确显示不支持。智能体打开设备管理入口,由用户确认后续交互。", - "reasonEn": "“List, connect, inspect model and OS, set aliases, and remove same-account devices” requires device selection and confirmation. The device directory uses the controller's account identity, not the selected Peer host. Aliases take display priority without changing technical names; model and OS metadata come from the device itself. Alias updates and clearing require Relay capability support; older Relays explicitly remain unsupported. The Agent opens device management and leaves subsequent interaction to the user.", + "reasonZh": "“列出、连接、查看设备型号与系统、设置别名和删除同账户设备”需要选择设备并确认操作。设备目录使用控制端的账户身份,不随 Peer Device 切换到目标主机。别名优先于技术名称显示,但不修改技术名称;型号与系统信息由设备自身上报。仅支持对应能力的 Relay 可更新或清除别名,旧 Relay 明确显示不支持。智能体打开设备管理入口,由用户确认后续交互。读取设备列表请调用 feature.detached-dispatch 的 list-targets。", + "reasonEn": "“List, connect, inspect model and OS, set aliases, and remove same-account devices” requires device selection and confirmation. The device directory uses the controller's account identity, not the selected Peer host. Aliases take display priority without changing technical names; model and OS metadata come from the device itself. Alias updates and clearing require Relay capability support; older Relays explicitly remain unsupported. The Agent opens device management and leaves subsequent interaction to the user. To read the device list, call list-targets on feature.detached-dispatch.", "presentationTarget": { "kind": "event", "eventName": "openbitfun:open-remote-connect" @@ -2046,26 +2046,6 @@ "command:peer_mode_ping" ] }, - { - "capabilityId": "feature.detached-dispatch", - "itemId": "targets", - "titleZh": "列出可派发目标并探测版本、连接和运行能力", - "titleEn": "List dispatch targets and probe their version, connectivity, and runtime capabilities", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“列出可派发目标并探测版本、连接和运行能力”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“List dispatch targets and probe their version, connectivity, and runtime capabilities” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user.", - "presentationTarget": { - "kind": "event", - "eventName": "openbitfun:open-remote-connect", - "detail": { - "group": "account" - } - }, - "evidence": [ - "command:dispatch_list_targets", - "command:dispatch_probe_target" - ] - }, { "capabilityId": "feature.detached-dispatch", "itemId": "provision", @@ -2107,46 +2087,6 @@ "command:dispatch_sync_model_config" ] }, - { - "capabilityId": "feature.detached-dispatch", - "itemId": "submit", - "titleZh": "把包含工作区、会话、模型和权限策略的持久任务提交到目标", - "titleEn": "Submit a durable job with workspace, session, model, and permission policy to the target", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“把包含工作区、会话、模型和权限策略的持久任务提交到目标”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Submit a durable job with workspace, session, model, and permission policy to the target” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user.", - "presentationTarget": { - "kind": "event", - "eventName": "openbitfun:open-remote-connect", - "detail": { - "group": "account" - } - }, - "evidence": [ - "command:dispatch_submit" - ] - }, - { - "capabilityId": "feature.detached-dispatch", - "itemId": "status-list", - "titleZh": "列出派发任务并查询运行状态、结果和错误", - "titleEn": "List dispatched jobs and query their status, result, and errors", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“列出派发任务并查询运行状态、结果和错误”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“List dispatched jobs and query their status, result, and errors” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user.", - "presentationTarget": { - "kind": "event", - "eventName": "openbitfun:open-remote-connect", - "detail": { - "group": "account" - } - }, - "evidence": [ - "command:dispatch_list_jobs", - "command:dispatch_status", - "command:dispatch_query" - ] - }, { "capabilityId": "feature.detached-dispatch", "itemId": "transcript", @@ -2171,11 +2111,11 @@ { "capabilityId": "feature.detached-dispatch", "itemId": "permission-continuation", - "titleZh": "远程回答权限请求,并继续或取消目标端任务", - "titleEn": "Answer permission requests remotely and continue or cancel target-side work", + "titleZh": "远程回答权限请求并继续目标端任务", + "titleEn": "Answer permission requests remotely and continue target-side work", "reasonCode": "unstructuredInteraction", - "reasonZh": "“远程回答权限请求,并继续或取消目标端任务”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Answer permission requests remotely and continue or cancel target-side work” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user.", + "reasonZh": "“远程回答权限请求并继续目标端任务”需要用户逐条确认权限与后续输入,目前没有能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", + "reasonEn": "“Answer permission requests remotely and continue target-side work” needs the user to confirm each permission and follow-up input and has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user.", "presentationTarget": { "kind": "event", "eventName": "openbitfun:open-remote-connect", @@ -2185,8 +2125,7 @@ }, "evidence": [ "command:dispatch_answer", - "command:dispatch_continue", - "command:dispatch_cancel" + "command:dispatch_continue" ] }, { diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index e8d4aa17d9..c562cd1c8c 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "2eb272d6820970e0c8ac677714ca03495729fa4a9cd458f1b89a4de726971107", + "catalogDigest": "a4e451cd4268edcf15c00d41b4e5c8c6a476eb407e1324698d3db2b3f2f1ad34", "commandCount": 707, "coverage": { "commandCount": 707, @@ -193,11 +193,13 @@ { "id": "account_list_devices", "moduleId": "remote_connect", - "capabilityId": "feature.remote-connect", + "capabilityId": null, "capabilityIds": [ + "feature.detached-dispatch", "feature.remote-connect" ], "documentedItemIds": [ + "feature.detached-dispatch:targets", "feature.remote-connect:devices" ], "visibility": "documented", @@ -2276,7 +2278,7 @@ "feature.detached-dispatch" ], "documentedItemIds": [ - "feature.detached-dispatch:permission-continuation" + "feature.detached-dispatch:cancel" ], "visibility": "documented", "rustPath": "api::dispatch_api::dispatch_cancel", diff --git a/scripts/generate-interactive-capabilities.mjs b/scripts/generate-interactive-capabilities.mjs index 622d4b14d6..7da1c6409d 100644 --- a/scripts/generate-interactive-capabilities.mjs +++ b/scripts/generate-interactive-capabilities.mjs @@ -1295,6 +1295,20 @@ function definitionAvailability(kind, handler) { if (handler?.kind === 'provider' && handler.providerId === 'remote-connect') { return { desktop, cli: availability(true), peer: peer('remote_connect_control_v1'), remoteControl: remote, detachedDispatch: availability(true) }; } + if (handler?.kind === 'provider' && handler.providerId === 'detached-dispatch') { + // A dispatch worker may observe jobs but must not fan out further work. + const nestedSubmit = handler.operationId === 'submit'; + return { + desktop, + cli: availability(true), + peer: peer('product_control_native_v1'), + remoteControl: remote, + detachedDispatch: availability( + !nestedSubmit, + 'A detached dispatch job cannot dispatch further work', + ), + }; + } const presentationOnly = handler?.kind === 'productAction'; const nativeProvider = handler?.kind === 'provider'; const headlessReason = presentationOnly diff --git a/src/apps/cli/src/dispatch/outbound.rs b/src/apps/cli/src/dispatch/outbound.rs index 45abe9fa94..950d35d4dc 100644 --- a/src/apps/cli/src/dispatch/outbound.rs +++ b/src/apps/cli/src/dispatch/outbound.rs @@ -2,9 +2,9 @@ //! //! `openbitfun dispatch outbound ...` submits jobs to another OpenBitFun host and //! observes them. The existing `openbitfun dispatch ` commands are the -//! target protocol and stay untouched. The controller semantics (baseline, -//! provisioning, observer records) live in the Core dispatch owner; this module -//! only resolves the CLI's SSH and account-device transports and prints JSON. +//! target protocol and stay untouched. This module and the CLI's Agent +//! product-control provider both run the Core dispatch-control executor; they +//! only supply this host's account session and SSH manager. use std::sync::Arc; @@ -12,19 +12,17 @@ use anyhow::{anyhow, bail, Context, Result}; use clap::{Subcommand, ValueEnum}; use openbitfun_core::infrastructure::try_get_path_manager_arc; use openbitfun_core::service::dispatch::{ - cancel_device_dispatch, cancel_dispatch, get_device_dispatch_status, get_dispatch_status, - list_device_dispatch_jobs, list_dispatch_jobs, list_dispatch_targets, submit_device_dispatch, - submit_dispatch, AccountRuntimeDeviceDispatchRpc, DispatchJobRequest, DispatchListJobsRequest, - DispatchListTargetsRequest, DispatchStatusRequest, DispatchSubmitRequest, DispatchTarget, - DispatchTargetOption, DispatchTargetRequest, OutboundDispatchStore, + execute_dispatch_control, AccountRuntimeDispatchAccess, DispatchControlContext, + OutboundDispatchStore, }; -use openbitfun_core::service::remote_connect::account_runtime::{AccountDevice, AccountRuntime}; +use openbitfun_core::service::remote_connect::account_runtime::AccountRuntime; use openbitfun_core::service::remote_connect::DeviceIdentity; -use openbitfun_core::service::remote_ssh::SSHConnectionManager; +use openbitfun_product_domains::detached_dispatch_control::{ + DetachedDispatchApprovalPolicy, DetachedDispatchControlRequest, + MAX_DETACHED_DISPATCH_PROMPT_BYTES, +}; use serde_json::Value; -const MAX_OUTBOUND_PROMPT_BYTES: u64 = 32 * 1024; - #[derive(Subcommand, Debug)] pub(crate) enum OutboundAction { /// List SSH connections and online account devices this machine can dispatch to @@ -86,11 +84,11 @@ pub(crate) enum OutboundApprovalPolicy { RejectAndReport, } -impl OutboundApprovalPolicy { - fn wire(self) -> &'static str { - match self { - Self::Auto => "auto", - Self::RejectAndReport => "reject-and-report", +impl From for DetachedDispatchApprovalPolicy { + fn from(policy: OutboundApprovalPolicy) -> Self { + match policy { + OutboundApprovalPolicy::Auto => Self::Auto, + OutboundApprovalPolicy::RejectAndReport => Self::RejectAndReport, } } } @@ -118,35 +116,8 @@ async fn run_action(action: OutboundAction) -> Result { openbitfun_core::service::config::initialize_global_config() .await .context("initialize global config")?; - let path_manager = try_get_path_manager_arc().context("resolve OpenBitFun data directory")?; - let store = OutboundDispatchStore::new(path_manager.as_ref()); - let context = OutboundContext::default(); - - match action { - OutboundAction::Targets => { - let mut targets = list_dispatch_targets( - &context.ssh_manager().await?, - DispatchListTargetsRequest::default(), - ) - .await?; - targets.retain(|target| target.kind != "local"); - match context.account_devices().await { - Ok(devices) => { - targets.extend(devices.into_iter().map(|device| DispatchTargetOption { - kind: "device".to_string(), - connection_id: None, - display_name: device_label(&device), - device_id: Some(device.device_id), - description: None, - online: Some(device.online), - })) - } - Err(error) => { - tracing::warn!("Account devices are unavailable for dispatch: {error:#}") - } - } - Ok(serde_json::to_value(targets)?) - } + let request = match action { + OutboundAction::Targets => DetachedDispatchControlRequest::ListTargets, OutboundAction::Submit { target, approval_policy, @@ -158,94 +129,63 @@ async fn run_action(action: OutboundAction) -> Result { title, workspace, } => { - let prompt = resolve_prompt(prompt)?; let workspace = match workspace { Some(path) => path, None => std::env::current_dir().context("resolve current directory")?, }; - let workspace = workspace - .canonicalize() + let workspace = std::path::absolute(&workspace) .with_context(|| format!("resolve workspace {}", workspace.display()))?; - let source_workspace_path = workspace - .to_str() - .ok_or_else(|| anyhow!("workspace path is not valid UTF-8"))? - .to_string(); - let resolved = context.resolve_target(&target).await?; - let request = DispatchSubmitRequest { - target: resolved.request(), - base_ref: Some(base_ref), - include_uncommitted, - job_id: uuid::Uuid::new_v4().to_string(), - session_id: uuid::Uuid::new_v4().to_string(), - agent_type: openbitfun_core_types::agent_identity::canonical_agent_id(&agent) + DetachedDispatchControlRequest::Submit { + target, + prompt: resolve_prompt(prompt)?, + approval_policy: approval_policy.into(), + workspace_path: workspace + .to_str() + .ok_or_else(|| anyhow!("workspace path is not valid UTF-8"))? .to_string(), - prompt, - approval_policy: approval_policy.wire().to_string(), + agent: Some(agent), model, - reasoning_preset: None, + base_ref: Some(base_ref), + include_uncommitted, title, - source_workspace_path: Some(source_workspace_path), - source_workspace_id: None, - attachments: Vec::new(), - }; - match resolved { - ResolvedTarget::Device { display_name, .. } => { - let rpc = context.device_rpc().await?; - submit_device_dispatch(&rpc, &store, display_name, request).await - } - ResolvedTarget::Ssh { .. } => { - submit_dispatch(&context.ssh_manager().await?, &store, request).await - } } } OutboundAction::Status { job_id, cursor } => { - let request = DispatchStatusRequest { job_id, cursor }; - if job_targets_device(&store, &request.job_id).await? { - get_device_dispatch_status(&context.device_rpc().await?, &store, request).await - } else { - get_dispatch_status(&context.ssh_manager().await?, &store, request).await - } - } - OutboundAction::Cancel { job_id } => { - let request = DispatchJobRequest { job_id }; - if job_targets_device(&store, &request.job_id).await? { - cancel_device_dispatch(&context.device_rpc().await?, &store, request).await - } else { - cancel_dispatch(&context.ssh_manager().await?, &store, request).await - } - } - OutboundAction::List { target } => { - let Some(target) = target else { - return list_dispatch_jobs( - &context.ssh_manager().await?, - &store, - DispatchListJobsRequest { target: None }, - ) - .await; - }; - let resolved = context.resolve_target(&target).await?; - let request = DispatchListJobsRequest { - target: Some(resolved.request()), - }; - match resolved { - ResolvedTarget::Device { display_name, .. } => { - let rpc = context.device_rpc().await?; - list_device_dispatch_jobs(&rpc, &store, display_name, request).await - } - ResolvedTarget::Ssh { .. } => { - list_dispatch_jobs(&context.ssh_manager().await?, &store, request).await - } - } + DetachedDispatchControlRequest::Status { job_id, cursor } } - } + OutboundAction::List { target } => DetachedDispatchControlRequest::ListJobs { target }, + OutboundAction::Cancel { job_id } => DetachedDispatchControlRequest::Cancel { job_id }, + }; + let account = crate::account::build_management_account_runtime(); + account.try_restore_session().await; + execute_for_host(account, request).await } -async fn job_targets_device(store: &OutboundDispatchStore, job_id: &str) -> Result { - let record = store - .get(job_id) - .await? - .ok_or_else(|| anyhow!("No outbound dispatch job {job_id} was submitted from here"))?; - Ok(matches!(record.target, DispatchTarget::Device { .. })) +/// Run one dispatch-control request with this host's account and SSH manager. +pub(crate) async fn execute_for_host( + account: Arc, + request: DetachedDispatchControlRequest, +) -> Result { + let path_manager = try_get_path_manager_arc().context("resolve OpenBitFun data directory")?; + let store = OutboundDispatchStore::new(path_manager.as_ref()); + let access = AccountRuntimeDispatchAccess::new(account); + let ssh = match openbitfun_core::service::remote_ssh::workspace_state::ensure_saved_connection_services().await + { + Ok(state) => state + .get_ssh_manager() + .await + .ok_or_else(|| "SSH manager is unavailable".to_string()), + Err(error) => Err(error), + }; + let context = DispatchControlContext { + store: &store, + ssh, + account: Some(&access), + local_device_id: DeviceIdentity::from_current_machine() + .map(|identity| identity.device_id) + .ok(), + }; + execute_dispatch_control(&context, request).await } fn resolve_prompt(prompt: Option) -> Result { @@ -259,7 +199,7 @@ fn resolve_prompt(prompt: Option) -> Result { } let mut raw = String::new(); stdin - .take(MAX_OUTBOUND_PROMPT_BYTES + 1) + .take(MAX_DETACHED_DISPATCH_PROMPT_BYTES as u64 + 1) .read_to_string(&mut raw) .context("read dispatch prompt from stdin")?; raw @@ -270,210 +210,3 @@ fn resolve_prompt(prompt: Option) -> Result { } Ok(prompt) } - -#[derive(Debug, Clone, PartialEq, Eq)] -enum ResolvedTarget { - Device { - device_id: String, - display_name: String, - }, - Ssh { - connection_id: String, - }, -} - -impl ResolvedTarget { - fn request(&self) -> DispatchTargetRequest { - // The target chooses where its worktree lands. - match self { - Self::Device { device_id, .. } => DispatchTargetRequest::Device { - device_id: device_id.clone(), - workspace_path: String::new(), - }, - Self::Ssh { connection_id } => DispatchTargetRequest::Ssh { - connection_id: connection_id.clone(), - workspace_path: String::new(), - }, - } - } -} - -fn device_label(device: &AccountDevice) -> String { - device - .device_alias - .as_deref() - .map(str::trim) - .filter(|alias| !alias.is_empty()) - .unwrap_or(&device.device_name) - .to_string() -} - -/// Resolve a `device:` selector or a device name/alias among the account's -/// other devices. Names must match exactly one device. -fn resolve_device_selector(selector: &str, devices: &[AccountDevice]) -> Result { - let selector = selector.trim(); - let matches: Vec<&AccountDevice> = match selector.strip_prefix("device:") { - Some(device_id) => devices - .iter() - .filter(|device| device.device_id == device_id.trim()) - .collect(), - None => { - let by_id: Vec<_> = devices - .iter() - .filter(|device| device.device_id == selector) - .collect(); - if by_id.is_empty() { - devices - .iter() - .filter(|device| { - device.device_name.eq_ignore_ascii_case(selector) - || device - .device_alias - .as_deref() - .is_some_and(|alias| alias.trim().eq_ignore_ascii_case(selector)) - }) - .collect() - } else { - by_id - } - } - }; - match matches.as_slice() { - [device] if device.online => Ok(ResolvedTarget::Device { - device_id: device.device_id.clone(), - display_name: device_label(device), - }), - [_] => bail!("Dispatch device {selector} is offline; no local fallback was attempted"), - [] => bail!( - "No other device on this account matches {selector}; run `dispatch outbound targets`" - ), - _ => bail!("Several account devices match {selector}; use device:"), - } -} - -#[derive(Default)] -struct OutboundContext { - account: tokio::sync::OnceCell>, -} - -impl OutboundContext { - async fn ssh_manager(&self) -> Result { - let state = - openbitfun_core::service::remote_ssh::workspace_state::ensure_saved_connection_services() - .await - .map_err(|error| anyhow!("SSH services are unavailable: {error}"))?; - state - .get_ssh_manager() - .await - .ok_or_else(|| anyhow!("SSH manager is unavailable")) - } - - async fn account(&self) -> Result> { - let account = self - .account - .get_or_init(|| async { - let account = crate::account::build_management_account_runtime(); - account.try_restore_session().await; - account - }) - .await - .clone(); - if !account.is_logged_in().await { - bail!("This machine is not signed in to an account; device dispatch is unavailable"); - } - Ok(account) - } - - async fn device_rpc(&self) -> Result { - Ok(AccountRuntimeDeviceDispatchRpc::new(self.account().await?)) - } - - /// Account devices other than this machine. - async fn account_devices(&self) -> Result> { - let devices = self.account().await?.list_devices().await?; - let local_device_id = DeviceIdentity::from_current_machine() - .map(|identity| identity.device_id) - .ok(); - Ok(devices - .into_iter() - .filter(|device| local_device_id.as_deref() != Some(device.device_id.as_str())) - .collect()) - } - - async fn resolve_target(&self, selector: &str) -> Result { - if let Some(connection_id) = selector.trim().strip_prefix("ssh:") { - let connection_id = connection_id.trim(); - if connection_id.is_empty() { - bail!("ssh: targets need a saved connection id"); - } - return Ok(ResolvedTarget::Ssh { - connection_id: connection_id.to_string(), - }); - } - resolve_device_selector(selector, &self.account_devices().await?) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn device(id: &str, name: &str, alias: Option<&str>, online: bool) -> AccountDevice { - AccountDevice { - device_id: id.to_string(), - device_name: name.to_string(), - device_kind: Some("cli".to_string()), - device_alias: alias.map(str::to_string), - device_model: None, - device_os: None, - device_os_version: None, - device_client_version: None, - device_client_protocol: None, - compatible: None, - online, - } - } - - #[test] - fn device_selector_resolves_ids_names_and_aliases() { - let devices = vec![ - device("mac-id", "MacBook-Pro-2.local", Some("Office Mac"), true), - device("ecs-id", "ecs-box", None, true), - device("old-id", "old-laptop", None, false), - ]; - let expected = ResolvedTarget::Device { - device_id: "mac-id".to_string(), - display_name: "Office Mac".to_string(), - }; - assert_eq!( - resolve_device_selector("device:mac-id", &devices).unwrap(), - expected - ); - assert_eq!( - resolve_device_selector("mac-id", &devices).unwrap(), - expected - ); - assert_eq!( - resolve_device_selector("macbook-pro-2.local", &devices).unwrap(), - expected - ); - assert_eq!( - resolve_device_selector("office mac", &devices).unwrap(), - expected - ); - - let offline = resolve_device_selector("old-laptop", &devices).unwrap_err(); - assert!(offline.to_string().contains("offline"), "{offline}"); - assert!(resolve_device_selector("missing", &devices).is_err()); - } - - #[test] - fn device_selector_rejects_ambiguous_names() { - let devices = vec![ - device("a", "build", None, true), - device("b", "build", None, true), - ]; - let error = resolve_device_selector("build", &devices).unwrap_err(); - assert!(error.to_string().contains("device:"), "{error}"); - } -} diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 37720329f8..12f09eb376 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -1012,7 +1012,7 @@ async fn initialize_core_services_for_deployment( )?); // Restore on the executing host for TUI, exec, Shared and detached jobs alike. runtime.account_runtime().try_restore_session().await; - remote_control::install(&runtime); + remote_control::install(&runtime, bootstrap_profile); debug_assert!(runtime .product() .service_availability() diff --git a/src/apps/cli/src/remote_control.rs b/src/apps/cli/src/remote_control.rs index 74a680f3c9..d40888fdeb 100644 --- a/src/apps/cli/src/remote_control.rs +++ b/src/apps/cli/src/remote_control.rs @@ -16,6 +16,9 @@ use openbitfun_core::service::remote_connect::{ relay_client::ConnectionState, AccountClient, DeviceIdentity, QrGenerator, RemoteConnectConfig, RemoteConnectService, }; +use openbitfun_product_domains::detached_dispatch_control::{ + DetachedDispatchControlRequest, DETACHED_DISPATCH_CONTROL_PROVIDER, +}; use openbitfun_product_domains::product_control::{ capability, discover, validate_operation_arguments, ProductCapabilityOperationHandler, ProductControlAction, ProductControlDeliveryProfile, ProductControlFuture, ProductControlPort, @@ -35,9 +38,10 @@ pub(crate) fn daemon_host() -> Option> { DAEMON_HOST.get().cloned() } -pub(crate) fn install(runtime: &Arc) { +pub(crate) fn install(runtime: &Arc, profile: crate::BootstrapProfile) { set_openbitfun_control_port(Arc::new(CliProductControlPort { runtime: Arc::downgrade(runtime), + dispatch_worker: matches!(profile, crate::BootstrapProfile::Detached), })); } @@ -223,6 +227,8 @@ impl EmbeddedRelayHost for CliEmbeddedRelayUnavailable { struct CliProductControlPort { runtime: Weak, + /// A detached dispatch worker may observe jobs but not dispatch further. + dispatch_worker: bool, } impl ProductControlPort for CliProductControlPort { @@ -243,6 +249,24 @@ impl ProductControlPort for CliProductControlPort { .await? .inspect(id, ProductControlDeliveryProfile::Cli) .await?; + if id == "feature.detached-dispatch" { + let worker = self.dispatch_worker; + result["operationAvailability"] = Value::Object( + capability(id)? + .operations + .iter() + .map(|operation| { + let status = if worker && operation.id == "submit" { + json!({"status":"unavailable", "reason":"A detached dispatch job cannot dispatch further work"}) + } else { + json!({"status":"available", "owner":"cli-host"}) + }; + (operation.id.clone(), status) + }) + .collect(), + ); + result["controlAvailability"] = json!({"status":"available", "adapter":"cli-host", "readBack":true, "actions":{"execute":{"status":"available"}}}); + } if id == "feature.remote-connect" { result["operationAvailability"] = Value::Object( capability(id)? @@ -304,6 +328,21 @@ impl ProductControlPort for CliProductControlPort { else { return Err("product_control_native_operation_unavailable: CLI has no provider for this operation".into()); }; + if provider_id == DETACHED_DISPATCH_CONTROL_PROVIDER { + let command = DetachedDispatchControlRequest::from_operation( + operation_id, + request.arguments.as_ref(), + )?; + if self.dispatch_worker { + command.ensure_allowed_in_dispatch_worker()?; + } + return crate::dispatch::outbound::execute_for_host( + runtime.account_runtime().clone(), + command, + ) + .await + .map_err(|error| format!("{error:#}")); + } if provider_id != REMOTE_CONTROL_PROVIDER { return Err("product_control_native_operation_unavailable: CLI has no provider for this operation".into()); } @@ -319,3 +358,39 @@ impl ProductControlPort for CliProductControlPort { }) } } + +#[cfg(test)] +mod product_control_binding_tests { + use openbitfun_product_domains::detached_dispatch_control::DETACHED_DISPATCH_CONTROL_PROVIDER; + use openbitfun_product_domains::product_control::{ + ProductCapabilityOperationHandler, ProductControlDeliveryProfile, ProductControlRegistry, + }; + use openbitfun_product_domains::remote_control::REMOTE_CONTROL_PROVIDER; + + #[test] + fn every_cli_available_provider_operation_has_a_cli_binding() { + let registry = ProductControlRegistry::global(); + let catalog = openbitfun_product_domains::product_control::catalog().unwrap(); + for capability in &catalog.capabilities { + for operation in &capability.operations { + let ProductCapabilityOperationHandler::Provider { provider_id, .. } = + &operation.handler + else { + continue; + }; + let definition = registry + .definition(&format!("{}:operation:{}", capability.id, operation.id)) + .unwrap(); + if definition.availability[&ProductControlDeliveryProfile::Cli].available { + assert!( + provider_id == REMOTE_CONTROL_PROVIDER + || provider_id == DETACHED_DISPATCH_CONTROL_PROVIDER, + "CLI-available provider {provider_id} for {}:{} has no CLI binding", + capability.id, + operation.id + ); + } + } + } + } +} diff --git a/src/apps/desktop/src/api/dispatch_api.rs b/src/apps/desktop/src/api/dispatch_api.rs index a1295d0638..40f6dbad1a 100644 --- a/src/apps/desktop/src/api/dispatch_api.rs +++ b/src/apps/desktop/src/api/dispatch_api.rs @@ -25,9 +25,14 @@ use openbitfun_core::service::dispatch::{ DispatchSubmitRequest, DispatchSyncResultRequest, DispatchTarget, DispatchTargetOption, DispatchTargetRequest, DispatchTranscriptRequest, OutboundDispatchStore, }; +use openbitfun_core::service::dispatch::{ + execute_dispatch_control, DispatchAccountAccess, DispatchControlContext, +}; +use openbitfun_core::service::remote_connect::account_runtime::AccountDevice; use openbitfun_core::service::remote_ssh::dispatch_ssh::{ self, DispatchInstallPoll, DispatchInstallStart, DispatchSshProbe, }; +use openbitfun_product_domains::detached_dispatch_control::DetachedDispatchControlRequest; use serde::{Deserialize, Serialize}; use serde_json::Value; use tauri::State; @@ -51,6 +56,64 @@ impl DeviceDispatchRpc for AccountDeviceDispatchRpc { } } +/// Desktop account session for the shared Agent dispatch-control executor. +struct DesktopDispatchAccountAccess; + +#[async_trait] +impl DispatchAccountAccess for DesktopDispatchAccountAccess { + async fn list_devices(&self) -> anyhow::Result> { + let devices = super::remote_connect_api::account_list_devices() + .await + .map_err(anyhow::Error::msg)?; + Ok(devices + .into_iter() + .map(|device| AccountDevice { + device_id: device.device_id, + device_name: device.device_name, + device_kind: device.device_kind, + device_alias: device.device_alias, + device_model: device.device_model, + device_os: device.device_os, + device_os_version: device.device_os_version, + device_client_version: device.device_client_version, + device_client_protocol: device.device_client_protocol, + compatible: device.compatible, + online: device.online, + }) + .collect()) + } + + fn device_rpc(&self) -> Arc { + Arc::new(AccountDeviceDispatchRpc) + } +} + +/// Run one Agent product-control dispatch request on this desktop. +pub(crate) async fn execute_agent_dispatch_control( + state: &AppState, + path_manager: &PathManager, + request: DetachedDispatchControlRequest, +) -> Result { + let store = OutboundDispatchStore::new(path_manager); + let ssh = state + .get_ssh_manager_async() + .await + .map_err(|error| error.to_string()); + let local_device_id = super::remote_connect_api::remote_connect_get_device_info() + .await + .ok() + .map(|device| device.device_id); + let context = DispatchControlContext { + store: &store, + ssh, + account: Some(&DesktopDispatchAccountAccess), + local_device_id, + }; + execute_dispatch_control(&context, request) + .await + .map_err(|error| format!("{error:#}")) +} + #[tauri::command] pub async fn dispatch_list_targets( state: State<'_, AppState>, diff --git a/src/apps/desktop/src/openbitfun_control_host.rs b/src/apps/desktop/src/openbitfun_control_host.rs index 5c05b97e1d..4d2279494b 100644 --- a/src/apps/desktop/src/openbitfun_control_host.rs +++ b/src/apps/desktop/src/openbitfun_control_host.rs @@ -856,6 +856,7 @@ enum DesktopProviderOperation { MiniApp(miniapps::Operation), Mcp(openbitfun_core::service::mcp::control::MCPControlOperation), RemoteConnect(&'static str), + DetachedDispatch(&'static str), ListCompanionPets, UseCompanionPet, DeleteCompanionPet, @@ -878,6 +879,10 @@ fn desktop_provider_operation( .into_iter() .find(|operation| *operation == id) .map(DesktopProviderOperation::RemoteConnect), + ("detached-dispatch", id) => ["list-targets", "submit", "status", "list-jobs", "cancel"] + .into_iter() + .find(|operation| *operation == id) + .map(DesktopProviderOperation::DetachedDispatch), ("miniapp", id) => miniapps::operation(id).map(DesktopProviderOperation::MiniApp), ("mcp", id) => { openbitfun_core::service::mcp::control::operation(id).map(DesktopProviderOperation::Mcp) @@ -923,6 +928,19 @@ async fn execute_desktop_provider_operation( )?; crate::api::remote_connect_api::execute_remote_control(request).await } + DesktopProviderOperation::DetachedDispatch(operation) => { + let request = openbitfun_product_domains::detached_dispatch_control::DetachedDispatchControlRequest::from_operation( + operation, arguments, + )?; + let path_manager = + app.state::>(); + crate::api::dispatch_api::execute_agent_dispatch_control( + &state, + path_manager.as_ref(), + request, + ) + .await + } DesktopProviderOperation::Mcp(operation) => { let mut result = state .mcp_service @@ -1098,7 +1116,14 @@ async fn execute_desktop( // Reads stay concurrent. Every mutating product operation shares the same // transaction coordinator as GUI and Agent setting mutations, so revision // order matches the order in which product state/effects are committed. - let _transaction = if operation.risk == ProductControlRisk::Read { + // Dispatch jobs change no product settings, and a submit can spend minutes + // uploading its baseline; holding the transaction would stall settings. + let dispatch_job = matches!( + &operation.handler, + ProductCapabilityOperationHandler::Provider { provider_id, .. } + if provider_id == openbitfun_product_domains::detached_dispatch_control::DETACHED_DISPATCH_CONTROL_PROVIDER + ); + let _transaction = if operation.risk == ProductControlRisk::Read || dispatch_job { None } else { Some(product_control_transaction().lock().await) diff --git a/src/crates/assembly/core/src/service/dispatch/agent_control.rs b/src/crates/assembly/core/src/service/dispatch/agent_control.rs new file mode 100644 index 0000000000..310ee93c13 --- /dev/null +++ b/src/crates/assembly/core/src/service/dispatch/agent_control.rs @@ -0,0 +1,559 @@ +//! Agent-facing Detached Dispatch controller shared by every product host. +//! +//! Desktop and CLI bind the `detached-dispatch` product-control provider to +//! [`execute_dispatch_control`]; the CLI `dispatch outbound` command reuses the +//! same target resolution. Hosts supply only their account session and SSH +//! manager, so device discovery, target resolution, and routing cannot drift. + +use std::sync::Arc; + +use anyhow::{anyhow, bail}; +use async_trait::async_trait; +use openbitfun_product_domains::detached_dispatch_control::{ + DetachedDispatchControlRequest, MAX_DETACHED_DISPATCH_PROMPT_BYTES, +}; +use serde_json::{json, Value}; + +use super::{ + cancel_device_dispatch, cancel_dispatch, get_device_dispatch_status, get_dispatch_status, + list_device_dispatch_jobs, list_dispatch_jobs, list_dispatch_targets, submit_device_dispatch, + submit_dispatch, AccountRuntimeDeviceDispatchRpc, DeviceDispatchRpc, DispatchJobRequest, + DispatchListJobsRequest, DispatchListTargetsRequest, DispatchStatusRequest, + DispatchSubmitRequest, DispatchTarget, DispatchTargetRequest, OutboundDispatchStore, +}; +use crate::service::remote_connect::account_runtime::{AccountDevice, AccountRuntime}; +use crate::service::remote_ssh::SSHConnectionManager; + +/// The executing host's account session, as seen by the dispatch controller. +#[async_trait] +pub trait DispatchAccountAccess: Send + Sync { + /// Every device on the account, this host included. Errors when signed out. + async fn list_devices(&self) -> anyhow::Result>; + fn device_rpc(&self) -> Arc; +} + +/// [`DispatchAccountAccess`] over a Core [`AccountRuntime`]. +pub struct AccountRuntimeDispatchAccess { + account: Arc, +} + +impl AccountRuntimeDispatchAccess { + pub fn new(account: Arc) -> Self { + Self { account } + } +} + +#[async_trait] +impl DispatchAccountAccess for AccountRuntimeDispatchAccess { + async fn list_devices(&self) -> anyhow::Result> { + if !self.account.is_logged_in().await { + bail!("This host is not signed in to an OpenBitFun account"); + } + self.account.list_devices().await + } + + fn device_rpc(&self) -> Arc { + Arc::new(AccountRuntimeDeviceDispatchRpc::new(self.account.clone())) + } +} + +/// Host resources for one dispatch-control call. +pub struct DispatchControlContext<'a> { + pub store: &'a OutboundDispatchStore, + /// `Err` carries why SSH targets are unavailable on this host. + pub ssh: Result, + /// `None` when this host has no account runtime at all. + pub account: Option<&'a dyn DispatchAccountAccess>, + /// This host's device identity, used to mark and exclude itself. + pub local_device_id: Option, +} + +/// A target resolved from a user- or Agent-supplied selector. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolvedDispatchTarget { + Device { + device_id: String, + display_name: String, + }, + Ssh { + connection_id: String, + }, +} + +impl ResolvedDispatchTarget { + pub fn request(&self) -> DispatchTargetRequest { + // The target chooses where its worktree lands. + match self { + Self::Device { device_id, .. } => DispatchTargetRequest::Device { + device_id: device_id.clone(), + workspace_path: String::new(), + }, + Self::Ssh { connection_id } => DispatchTargetRequest::Ssh { + connection_id: connection_id.clone(), + workspace_path: String::new(), + }, + } + } +} + +pub fn device_label(device: &AccountDevice) -> String { + device + .device_alias + .as_deref() + .map(str::trim) + .filter(|alias| !alias.is_empty()) + .unwrap_or(&device.device_name) + .to_string() +} + +/// Resolve `ssh:`, `device:`, a device ID, or an +/// exact (case-insensitive) device name or alias. `devices` must already +/// exclude this host. Names must match exactly one device. +pub fn resolve_dispatch_target( + selector: &str, + devices: &[AccountDevice], +) -> anyhow::Result { + let selector = selector.trim(); + if let Some(connection_id) = selector.strip_prefix("ssh:") { + let connection_id = connection_id.trim(); + if connection_id.is_empty() { + bail!("ssh: targets need a saved connection id"); + } + return Ok(ResolvedDispatchTarget::Ssh { + connection_id: connection_id.to_string(), + }); + } + let matches: Vec<&AccountDevice> = match selector.strip_prefix("device:") { + Some(device_id) => devices + .iter() + .filter(|device| device.device_id == device_id.trim()) + .collect(), + None => { + let by_id: Vec<_> = devices + .iter() + .filter(|device| device.device_id == selector) + .collect(); + if by_id.is_empty() { + devices + .iter() + .filter(|device| { + device.device_name.eq_ignore_ascii_case(selector) + || device + .device_alias + .as_deref() + .is_some_and(|alias| alias.trim().eq_ignore_ascii_case(selector)) + }) + .collect() + } else { + by_id + } + } + }; + match matches.as_slice() { + [device] if device.online => Ok(ResolvedDispatchTarget::Device { + device_id: device.device_id.clone(), + display_name: device_label(device), + }), + [_] => bail!("Dispatch device {selector} is offline; no local fallback was attempted"), + [] => bail!( + "No other device on this account matches {selector}; list targets first and use a device name, device:, or ssh:" + ), + _ => bail!("Several account devices match {selector}; use device:"), + } +} + +fn device_json(device: &AccountDevice, local_device_id: Option<&str>) -> Value { + json!({ + "deviceId": device.device_id, + "name": device_label(device), + "deviceName": device.device_name, + "alias": device.device_alias, + "kind": device.device_kind, + "online": device.online, + "isCurrentDevice": local_device_id == Some(device.device_id.as_str()), + "model": device.device_model, + "os": device.device_os, + "osVersion": device.device_os_version, + "compatible": device.compatible, + }) +} + +impl DispatchControlContext<'_> { + fn account(&self) -> anyhow::Result<&dyn DispatchAccountAccess> { + self.account + .ok_or_else(|| anyhow!("This host has no OpenBitFun account runtime")) + } + + fn ssh(&self) -> anyhow::Result<&SSHConnectionManager> { + self.ssh + .as_ref() + .map_err(|error| anyhow!("SSH dispatch is unavailable: {error}")) + } + + /// Account devices other than this host. + async fn other_devices(&self) -> anyhow::Result> { + let local = self.local_device_id.as_deref(); + Ok(self + .account()? + .list_devices() + .await? + .into_iter() + .filter(|device| local != Some(device.device_id.as_str())) + .collect()) + } + + pub async fn resolve_target(&self, selector: &str) -> anyhow::Result { + if selector.trim().starts_with("ssh:") { + return resolve_dispatch_target(selector, &[]); + } + resolve_dispatch_target(selector, &self.other_devices().await?) + } + + async fn job_targets_device(&self, job_id: &str) -> anyhow::Result { + let record = self + .store + .get(job_id) + .await? + .ok_or_else(|| anyhow!("No dispatch job {job_id} was submitted from this host"))?; + Ok(matches!(record.target, DispatchTarget::Device { .. })) + } + + async fn list_targets(&self) -> Value { + let local = self.local_device_id.as_deref(); + let mut result = json!({ + "submitTargetForms": ["", "device:", "ssh:"], + }); + match self.account() { + Ok(account) => match account.list_devices().await { + Ok(devices) => { + let others = devices + .iter() + .filter(|device| local != Some(device.device_id.as_str())); + result["signedIn"] = json!(true); + result["deviceCount"] = json!(devices.len()); + result["otherOnlineDeviceCount"] = + json!(others.filter(|device| device.online).count()); + result["devices"] = devices + .iter() + .map(|device| device_json(device, local)) + .collect(); + } + Err(error) => { + result["signedIn"] = json!(false); + result["devices"] = json!([]); + result["devicesUnavailable"] = json!(format!("{error:#}")); + } + }, + Err(error) => { + result["signedIn"] = json!(false); + result["devices"] = json!([]); + result["devicesUnavailable"] = json!(error.to_string()); + } + } + match self.ssh() { + Ok(ssh) => { + match list_dispatch_targets(ssh, DispatchListTargetsRequest::default()).await { + Ok(targets) => { + result["sshTargets"] = targets + .into_iter() + .filter(|target| target.kind == "ssh") + .map(|target| { + json!({ + "target": format!("ssh:{}", target.connection_id.clone().unwrap_or_default()), + "connectionId": target.connection_id, + "name": target.display_name, + "description": target.description, + }) + }) + .collect(); + } + Err(error) => { + result["sshTargets"] = json!([]); + result["sshUnavailable"] = json!(format!("{error:#}")); + } + } + } + Err(error) => { + result["sshTargets"] = json!([]); + result["sshUnavailable"] = json!(error.to_string()); + } + } + result + } +} + +/// Execute one Agent-facing dispatch-control request on this host. +pub async fn execute_dispatch_control( + context: &DispatchControlContext<'_>, + request: DetachedDispatchControlRequest, +) -> anyhow::Result { + match request { + DetachedDispatchControlRequest::ListTargets => Ok(context.list_targets().await), + DetachedDispatchControlRequest::Submit { + target, + prompt, + approval_policy, + workspace_path, + agent, + model, + base_ref, + include_uncommitted, + title, + } => { + if prompt.trim().is_empty() { + bail!("Dispatch prompt cannot be empty"); + } + if prompt.len() > MAX_DETACHED_DISPATCH_PROMPT_BYTES { + bail!("Dispatch prompt exceeds the 32 KiB request limit"); + } + let workspace = std::path::Path::new(workspace_path.trim()); + if !workspace.is_absolute() { + bail!("workspacePath must be an absolute path on this host"); + } + let workspace = workspace + .canonicalize() + .map_err(|error| anyhow!("resolve workspacePath: {error}"))?; + let source_workspace_path = workspace + .to_str() + .ok_or_else(|| anyhow!("workspacePath is not valid UTF-8"))? + .to_string(); + let resolved = context.resolve_target(&target).await?; + let request = DispatchSubmitRequest { + target: resolved.request(), + base_ref: Some(base_ref.unwrap_or_else(|| "HEAD".to_string())), + include_uncommitted, + job_id: uuid::Uuid::new_v4().to_string(), + session_id: uuid::Uuid::new_v4().to_string(), + agent_type: openbitfun_core_types::agent_identity::canonical_agent_id( + agent.as_deref().unwrap_or("Standard"), + ) + .to_string(), + prompt, + approval_policy: approval_policy.wire().to_string(), + model, + reasoning_preset: None, + title, + source_workspace_path: Some(source_workspace_path), + source_workspace_id: None, + attachments: Vec::new(), + }; + let job_id = request.job_id.clone(); + let session_id = request.session_id.clone(); + let ack = match &resolved { + ResolvedDispatchTarget::Device { display_name, .. } => { + let rpc = context.account()?.device_rpc(); + submit_device_dispatch( + rpc.as_ref(), + context.store, + display_name.clone(), + request, + ) + .await? + } + ResolvedDispatchTarget::Ssh { .. } => { + submit_dispatch(context.ssh()?, context.store, request).await? + } + }; + let target = match &resolved { + ResolvedDispatchTarget::Device { + device_id, + display_name, + } => json!({"kind":"device", "deviceId": device_id, "name": display_name}), + ResolvedDispatchTarget::Ssh { connection_id } => { + json!({"kind":"ssh", "connectionId": connection_id}) + } + }; + Ok(json!({ + "jobId": job_id, + "sessionId": session_id, + "target": target, + "acknowledgement": ack, + "nextStep": "Use status with this jobId to follow progress; the target keeps running if this host disconnects.", + })) + } + DetachedDispatchControlRequest::Status { job_id, cursor } => { + let request = DispatchStatusRequest { job_id, cursor }; + if context.job_targets_device(&request.job_id).await? { + let rpc = context.account()?.device_rpc(); + get_device_dispatch_status(rpc.as_ref(), context.store, request).await + } else { + get_dispatch_status(context.ssh()?, context.store, request).await + } + } + DetachedDispatchControlRequest::Cancel { job_id } => { + let request = DispatchJobRequest { job_id }; + if context.job_targets_device(&request.job_id).await? { + let rpc = context.account()?.device_rpc(); + cancel_device_dispatch(rpc.as_ref(), context.store, request).await + } else { + cancel_dispatch(context.ssh()?, context.store, request).await + } + } + DetachedDispatchControlRequest::ListJobs { target } => { + let Some(target) = target else { + // The local outbound index needs no transport. + return Ok(serde_json::to_value(context.store.list().await?)?); + }; + let resolved = context.resolve_target(&target).await?; + let request = DispatchListJobsRequest { + target: Some(resolved.request()), + }; + match resolved { + ResolvedDispatchTarget::Device { display_name, .. } => { + let rpc = context.account()?.device_rpc(); + list_device_dispatch_jobs(rpc.as_ref(), context.store, display_name, request) + .await + } + ResolvedDispatchTarget::Ssh { .. } => { + list_dispatch_jobs(context.ssh()?, context.store, request).await + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn device(id: &str, name: &str, alias: Option<&str>, online: bool) -> AccountDevice { + AccountDevice { + device_id: id.to_string(), + device_name: name.to_string(), + device_kind: Some("cli".to_string()), + device_alias: alias.map(str::to_string), + device_model: None, + device_os: None, + device_os_version: None, + device_client_version: None, + device_client_protocol: None, + compatible: None, + online, + } + } + + #[test] + fn target_selector_resolves_ids_names_aliases_and_ssh() { + let devices = vec![ + device("mac-id", "MacBook-Pro-2.local", Some("Office Mac"), true), + device("ecs-id", "ecs-box", None, true), + device("old-id", "old-laptop", None, false), + ]; + let expected = ResolvedDispatchTarget::Device { + device_id: "mac-id".to_string(), + display_name: "Office Mac".to_string(), + }; + for selector in [ + "device:mac-id", + "mac-id", + "macbook-pro-2.local", + "office mac", + ] { + assert_eq!( + resolve_dispatch_target(selector, &devices).unwrap(), + expected + ); + } + assert_eq!( + resolve_dispatch_target("ssh:a100", &devices).unwrap(), + ResolvedDispatchTarget::Ssh { + connection_id: "a100".to_string() + } + ); + let offline = resolve_dispatch_target("old-laptop", &devices).unwrap_err(); + assert!(offline.to_string().contains("offline"), "{offline}"); + assert!(resolve_dispatch_target("missing", &devices).is_err()); + assert!(resolve_dispatch_target("ssh:", &devices).is_err()); + let duplicate = vec![ + device("a", "build", None, true), + device("b", "build", None, true), + ]; + let error = resolve_dispatch_target("build", &duplicate).unwrap_err(); + assert!(error.to_string().contains("device:"), "{error}"); + } + + struct FixedDevices(Vec); + + struct NoRpc; + #[async_trait] + impl DeviceDispatchRpc for NoRpc { + async fn invoke(&self, _: &str, _: &str, _: Value) -> anyhow::Result { + bail!("no transport in this test") + } + } + + #[async_trait] + impl DispatchAccountAccess for FixedDevices { + async fn list_devices(&self) -> anyhow::Result> { + Ok(self.0.clone()) + } + fn device_rpc(&self) -> Arc { + Arc::new(NoRpc) + } + } + + #[tokio::test] + async fn list_targets_counts_every_device_and_marks_this_host() { + let dir = tempfile::tempdir().unwrap(); + let path_manager = + crate::infrastructure::PathManager::with_user_root_for_tests(dir.path().to_path_buf()); + let store = OutboundDispatchStore::new(&path_manager); + let account = FixedDevices(vec![ + device("self-id", "ecs-openbitfun-personal-lwb", None, true), + device("mac-id", "MacBook-Pro-2.local", None, true), + ]); + let context = DispatchControlContext { + store: &store, + ssh: Err("not configured in this test".to_string()), + account: Some(&account), + local_device_id: Some("self-id".to_string()), + }; + let result = + execute_dispatch_control(&context, DetachedDispatchControlRequest::ListTargets) + .await + .unwrap(); + assert_eq!(result["deviceCount"], 2); + assert_eq!(result["otherOnlineDeviceCount"], 1); + assert_eq!(result["devices"][0]["isCurrentDevice"], true); + assert_eq!(result["devices"][1]["isCurrentDevice"], false); + assert!(result["sshUnavailable"].is_string()); + + // A host never resolves itself as a dispatch target. + assert!(context + .resolve_target("ecs-openbitfun-personal-lwb") + .await + .is_err()); + assert!(context.resolve_target("MacBook-Pro-2.local").await.is_ok()); + + let unknown = execute_dispatch_control( + &context, + DetachedDispatchControlRequest::Status { + job_id: "missing".to_string(), + cursor: 0, + }, + ) + .await + .unwrap_err(); + assert!(unknown.to_string().contains("No dispatch job"), "{unknown}"); + } + + #[tokio::test] + async fn signed_out_hosts_report_why_devices_are_missing() { + let dir = tempfile::tempdir().unwrap(); + let path_manager = + crate::infrastructure::PathManager::with_user_root_for_tests(dir.path().to_path_buf()); + let store = OutboundDispatchStore::new(&path_manager); + let context = DispatchControlContext { + store: &store, + ssh: Err("not configured".to_string()), + account: None, + local_device_id: None, + }; + let result = + execute_dispatch_control(&context, DetachedDispatchControlRequest::ListTargets) + .await + .unwrap(); + assert_eq!(result["signedIn"], false); + assert!(result["devicesUnavailable"].is_string()); + } +} diff --git a/src/crates/assembly/core/src/service/dispatch/mod.rs b/src/crates/assembly/core/src/service/dispatch/mod.rs index 585faae146..82db3a1b8a 100644 --- a/src/crates/assembly/core/src/service/dispatch/mod.rs +++ b/src/crates/assembly/core/src/service/dispatch/mod.rs @@ -4,6 +4,12 @@ feature = "remote-connect" ))] mod account_device_rpc; +#[cfg(all( + feature = "agent-runtime", + feature = "ssh-remote", + feature = "remote-connect" +))] +mod agent_control; #[cfg(feature = "agent-runtime")] mod baseline; #[cfg(all(feature = "agent-runtime", feature = "ssh-remote"))] @@ -35,6 +41,15 @@ pub use account_device_rpc::{ decode_device_dispatch_response, encode_device_dispatch_command, AccountRuntimeDeviceDispatchRpc, }; +#[cfg(all( + feature = "agent-runtime", + feature = "ssh-remote", + feature = "remote-connect" +))] +pub use agent_control::{ + device_label, execute_dispatch_control, resolve_dispatch_target, AccountRuntimeDispatchAccess, + DispatchAccountAccess, DispatchControlContext, ResolvedDispatchTarget, +}; #[cfg(all(feature = "agent-runtime", feature = "ssh-remote"))] pub use controller::{ answer as answer_dispatch, append as append_dispatch, cancel as cancel_dispatch, diff --git a/src/crates/contracts/product-domains/src/detached_dispatch_control.rs b/src/crates/contracts/product-domains/src/detached_dispatch_control.rs new file mode 100644 index 0000000000..b16c7a0a5d --- /dev/null +++ b/src/crates/contracts/product-domains/src/detached_dispatch_control.rs @@ -0,0 +1,196 @@ +//! Closed Agent-facing requests for the executing host's Detached Dispatch +//! controller: list same-account devices and saved SSH targets, submit a job, +//! and observe or cancel jobs this host submitted. +//! +//! The controller semantics live in the Core dispatch owner; this contract only +//! names the operations and their arguments so every host binds them the same way. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const DETACHED_DISPATCH_CONTROL_PROVIDER: &str = "detached-dispatch"; +/// Largest prompt a controller may submit; mirrors the dispatch protocol limit. +pub const MAX_DETACHED_DISPATCH_PROMPT_BYTES: usize = 32 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum DetachedDispatchApprovalPolicy { + /// Approve every tool request that static rules do not deny. + Auto, + /// Reject permission prompts and report them in the job result. + RejectAndReport, +} + +impl DetachedDispatchApprovalPolicy { + pub fn wire(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::RejectAndReport => "reject-and-report", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "operation", + rename_all = "kebab-case", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum DetachedDispatchControlRequest { + /// Same-account devices (including this one, marked) and saved SSH targets. + ListTargets, + Submit { + /// Device name or alias, `device:`, or `ssh:`. + target: String, + prompt: String, + approval_policy: DetachedDispatchApprovalPolicy, + /// Git checkout on this host the job branches from. + workspace_path: String, + #[serde(default)] + agent: Option, + #[serde(default)] + model: Option, + #[serde(default)] + base_ref: Option, + #[serde(default)] + include_uncommitted: bool, + #[serde(default)] + title: Option, + }, + Status { + job_id: String, + #[serde(default)] + cursor: u64, + }, + ListJobs { + #[serde(default)] + target: Option, + }, + Cancel { + job_id: String, + }, +} + +impl DetachedDispatchControlRequest { + pub fn from_operation(operation: &str, arguments: Option<&Value>) -> Result { + let mut arguments = arguments.cloned().unwrap_or_else(|| serde_json::json!({})); + let object = arguments + .as_object_mut() + .ok_or("Detached Dispatch arguments must be an object")?; + if object.contains_key("operation") { + return Err( + "Detached Dispatch operation must be supplied by the registered handler".into(), + ); + } + object.insert("operation".into(), Value::String(operation.into())); + serde_json::from_value(arguments) + .map_err(|_| "Invalid Detached Dispatch operation or arguments".into()) + } + + pub fn operation_id(&self) -> &'static str { + match self { + Self::ListTargets => "list-targets", + Self::Submit { .. } => "submit", + Self::Status { .. } => "status", + Self::ListJobs { .. } => "list-jobs", + Self::Cancel { .. } => "cancel", + } + } + + pub fn is_readonly(&self) -> bool { + matches!( + self, + Self::ListTargets | Self::Status { .. } | Self::ListJobs { .. } + ) + } + + /// A detached dispatch worker runs unattended on a target; it may observe + /// jobs but must not fan out further dispatches. + pub fn ensure_allowed_in_dispatch_worker(&self) -> Result<(), String> { + match self { + Self::Submit { .. } => Err("detached_dispatch_nested_submit_unsupported: A detached dispatch job cannot dispatch further work; report the follow-up in the job result instead".into()), + _ => Ok(()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn closed_requests_parse_documented_arguments_only() { + let submit = DetachedDispatchControlRequest::from_operation( + "submit", + Some(&json!({ + "target": "MacBook-Pro-2.local", + "prompt": "run the tests", + "approvalPolicy": "reject-and-report", + "workspacePath": "/repo", + })), + ) + .unwrap(); + assert!(!submit.is_readonly()); + assert!(submit.ensure_allowed_in_dispatch_worker().is_err()); + assert_eq!(submit.operation_id(), "submit"); + + let list = DetachedDispatchControlRequest::from_operation("list-targets", None).unwrap(); + assert!(list.is_readonly()); + assert!(list.ensure_allowed_in_dispatch_worker().is_ok()); + + for (operation, arguments) in [ + ( + "submit", + json!({"target":"a","prompt":"p","approvalPolicy":"remote","workspacePath":"/r"}), + ), + ( + "submit", + json!({"target":"a","prompt":"p","approvalPolicy":"auto"}), + ), + ("status", json!({"jobId":"j","token":"secret"})), + ("cancel", json!({"operation":"submit","jobId":"j"})), + ("delete", json!({})), + ] { + assert!( + DetachedDispatchControlRequest::from_operation(operation, Some(&arguments)) + .is_err(), + "{operation} {arguments}" + ); + } + } + + #[test] + fn controls_declare_risk_host_paths_and_cli_availability() { + use crate::product_control::*; + let registry = ProductControlRegistry::global(); + let capability = "feature.detached-dispatch"; + for (operation, risk) in [ + ("list-targets", ProductControlRisk::Read), + ("status", ProductControlRisk::Read), + ("list-jobs", ProductControlRisk::Read), + ("submit", ProductControlRisk::Execute), + ("cancel", ProductControlRisk::Destructive), + ] { + assert_eq!( + registry.operation(capability, operation).unwrap().risk, + risk + ); + let definition = registry + .definition(&format!("{capability}:operation:{operation}")) + .unwrap(); + assert!(definition.availability[&ProductControlDeliveryProfile::Desktop].available); + assert!(definition.availability[&ProductControlDeliveryProfile::Cli].available); + assert_eq!( + definition.availability[&ProductControlDeliveryProfile::DetachedDispatch].available, + operation != "submit", + "{operation}" + ); + } + let submit = registry.operation(capability, "submit").unwrap(); + let args = json!({"target":"mac","prompt":"p","approvalPolicy":"auto","workspacePath":"/host/repo"}); + validate_operation_arguments(&submit.input_schema, Some(&args)).unwrap(); + assert!(validate_operation_argument_scopes(submit, Some(&args), true).is_err()); + } +} diff --git a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json index efece5837b..c07bad82ec 100644 --- a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json +++ b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json @@ -4,8 +4,8 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "2eb272d6820970e0c8ac677714ca03495729fa4a9cd458f1b89a4de726971107", - "ownerDigest": "df28e5eed6e3190741899ebf236bc5fb426d2469187e437839a349a7136c7c34", + "digest": "a4e451cd4268edcf15c00d41b4e5c8c6a476eb407e1324698d3db2b3f2f1ad34", + "ownerDigest": "0b2d30952efd57108d9e566dc0bf5a176f2b3d753abfb736bfc32b2c1fa3f7b7", "searchAcceptance": [ { "id": "native-image-generation", @@ -175,11 +175,11 @@ "features": 22, "settings": 22, "userFacing": 44, - "documentedItems": 336, + "documentedItems": 337, "controlCoverage": { - "direct": 58, + "direct": 62, "delegated": 72, - "interactive": 206, + "interactive": 203, "unsupported": 0 } }, @@ -9775,6 +9775,7 @@ "status-list", "transcript", "permission-continuation", + "cancel", "result-sync" ], "kind": "query", @@ -9820,46 +9821,49 @@ } }, { - "id": "feature.detached-dispatch:open:targets", + "id": "feature.detached-dispatch:operation:list-targets", "capabilityId": "feature.detached-dispatch", "itemIds": [ "targets" ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", + "kind": "operation", + "risk": "read", + "executionHost": "productHost", "availability": { "desktop": { "available": true }, "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true }, "peer": { "available": true, "requiredCapabilities": [ "product_control_v1", - "product_control_presentation_v1" + "product_control_native_v1" ] }, "remoteControl": { "available": true }, "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true } }, "inputSchema": { - "type": "object", - "additionalProperties": false + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object" }, "outputSchema": { "type": "object", "additionalProperties": true }, - "openReason": "unstructuredInteraction", + "valueSource": { + "kind": "provider", + "providerId": "detached-dispatch" + }, "presentationTarget": { "kind": "event", "eventName": "openbitfun:open-remote-connect", @@ -9869,27 +9873,26 @@ } }, { - "id": "feature.detached-dispatch:open:provision", + "id": "feature.detached-dispatch:operation:submit", "capabilityId": "feature.detached-dispatch", "itemIds": [ - "provision" + "submit" ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", + "kind": "operation", + "risk": "execute", + "executionHost": "productHost", "availability": { "desktop": { "available": true }, "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true }, "peer": { "available": true, "requiredCapabilities": [ "product_control_v1", - "product_control_presentation_v1" + "product_control_native_v1" ] }, "remoteControl": { @@ -9897,18 +9900,137 @@ }, "detachedDispatch": { "available": false, - "reason": "This delivery profile has no live presentation surface" + "reason": "A detached dispatch job cannot dispatch further work" } }, "inputSchema": { + "additionalProperties": false, + "properties": { + "agent": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "approvalPolicy": { + "enum": [ + "auto", + "reject-and-report" + ], + "type": "string" + }, + "baseRef": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "includeUncommitted": { + "type": "boolean" + }, + "model": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "prompt": { + "maxLength": 32768, + "minLength": 1, + "type": "string" + }, + "target": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "title": { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "workspacePath": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "target", + "prompt", + "approvalPolicy", + "workspacePath" + ], + "type": "object" + }, + "outputSchema": { "type": "object", - "additionalProperties": false + "additionalProperties": true + }, + "valueSource": { + "kind": "provider", + "providerId": "detached-dispatch" + }, + "presentationTarget": { + "kind": "event", + "eventName": "openbitfun:open-remote-connect", + "detail": { + "group": "account" + } + } + }, + { + "id": "feature.detached-dispatch:operation:status", + "capabilityId": "feature.detached-dispatch", + "itemIds": [ + "status-list" + ], + "kind": "operation", + "risk": "read", + "executionHost": "productHost", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": true + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_native_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": true + } + }, + "inputSchema": { + "additionalProperties": false, + "properties": { + "cursor": { + "minimum": 0, + "type": "integer" + }, + "jobId": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "jobId" + ], + "type": "object" }, "outputSchema": { "type": "object", "additionalProperties": true }, - "openReason": "unstructuredInteraction", + "valueSource": { + "kind": "provider", + "providerId": "detached-dispatch" + }, "presentationTarget": { "kind": "event", "eventName": "openbitfun:open-remote-connect", @@ -9918,46 +10040,115 @@ } }, { - "id": "feature.detached-dispatch:open:model-sync", + "id": "feature.detached-dispatch:operation:list-jobs", "capabilityId": "feature.detached-dispatch", "itemIds": [ - "model-sync" + "status-list" ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", + "kind": "operation", + "risk": "read", + "executionHost": "productHost", "availability": { "desktop": { "available": true }, "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true }, "peer": { "available": true, "requiredCapabilities": [ "product_control_v1", - "product_control_presentation_v1" + "product_control_native_v1" ] }, "remoteControl": { "available": true }, "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true } }, "inputSchema": { + "additionalProperties": false, + "properties": { + "target": { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "outputSchema": { "type": "object", - "additionalProperties": false + "additionalProperties": true + }, + "valueSource": { + "kind": "provider", + "providerId": "detached-dispatch" + }, + "presentationTarget": { + "kind": "event", + "eventName": "openbitfun:open-remote-connect", + "detail": { + "group": "account" + } + } + }, + { + "id": "feature.detached-dispatch:operation:cancel", + "capabilityId": "feature.detached-dispatch", + "itemIds": [ + "cancel" + ], + "kind": "operation", + "risk": "destructive", + "executionHost": "productHost", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": true + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_native_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": true + } + }, + "inputSchema": { + "additionalProperties": false, + "properties": { + "jobId": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "jobId" + ], + "type": "object" }, "outputSchema": { "type": "object", "additionalProperties": true }, - "openReason": "unstructuredInteraction", + "valueSource": { + "kind": "provider", + "providerId": "detached-dispatch" + }, "presentationTarget": { "kind": "event", "eventName": "openbitfun:open-remote-connect", @@ -9967,10 +10158,10 @@ } }, { - "id": "feature.detached-dispatch:open:submit", + "id": "feature.detached-dispatch:open:provision", "capabilityId": "feature.detached-dispatch", "itemIds": [ - "submit" + "provision" ], "kind": "open", "risk": "ui", @@ -10016,10 +10207,10 @@ } }, { - "id": "feature.detached-dispatch:open:status-list", + "id": "feature.detached-dispatch:open:model-sync", "capabilityId": "feature.detached-dispatch", "itemIds": [ - "status-list" + "model-sync" ], "kind": "open", "risk": "ui", @@ -25419,8 +25610,8 @@ "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "“列出、连接、查看设备型号与系统、设置别名和删除同账户设备”需要选择设备并确认操作。设备目录使用控制端的账户身份,不随 Peer Device 切换到目标主机。别名优先于技术名称显示,但不修改技术名称;型号与系统信息由设备自身上报。仅支持对应能力的 Relay 可更新或清除别名,旧 Relay 明确显示不支持。智能体打开设备管理入口,由用户确认后续交互。", - "reasonEn": "“List, connect, inspect model and OS, set aliases, and remove same-account devices” requires device selection and confirmation. The device directory uses the controller's account identity, not the selected Peer host. Aliases take display priority without changing technical names; model and OS metadata come from the device itself. Alias updates and clearing require Relay capability support; older Relays explicitly remain unsupported. The Agent opens device management and leaves subsequent interaction to the user." + "reasonZh": "“列出、连接、查看设备型号与系统、设置别名和删除同账户设备”需要选择设备并确认操作。设备目录使用控制端的账户身份,不随 Peer Device 切换到目标主机。别名优先于技术名称显示,但不修改技术名称;型号与系统信息由设备自身上报。仅支持对应能力的 Relay 可更新或清除别名,旧 Relay 明确显示不支持。智能体打开设备管理入口,由用户确认后续交互。读取设备列表请调用 feature.detached-dispatch 的 list-targets。", + "reasonEn": "“List, connect, inspect model and OS, set aliases, and remove same-account devices” requires device selection and confirmation. The device directory uses the controller's account identity, not the selected Peer host. Aliases take display priority without changing technical names; model and OS metadata come from the device itself. Alias updates and clearing require Relay capability support; older Relays explicitly remain unsupported. The Agent opens device management and leaves subsequent interaction to the user. To read the device list, call list-targets on feature.detached-dispatch." } }, { @@ -25745,25 +25936,26 @@ "offline job" ], "highlightsZh": [ - "发现与探测目标主机", - "派发、继续或取消任务", + "列出同账户设备与 SSH 目标", + "派发、查询或取消任务", "断线后重新加载事件与执行记录" ], "highlightsEn": [ - "Discover and probe target hosts", - "Dispatch, continue, or cancel jobs", + "List same-account devices and SSH targets", + "Dispatch, inspect, or cancel jobs", "Reload events and execution history after reconnecting" ], "items": [ { "id": "targets", - "titleZh": "列出可派发目标并探测版本、连接和运行能力", - "titleEn": "List dispatch targets and probe their version, connectivity, and runtime capabilities", + "titleZh": "列出同账户设备与已保存的 SSH 目标", + "titleEn": "List same-account devices and saved SSH targets", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“列出可派发目标并探测版本、连接和运行能力”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“List dispatch targets and probe their version, connectivity, and runtime capabilities” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "direct", + "operations": [ + "list-targets" + ], + "options": [] } }, { @@ -25790,13 +25982,14 @@ }, { "id": "submit", - "titleZh": "把包含工作区、会话、模型和权限策略的持久任务提交到目标", - "titleEn": "Submit a durable job with workspace, session, model, and permission policy to the target", + "titleZh": "从当前 Git 工作区把持久任务提交到同账户设备或 SSH 目标", + "titleEn": "Submit a durable job from the current Git workspace to a same-account device or SSH target", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“把包含工作区、会话、模型和权限策略的持久任务提交到目标”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Submit a durable job with workspace, session, model, and permission policy to the target” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "direct", + "operations": [ + "submit" + ], + "options": [] } }, { @@ -25804,10 +25997,12 @@ "titleZh": "列出派发任务并查询运行状态、结果和错误", "titleEn": "List dispatched jobs and query their status, result, and errors", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“列出派发任务并查询运行状态、结果和错误”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“List dispatched jobs and query their status, result, and errors” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "direct", + "operations": [ + "list-jobs", + "status" + ], + "options": [] } }, { @@ -25823,13 +26018,25 @@ }, { "id": "permission-continuation", - "titleZh": "远程回答权限请求,并继续或取消目标端任务", - "titleEn": "Answer permission requests remotely and continue or cancel target-side work", + "titleZh": "远程回答权限请求并继续目标端任务", + "titleEn": "Answer permission requests remotely and continue target-side work", "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "“远程回答权限请求,并继续或取消目标端任务”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Answer permission requests remotely and continue or cancel target-side work” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "reasonZh": "“远程回答权限请求并继续目标端任务”需要用户逐条确认权限与后续输入,目前没有能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", + "reasonEn": "“Answer permission requests remotely and continue target-side work” needs the user to confirm each permission and follow-up input and has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + } + }, + { + "id": "cancel", + "titleZh": "取消本机派发的任务", + "titleEn": "Cancel a job submitted from this host", + "control": { + "kind": "direct", + "operations": [ + "cancel" + ], + "options": [] } }, { @@ -25855,12 +26062,16 @@ "Submit the work and follow its job status" ], "agentExamplesZh": [ - "打开设备管理,我想把任务派发到另一台机器", - "带我配置远程任务派发" + "我有几台设备?", + "把这个任务派发到我的 MacBook 上跑", + "看看刚才派发的任务跑完没有", + "打开设备管理,我想把任务派发到另一台机器" ], "agentExamplesEn": [ - "Open device management so I can dispatch work to another machine", - "Take me to remote task dispatch setup" + "How many devices do I have?", + "Dispatch this task to my MacBook", + "Check whether the job I dispatched has finished", + "Open device management so I can dispatch work to another machine" ], "destination": { "kind": "event", @@ -25869,7 +26080,182 @@ "group": "account" } }, - "operations": [], + "operations": [ + { + "id": "list-targets", + "titleZh": "列出派发目标", + "titleEn": "List dispatch targets", + "descriptionZh": "读取同账户下所有 OpenBitFun 设备(含本机,带 isCurrentDevice 标记、在线状态、型号与系统)以及本机已保存的 SSH 连接。回答“有几台设备”或派发前先调用;未登录账户时 devices 为空并说明原因。", + "descriptionEn": "Read every OpenBitFun device on this account (including this one, marked isCurrentDevice, with online state, model and OS) plus this host's saved SSH connections. Call it before stating how many devices exist or before submitting; when signed out, devices is empty with a reason.", + "risk": "read", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object" + }, + "handler": { + "kind": "provider", + "providerId": "detached-dispatch", + "operationId": "list-targets" + } + }, + { + "id": "submit", + "titleZh": "派发任务", + "titleEn": "Submit a dispatch job", + "descriptionZh": "把任务提交到另一台同账户设备(名称、别名或 device:)或 SSH 目标(ssh:)。目标在自己的 Git worktree 中从 baseRef(默认 HEAD)开始执行,本机可以断开。仅在用户明确要求派发时调用,并明确选择 approvalPolicy;离线设备直接报错,不会在本机执行。", + "descriptionEn": "Submit work to another same-account device (name, alias, or device:) or SSH target (ssh:). The target runs in its own Git worktree branched from baseRef (default HEAD) and this host may disconnect. Call only when the user asked to dispatch, and choose approvalPolicy explicitly; an offline device is an error, never a local run.", + "risk": "execute", + "inputSchema": { + "additionalProperties": false, + "properties": { + "agent": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "approvalPolicy": { + "enum": [ + "auto", + "reject-and-report" + ], + "type": "string" + }, + "baseRef": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "includeUncommitted": { + "type": "boolean" + }, + "model": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "prompt": { + "maxLength": 32768, + "minLength": 1, + "type": "string" + }, + "target": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "title": { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "workspacePath": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "target", + "prompt", + "approvalPolicy", + "workspacePath" + ], + "type": "object" + }, + "argumentScopes": { + "workspacePath": "productHostLocal" + }, + "handler": { + "kind": "provider", + "providerId": "detached-dispatch", + "operationId": "submit" + } + }, + { + "id": "status", + "titleZh": "查询派发任务", + "titleEn": "Read dispatch job status", + "descriptionZh": "按 jobId 读取本机派发任务的状态与增量事件;cursor 传上次返回值以只取新事件。", + "descriptionEn": "Read state and incremental events of a job submitted from this host; pass the previous cursor to read only new events.", + "risk": "read", + "inputSchema": { + "additionalProperties": false, + "properties": { + "cursor": { + "minimum": 0, + "type": "integer" + }, + "jobId": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "jobId" + ], + "type": "object" + }, + "handler": { + "kind": "provider", + "providerId": "detached-dispatch", + "operationId": "status" + } + }, + { + "id": "list-jobs", + "titleZh": "列出派发任务", + "titleEn": "List dispatch jobs", + "descriptionZh": "不带 target 时列出本机提交过的任务;带 target 时直接询问该目标正在持有的任务。", + "descriptionEn": "Without target, list jobs submitted from this host; with target, ask that target for the jobs it owns.", + "risk": "read", + "inputSchema": { + "additionalProperties": false, + "properties": { + "target": { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "handler": { + "kind": "provider", + "providerId": "detached-dispatch", + "operationId": "list-jobs" + } + }, + { + "id": "cancel", + "titleZh": "取消派发任务", + "titleEn": "Cancel a dispatch job", + "descriptionZh": "取消本机提交的指定任务;目标端停止执行并保留记录。", + "descriptionEn": "Cancel a job submitted from this host; the target stops it and keeps its record.", + "risk": "destructive", + "inputSchema": { + "additionalProperties": false, + "properties": { + "jobId": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "jobId" + ], + "type": "object" + }, + "handler": { + "kind": "provider", + "providerId": "detached-dispatch", + "operationId": "cancel" + } + } + ], "options": [], "searchTerms": [ "feature.detached-dispatch", @@ -25886,32 +26272,38 @@ "remote execution", "target host", "offline job", - "发现与探测目标主机", - "派发、继续或取消任务", + "列出同账户设备与 SSH 目标", + "派发、查询或取消任务", "断线后重新加载事件与执行记录", - "Discover and probe target hosts", - "Dispatch, continue, or cancel jobs", + "List same-account devices and SSH targets", + "Dispatch, inspect, or cancel jobs", "Reload events and execution history after reconnecting", - "列出可派发目标并探测版本、连接和运行能力", - "List dispatch targets and probe their version, connectivity, and runtime capabilities", + "列出同账户设备与已保存的 SSH 目标", + "List same-account devices and saved SSH targets", "为目标主机配置运行环境并安装或更新 OpenBitFun CLI", "Provision the target runtime and install or update the OpenBitFun CLI", "在提交任务前同步目标所需的模型配置", "Synchronize required model configuration before submitting a job", - "把包含工作区、会话、模型和权限策略的持久任务提交到目标", - "Submit a durable job with workspace, session, model, and permission policy to the target", + "从当前 Git 工作区把持久任务提交到同账户设备或 SSH 目标", + "Submit a durable job from the current Git workspace to a same-account device or SSH target", "列出派发任务并查询运行状态、结果和错误", "List dispatched jobs and query their status, result, and errors", "保存、追加和重新加载可断线恢复的执行记录", "Save, append, and reload reconnectable execution transcripts", - "远程回答权限请求,并继续或取消目标端任务", - "Answer permission requests remotely and continue or cancel target-side work", + "远程回答权限请求并继续目标端任务", + "Answer permission requests remotely and continue target-side work", + "取消本机派发的任务", + "Cancel a job submitted from this host", "将目标端结果同步回控制端,而不把控制端当作远程文件系统代理", "Synchronize target-side results back without treating the controller as a remote-filesystem proxy", + "我有几台设备?", + "把这个任务派发到我的 MacBook 上跑", + "看看刚才派发的任务跑完没有", "打开设备管理,我想把任务派发到另一台机器", - "带我配置远程任务派发", - "Open device management so I can dispatch work to another machine", - "Take me to remote task dispatch setup" + "How many devices do I have?", + "Dispatch this task to my MacBook", + "Check whether the job I dispatched has finished", + "Open device management so I can dispatch work to another machine" ], "docsUrl": "https://playbook.openbitfun.com/capabilities/feature.detached-dispatch/" }, diff --git a/src/crates/contracts/product-domains/src/lib.rs b/src/crates/contracts/product-domains/src/lib.rs index a9a5b22cf1..dfbe0aacb6 100644 --- a/src/crates/contracts/product-domains/src/lib.rs +++ b/src/crates/contracts/product-domains/src/lib.rs @@ -6,6 +6,7 @@ pub mod account; pub mod agent_catalog; pub mod canvas; +pub mod detached_dispatch_control; pub mod html_preview; pub mod mcp; pub mod native_hooks; diff --git a/src/crates/contracts/product-domains/src/product_control_owner_registry.rs b/src/crates/contracts/product-domains/src/product_control_owner_registry.rs index 0d78de3244..4f592a3dca 100644 --- a/src/crates/contracts/product-domains/src/product_control_owner_registry.rs +++ b/src/crates/contracts/product-domains/src/product_control_owner_registry.rs @@ -286,9 +286,59 @@ pub fn owner_definitions() -> Vec { AppearanceSelection, FlowChatPermissionModeControl, Language, }; use ProductControlArgumentScope::ProductHostLocal; - use ProductControlRisk::{Destructive, Read, Ui, Write}; + use ProductControlRisk::{Destructive, Execute, Read, Ui, Write}; vec![ + operation( + "feature.detached-dispatch", + "list-targets", + Read, + json!({"type": "object", "properties": {}, "required": [], "additionalProperties": false}), + &[], + operation_provider("detached-dispatch", "list-targets"), + ), + operation( + "feature.detached-dispatch", + "submit", + Execute, + json!({"type": "object", "properties": { + "target": {"type": "string", "minLength": 1, "maxLength": 512}, + "prompt": {"type": "string", "minLength": 1, "maxLength": 32768}, + "approvalPolicy": {"type": "string", "enum": ["auto", "reject-and-report"]}, + "workspacePath": {"type": "string", "minLength": 1}, + "agent": {"type": "string", "minLength": 1, "maxLength": 128}, + "model": {"type": "string", "minLength": 1, "maxLength": 256}, + "baseRef": {"type": "string", "minLength": 1, "maxLength": 512}, + "includeUncommitted": {"type": "boolean"}, + "title": {"type": "string", "minLength": 1, "maxLength": 200} + }, "required": ["target", "prompt", "approvalPolicy", "workspacePath"], "additionalProperties": false}), + &[("workspacePath", ProductHostLocal)], + operation_provider("detached-dispatch", "submit"), + ), + operation( + "feature.detached-dispatch", + "status", + Read, + json!({"type": "object", "properties": {"jobId": {"type": "string", "minLength": 1, "maxLength": 128}, "cursor": {"type": "integer", "minimum": 0}}, "required": ["jobId"], "additionalProperties": false}), + &[], + operation_provider("detached-dispatch", "status"), + ), + operation( + "feature.detached-dispatch", + "list-jobs", + Read, + json!({"type": "object", "properties": {"target": {"type": "string", "minLength": 1, "maxLength": 512}}, "required": [], "additionalProperties": false}), + &[], + operation_provider("detached-dispatch", "list-jobs"), + ), + operation( + "feature.detached-dispatch", + "cancel", + Destructive, + json!({"type": "object", "properties": {"jobId": {"type": "string", "minLength": 1, "maxLength": 128}}, "required": ["jobId"], "additionalProperties": false}), + &[], + operation_provider("detached-dispatch", "cancel"), + ), operation( "feature.remote-connect", "status", diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index aa2d828db2..6bd9ea414e 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -4631,8 +4631,8 @@ "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "“列出、连接、查看设备型号与系统、设置别名和删除同账户设备”需要选择设备并确认操作。设备目录使用控制端的账户身份,不随 Peer Device 切换到目标主机。别名优先于技术名称显示,但不修改技术名称;型号与系统信息由设备自身上报。仅支持对应能力的 Relay 可更新或清除别名,旧 Relay 明确显示不支持。智能体打开设备管理入口,由用户确认后续交互。", - "reasonEn": "“List, connect, inspect model and OS, set aliases, and remove same-account devices” requires device selection and confirmation. The device directory uses the controller's account identity, not the selected Peer host. Aliases take display priority without changing technical names; model and OS metadata come from the device itself. Alias updates and clearing require Relay capability support; older Relays explicitly remain unsupported. The Agent opens device management and leaves subsequent interaction to the user." + "reasonZh": "“列出、连接、查看设备型号与系统、设置别名和删除同账户设备”需要选择设备并确认操作。设备目录使用控制端的账户身份,不随 Peer Device 切换到目标主机。别名优先于技术名称显示,但不修改技术名称;型号与系统信息由设备自身上报。仅支持对应能力的 Relay 可更新或清除别名,旧 Relay 明确显示不支持。智能体打开设备管理入口,由用户确认后续交互。读取设备列表请调用 feature.detached-dispatch 的 list-targets。", + "reasonEn": "“List, connect, inspect model and OS, set aliases, and remove same-account devices” requires device selection and confirmation. The device directory uses the controller's account identity, not the selected Peer host. Aliases take display priority without changing technical names; model and OS metadata come from the device itself. Alias updates and clearing require Relay capability support; older Relays explicitly remain unsupported. The Agent opens device management and leaves subsequent interaction to the user. To read the device list, call list-targets on feature.detached-dispatch." }, "evidence": [ "command:account_list_devices", @@ -4768,29 +4768,31 @@ "offline job" ], "highlightsZh": [ - "发现与探测目标主机", - "派发、继续或取消任务", + "列出同账户设备与 SSH 目标", + "派发、查询或取消任务", "断线后重新加载事件与执行记录" ], "highlightsEn": [ - "Discover and probe target hosts", - "Dispatch, continue, or cancel jobs", + "List same-account devices and SSH targets", + "Dispatch, inspect, or cancel jobs", "Reload events and execution history after reconnecting" ], "items": [ { "id": "targets", - "titleZh": "列出可派发目标并探测版本、连接和运行能力", - "titleEn": "List dispatch targets and probe their version, connectivity, and runtime capabilities", + "titleZh": "列出同账户设备与已保存的 SSH 目标", + "titleEn": "List same-account devices and saved SSH targets", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“列出可派发目标并探测版本、连接和运行能力”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“List dispatch targets and probe their version, connectivity, and runtime capabilities” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "direct", + "operations": [ + "list-targets" + ], + "options": [] }, "evidence": [ "command:dispatch_list_targets", - "command:dispatch_probe_target" + "command:dispatch_probe_target", + "command:account_list_devices" ] }, { @@ -4826,13 +4828,14 @@ }, { "id": "submit", - "titleZh": "把包含工作区、会话、模型和权限策略的持久任务提交到目标", - "titleEn": "Submit a durable job with workspace, session, model, and permission policy to the target", + "titleZh": "从当前 Git 工作区把持久任务提交到同账户设备或 SSH 目标", + "titleEn": "Submit a durable job from the current Git workspace to a same-account device or SSH target", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“把包含工作区、会话、模型和权限策略的持久任务提交到目标”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Submit a durable job with workspace, session, model, and permission policy to the target” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "direct", + "operations": [ + "submit" + ], + "options": [] }, "evidence": [ "command:dispatch_submit" @@ -4843,10 +4846,12 @@ "titleZh": "列出派发任务并查询运行状态、结果和错误", "titleEn": "List dispatched jobs and query their status, result, and errors", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“列出派发任务并查询运行状态、结果和错误”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“List dispatched jobs and query their status, result, and errors” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "direct", + "operations": [ + "list-jobs", + "status" + ], + "options": [] }, "evidence": [ "command:dispatch_list_jobs", @@ -4872,17 +4877,31 @@ }, { "id": "permission-continuation", - "titleZh": "远程回答权限请求,并继续或取消目标端任务", - "titleEn": "Answer permission requests remotely and continue or cancel target-side work", + "titleZh": "远程回答权限请求并继续目标端任务", + "titleEn": "Answer permission requests remotely and continue target-side work", "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "“远程回答权限请求,并继续或取消目标端任务”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Answer permission requests remotely and continue or cancel target-side work” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "reasonZh": "“远程回答权限请求并继续目标端任务”需要用户逐条确认权限与后续输入,目前没有能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", + "reasonEn": "“Answer permission requests remotely and continue target-side work” needs the user to confirm each permission and follow-up input and has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." }, "evidence": [ "command:dispatch_answer", - "command:dispatch_continue", + "command:dispatch_continue" + ] + }, + { + "id": "cancel", + "titleZh": "取消本机派发的任务", + "titleEn": "Cancel a job submitted from this host", + "control": { + "kind": "direct", + "operations": [ + "cancel" + ], + "options": [] + }, + "evidence": [ "command:dispatch_cancel" ] }, @@ -4912,12 +4931,16 @@ "Submit the work and follow its job status" ], "agentExamplesZh": [ - "打开设备管理,我想把任务派发到另一台机器", - "带我配置远程任务派发" + "我有几台设备?", + "把这个任务派发到我的 MacBook 上跑", + "看看刚才派发的任务跑完没有", + "打开设备管理,我想把任务派发到另一台机器" ], "agentExamplesEn": [ - "Open device management so I can dispatch work to another machine", - "Take me to remote task dispatch setup" + "How many devices do I have?", + "Dispatch this task to my MacBook", + "Check whether the job I dispatched has finished", + "Open device management so I can dispatch work to another machine" ], "destination": { "kind": "event", @@ -4926,7 +4949,43 @@ "group": "account" } }, - "operations": [] + "operations": [ + { + "id": "list-targets", + "titleZh": "列出派发目标", + "titleEn": "List dispatch targets", + "descriptionZh": "读取同账户下所有 OpenBitFun 设备(含本机,带 isCurrentDevice 标记、在线状态、型号与系统)以及本机已保存的 SSH 连接。回答“有几台设备”或派发前先调用;未登录账户时 devices 为空并说明原因。", + "descriptionEn": "Read every OpenBitFun device on this account (including this one, marked isCurrentDevice, with online state, model and OS) plus this host's saved SSH connections. Call it before stating how many devices exist or before submitting; when signed out, devices is empty with a reason." + }, + { + "id": "submit", + "titleZh": "派发任务", + "titleEn": "Submit a dispatch job", + "descriptionZh": "把任务提交到另一台同账户设备(名称、别名或 device:)或 SSH 目标(ssh:)。目标在自己的 Git worktree 中从 baseRef(默认 HEAD)开始执行,本机可以断开。仅在用户明确要求派发时调用,并明确选择 approvalPolicy;离线设备直接报错,不会在本机执行。", + "descriptionEn": "Submit work to another same-account device (name, alias, or device:) or SSH target (ssh:). The target runs in its own Git worktree branched from baseRef (default HEAD) and this host may disconnect. Call only when the user asked to dispatch, and choose approvalPolicy explicitly; an offline device is an error, never a local run." + }, + { + "id": "status", + "titleZh": "查询派发任务", + "titleEn": "Read dispatch job status", + "descriptionZh": "按 jobId 读取本机派发任务的状态与增量事件;cursor 传上次返回值以只取新事件。", + "descriptionEn": "Read state and incremental events of a job submitted from this host; pass the previous cursor to read only new events." + }, + { + "id": "list-jobs", + "titleZh": "列出派发任务", + "titleEn": "List dispatch jobs", + "descriptionZh": "不带 target 时列出本机提交过的任务;带 target 时直接询问该目标正在持有的任务。", + "descriptionEn": "Without target, list jobs submitted from this host; with target, ask that target for the jobs it owns." + }, + { + "id": "cancel", + "titleZh": "取消派发任务", + "titleEn": "Cancel a dispatch job", + "descriptionZh": "取消本机提交的指定任务;目标端停止执行并保留记录。", + "descriptionEn": "Cancel a job submitted from this host; the target stops it and keeps its record." + } + ] }, { "id": "feature.pages", diff --git a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json index 8ba33cd4e6..bc8aa707c3 100644 --- a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json +++ b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json @@ -4,8 +4,8 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "2eb272d6820970e0c8ac677714ca03495729fa4a9cd458f1b89a4de726971107", - "ownerDigest": "df28e5eed6e3190741899ebf236bc5fb426d2469187e437839a349a7136c7c34", + "digest": "a4e451cd4268edcf15c00d41b4e5c8c6a476eb407e1324698d3db2b3f2f1ad34", + "ownerDigest": "0b2d30952efd57108d9e566dc0bf5a176f2b3d753abfb736bfc32b2c1fa3f7b7", "searchAcceptance": [ { "id": "native-image-generation", @@ -175,11 +175,11 @@ "features": 22, "settings": 22, "userFacing": 44, - "documentedItems": 336, + "documentedItems": 337, "controlCoverage": { - "direct": 58, + "direct": 62, "delegated": 72, - "interactive": 206, + "interactive": 203, "unsupported": 0 } }, @@ -9775,6 +9775,7 @@ "status-list", "transcript", "permission-continuation", + "cancel", "result-sync" ], "kind": "query", @@ -9820,46 +9821,49 @@ } }, { - "id": "feature.detached-dispatch:open:targets", + "id": "feature.detached-dispatch:operation:list-targets", "capabilityId": "feature.detached-dispatch", "itemIds": [ "targets" ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", + "kind": "operation", + "risk": "read", + "executionHost": "productHost", "availability": { "desktop": { "available": true }, "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true }, "peer": { "available": true, "requiredCapabilities": [ "product_control_v1", - "product_control_presentation_v1" + "product_control_native_v1" ] }, "remoteControl": { "available": true }, "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true } }, "inputSchema": { - "type": "object", - "additionalProperties": false + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object" }, "outputSchema": { "type": "object", "additionalProperties": true }, - "openReason": "unstructuredInteraction", + "valueSource": { + "kind": "provider", + "providerId": "detached-dispatch" + }, "presentationTarget": { "kind": "event", "eventName": "openbitfun:open-remote-connect", @@ -9869,27 +9873,26 @@ } }, { - "id": "feature.detached-dispatch:open:provision", + "id": "feature.detached-dispatch:operation:submit", "capabilityId": "feature.detached-dispatch", "itemIds": [ - "provision" + "submit" ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", + "kind": "operation", + "risk": "execute", + "executionHost": "productHost", "availability": { "desktop": { "available": true }, "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true }, "peer": { "available": true, "requiredCapabilities": [ "product_control_v1", - "product_control_presentation_v1" + "product_control_native_v1" ] }, "remoteControl": { @@ -9897,18 +9900,137 @@ }, "detachedDispatch": { "available": false, - "reason": "This delivery profile has no live presentation surface" + "reason": "A detached dispatch job cannot dispatch further work" } }, "inputSchema": { + "additionalProperties": false, + "properties": { + "agent": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "approvalPolicy": { + "enum": [ + "auto", + "reject-and-report" + ], + "type": "string" + }, + "baseRef": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "includeUncommitted": { + "type": "boolean" + }, + "model": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "prompt": { + "maxLength": 32768, + "minLength": 1, + "type": "string" + }, + "target": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "title": { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "workspacePath": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "target", + "prompt", + "approvalPolicy", + "workspacePath" + ], + "type": "object" + }, + "outputSchema": { "type": "object", - "additionalProperties": false + "additionalProperties": true + }, + "valueSource": { + "kind": "provider", + "providerId": "detached-dispatch" + }, + "presentationTarget": { + "kind": "event", + "eventName": "openbitfun:open-remote-connect", + "detail": { + "group": "account" + } + } + }, + { + "id": "feature.detached-dispatch:operation:status", + "capabilityId": "feature.detached-dispatch", + "itemIds": [ + "status-list" + ], + "kind": "operation", + "risk": "read", + "executionHost": "productHost", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": true + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_native_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": true + } + }, + "inputSchema": { + "additionalProperties": false, + "properties": { + "cursor": { + "minimum": 0, + "type": "integer" + }, + "jobId": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "jobId" + ], + "type": "object" }, "outputSchema": { "type": "object", "additionalProperties": true }, - "openReason": "unstructuredInteraction", + "valueSource": { + "kind": "provider", + "providerId": "detached-dispatch" + }, "presentationTarget": { "kind": "event", "eventName": "openbitfun:open-remote-connect", @@ -9918,46 +10040,115 @@ } }, { - "id": "feature.detached-dispatch:open:model-sync", + "id": "feature.detached-dispatch:operation:list-jobs", "capabilityId": "feature.detached-dispatch", "itemIds": [ - "model-sync" + "status-list" ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", + "kind": "operation", + "risk": "read", + "executionHost": "productHost", "availability": { "desktop": { "available": true }, "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true }, "peer": { "available": true, "requiredCapabilities": [ "product_control_v1", - "product_control_presentation_v1" + "product_control_native_v1" ] }, "remoteControl": { "available": true }, "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true } }, "inputSchema": { + "additionalProperties": false, + "properties": { + "target": { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "outputSchema": { "type": "object", - "additionalProperties": false + "additionalProperties": true + }, + "valueSource": { + "kind": "provider", + "providerId": "detached-dispatch" + }, + "presentationTarget": { + "kind": "event", + "eventName": "openbitfun:open-remote-connect", + "detail": { + "group": "account" + } + } + }, + { + "id": "feature.detached-dispatch:operation:cancel", + "capabilityId": "feature.detached-dispatch", + "itemIds": [ + "cancel" + ], + "kind": "operation", + "risk": "destructive", + "executionHost": "productHost", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": true + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_native_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": true + } + }, + "inputSchema": { + "additionalProperties": false, + "properties": { + "jobId": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "jobId" + ], + "type": "object" }, "outputSchema": { "type": "object", "additionalProperties": true }, - "openReason": "unstructuredInteraction", + "valueSource": { + "kind": "provider", + "providerId": "detached-dispatch" + }, "presentationTarget": { "kind": "event", "eventName": "openbitfun:open-remote-connect", @@ -9967,10 +10158,10 @@ } }, { - "id": "feature.detached-dispatch:open:submit", + "id": "feature.detached-dispatch:open:provision", "capabilityId": "feature.detached-dispatch", "itemIds": [ - "submit" + "provision" ], "kind": "open", "risk": "ui", @@ -10016,10 +10207,10 @@ } }, { - "id": "feature.detached-dispatch:open:status-list", + "id": "feature.detached-dispatch:open:model-sync", "capabilityId": "feature.detached-dispatch", "itemIds": [ - "status-list" + "model-sync" ], "kind": "open", "risk": "ui", @@ -25378,8 +25569,8 @@ "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "“列出、连接、查看设备型号与系统、设置别名和删除同账户设备”需要选择设备并确认操作。设备目录使用控制端的账户身份,不随 Peer Device 切换到目标主机。别名优先于技术名称显示,但不修改技术名称;型号与系统信息由设备自身上报。仅支持对应能力的 Relay 可更新或清除别名,旧 Relay 明确显示不支持。智能体打开设备管理入口,由用户确认后续交互。", - "reasonEn": "“List, connect, inspect model and OS, set aliases, and remove same-account devices” requires device selection and confirmation. The device directory uses the controller's account identity, not the selected Peer host. Aliases take display priority without changing technical names; model and OS metadata come from the device itself. Alias updates and clearing require Relay capability support; older Relays explicitly remain unsupported. The Agent opens device management and leaves subsequent interaction to the user." + "reasonZh": "“列出、连接、查看设备型号与系统、设置别名和删除同账户设备”需要选择设备并确认操作。设备目录使用控制端的账户身份,不随 Peer Device 切换到目标主机。别名优先于技术名称显示,但不修改技术名称;型号与系统信息由设备自身上报。仅支持对应能力的 Relay 可更新或清除别名,旧 Relay 明确显示不支持。智能体打开设备管理入口,由用户确认后续交互。读取设备列表请调用 feature.detached-dispatch 的 list-targets。", + "reasonEn": "“List, connect, inspect model and OS, set aliases, and remove same-account devices” requires device selection and confirmation. The device directory uses the controller's account identity, not the selected Peer host. Aliases take display priority without changing technical names; model and OS metadata come from the device itself. Alias updates and clearing require Relay capability support; older Relays explicitly remain unsupported. The Agent opens device management and leaves subsequent interaction to the user. To read the device list, call list-targets on feature.detached-dispatch." } }, { @@ -25669,25 +25860,26 @@ "offline job" ], "highlightsZh": [ - "发现与探测目标主机", - "派发、继续或取消任务", + "列出同账户设备与 SSH 目标", + "派发、查询或取消任务", "断线后重新加载事件与执行记录" ], "highlightsEn": [ - "Discover and probe target hosts", - "Dispatch, continue, or cancel jobs", + "List same-account devices and SSH targets", + "Dispatch, inspect, or cancel jobs", "Reload events and execution history after reconnecting" ], "items": [ { "id": "targets", - "titleZh": "列出可派发目标并探测版本、连接和运行能力", - "titleEn": "List dispatch targets and probe their version, connectivity, and runtime capabilities", + "titleZh": "列出同账户设备与已保存的 SSH 目标", + "titleEn": "List same-account devices and saved SSH targets", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“列出可派发目标并探测版本、连接和运行能力”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“List dispatch targets and probe their version, connectivity, and runtime capabilities” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "direct", + "operations": [ + "list-targets" + ], + "options": [] } }, { @@ -25714,13 +25906,14 @@ }, { "id": "submit", - "titleZh": "把包含工作区、会话、模型和权限策略的持久任务提交到目标", - "titleEn": "Submit a durable job with workspace, session, model, and permission policy to the target", + "titleZh": "从当前 Git 工作区把持久任务提交到同账户设备或 SSH 目标", + "titleEn": "Submit a durable job from the current Git workspace to a same-account device or SSH target", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“把包含工作区、会话、模型和权限策略的持久任务提交到目标”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Submit a durable job with workspace, session, model, and permission policy to the target” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "direct", + "operations": [ + "submit" + ], + "options": [] } }, { @@ -25728,10 +25921,12 @@ "titleZh": "列出派发任务并查询运行状态、结果和错误", "titleEn": "List dispatched jobs and query their status, result, and errors", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“列出派发任务并查询运行状态、结果和错误”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“List dispatched jobs and query their status, result, and errors” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "direct", + "operations": [ + "list-jobs", + "status" + ], + "options": [] } }, { @@ -25747,13 +25942,25 @@ }, { "id": "permission-continuation", - "titleZh": "远程回答权限请求,并继续或取消目标端任务", - "titleEn": "Answer permission requests remotely and continue or cancel target-side work", + "titleZh": "远程回答权限请求并继续目标端任务", + "titleEn": "Answer permission requests remotely and continue target-side work", "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "“远程回答权限请求,并继续或取消目标端任务”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Answer permission requests remotely and continue or cancel target-side work” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "reasonZh": "“远程回答权限请求并继续目标端任务”需要用户逐条确认权限与后续输入,目前没有能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", + "reasonEn": "“Answer permission requests remotely and continue target-side work” needs the user to confirm each permission and follow-up input and has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + } + }, + { + "id": "cancel", + "titleZh": "取消本机派发的任务", + "titleEn": "Cancel a job submitted from this host", + "control": { + "kind": "direct", + "operations": [ + "cancel" + ], + "options": [] } }, { @@ -25779,12 +25986,16 @@ "Submit the work and follow its job status" ], "agentExamplesZh": [ - "打开设备管理,我想把任务派发到另一台机器", - "带我配置远程任务派发" + "我有几台设备?", + "把这个任务派发到我的 MacBook 上跑", + "看看刚才派发的任务跑完没有", + "打开设备管理,我想把任务派发到另一台机器" ], "agentExamplesEn": [ - "Open device management so I can dispatch work to another machine", - "Take me to remote task dispatch setup" + "How many devices do I have?", + "Dispatch this task to my MacBook", + "Check whether the job I dispatched has finished", + "Open device management so I can dispatch work to another machine" ], "destination": { "kind": "event", @@ -25793,7 +26004,157 @@ "group": "account" } }, - "operations": [], + "operations": [ + { + "id": "list-targets", + "titleZh": "列出派发目标", + "titleEn": "List dispatch targets", + "descriptionZh": "读取同账户下所有 OpenBitFun 设备(含本机,带 isCurrentDevice 标记、在线状态、型号与系统)以及本机已保存的 SSH 连接。回答“有几台设备”或派发前先调用;未登录账户时 devices 为空并说明原因。", + "descriptionEn": "Read every OpenBitFun device on this account (including this one, marked isCurrentDevice, with online state, model and OS) plus this host's saved SSH connections. Call it before stating how many devices exist or before submitting; when signed out, devices is empty with a reason.", + "risk": "read", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object" + } + }, + { + "id": "submit", + "titleZh": "派发任务", + "titleEn": "Submit a dispatch job", + "descriptionZh": "把任务提交到另一台同账户设备(名称、别名或 device:)或 SSH 目标(ssh:)。目标在自己的 Git worktree 中从 baseRef(默认 HEAD)开始执行,本机可以断开。仅在用户明确要求派发时调用,并明确选择 approvalPolicy;离线设备直接报错,不会在本机执行。", + "descriptionEn": "Submit work to another same-account device (name, alias, or device:) or SSH target (ssh:). The target runs in its own Git worktree branched from baseRef (default HEAD) and this host may disconnect. Call only when the user asked to dispatch, and choose approvalPolicy explicitly; an offline device is an error, never a local run.", + "risk": "execute", + "inputSchema": { + "additionalProperties": false, + "properties": { + "agent": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "approvalPolicy": { + "enum": [ + "auto", + "reject-and-report" + ], + "type": "string" + }, + "baseRef": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "includeUncommitted": { + "type": "boolean" + }, + "model": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "prompt": { + "maxLength": 32768, + "minLength": 1, + "type": "string" + }, + "target": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "title": { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "workspacePath": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "target", + "prompt", + "approvalPolicy", + "workspacePath" + ], + "type": "object" + }, + "argumentScopes": { + "workspacePath": "productHostLocal" + } + }, + { + "id": "status", + "titleZh": "查询派发任务", + "titleEn": "Read dispatch job status", + "descriptionZh": "按 jobId 读取本机派发任务的状态与增量事件;cursor 传上次返回值以只取新事件。", + "descriptionEn": "Read state and incremental events of a job submitted from this host; pass the previous cursor to read only new events.", + "risk": "read", + "inputSchema": { + "additionalProperties": false, + "properties": { + "cursor": { + "minimum": 0, + "type": "integer" + }, + "jobId": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "jobId" + ], + "type": "object" + } + }, + { + "id": "list-jobs", + "titleZh": "列出派发任务", + "titleEn": "List dispatch jobs", + "descriptionZh": "不带 target 时列出本机提交过的任务;带 target 时直接询问该目标正在持有的任务。", + "descriptionEn": "Without target, list jobs submitted from this host; with target, ask that target for the jobs it owns.", + "risk": "read", + "inputSchema": { + "additionalProperties": false, + "properties": { + "target": { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + }, + "required": [], + "type": "object" + } + }, + { + "id": "cancel", + "titleZh": "取消派发任务", + "titleEn": "Cancel a dispatch job", + "descriptionZh": "取消本机提交的指定任务;目标端停止执行并保留记录。", + "descriptionEn": "Cancel a job submitted from this host; the target stops it and keeps its record.", + "risk": "destructive", + "inputSchema": { + "additionalProperties": false, + "properties": { + "jobId": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "jobId" + ], + "type": "object" + } + } + ], "options": [], "searchTerms": [ "feature.detached-dispatch", @@ -25810,32 +26171,38 @@ "remote execution", "target host", "offline job", - "发现与探测目标主机", - "派发、继续或取消任务", + "列出同账户设备与 SSH 目标", + "派发、查询或取消任务", "断线后重新加载事件与执行记录", - "Discover and probe target hosts", - "Dispatch, continue, or cancel jobs", + "List same-account devices and SSH targets", + "Dispatch, inspect, or cancel jobs", "Reload events and execution history after reconnecting", - "列出可派发目标并探测版本、连接和运行能力", - "List dispatch targets and probe their version, connectivity, and runtime capabilities", + "列出同账户设备与已保存的 SSH 目标", + "List same-account devices and saved SSH targets", "为目标主机配置运行环境并安装或更新 OpenBitFun CLI", "Provision the target runtime and install or update the OpenBitFun CLI", "在提交任务前同步目标所需的模型配置", "Synchronize required model configuration before submitting a job", - "把包含工作区、会话、模型和权限策略的持久任务提交到目标", - "Submit a durable job with workspace, session, model, and permission policy to the target", + "从当前 Git 工作区把持久任务提交到同账户设备或 SSH 目标", + "Submit a durable job from the current Git workspace to a same-account device or SSH target", "列出派发任务并查询运行状态、结果和错误", "List dispatched jobs and query their status, result, and errors", "保存、追加和重新加载可断线恢复的执行记录", "Save, append, and reload reconnectable execution transcripts", - "远程回答权限请求,并继续或取消目标端任务", - "Answer permission requests remotely and continue or cancel target-side work", + "远程回答权限请求并继续目标端任务", + "Answer permission requests remotely and continue target-side work", + "取消本机派发的任务", + "Cancel a job submitted from this host", "将目标端结果同步回控制端,而不把控制端当作远程文件系统代理", "Synchronize target-side results back without treating the controller as a remote-filesystem proxy", + "我有几台设备?", + "把这个任务派发到我的 MacBook 上跑", + "看看刚才派发的任务跑完没有", "打开设备管理,我想把任务派发到另一台机器", - "带我配置远程任务派发", - "Open device management so I can dispatch work to another machine", - "Take me to remote task dispatch setup" + "How many devices do I have?", + "Dispatch this task to my MacBook", + "Check whether the job I dispatched has finished", + "Open device management so I can dispatch work to another machine" ], "docsUrl": "https://playbook.openbitfun.com/capabilities/feature.detached-dispatch/" }, diff --git a/src/web-ui/src/infrastructure/api/generated/productControl.ts b/src/web-ui/src/infrastructure/api/generated/productControl.ts index 194486b12d..7dfc4482bb 100644 --- a/src/web-ui/src/infrastructure/api/generated/productControl.ts +++ b/src/web-ui/src/infrastructure/api/generated/productControl.ts @@ -1,5 +1,5 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. -export const PRODUCT_CONTROL_GRAPH_DIGEST = "2eb272d6820970e0c8ac677714ca03495729fa4a9cd458f1b89a4de726971107" as const; +export const PRODUCT_CONTROL_GRAPH_DIGEST = "a4e451cd4268edcf15c00d41b4e5c8c6a476eb407e1324698d3db2b3f2f1ad34" as const; export type ProductControlCapabilityId = "feature.ai-assistant" | "feature.agents" | "feature.personal-assistants" | "feature.projects" | "feature.files-editor" | "feature.terminal" | "feature.git" | "feature.code-review" | "feature.browser" | "feature.computer-use" | "feature.skills" | "feature.miniapps" | "feature.canvas" | "feature.tasks-automation" | "feature.insights" | "feature.ecosystem-compatibility" | "feature.remote-workspaces" | "feature.remote-connect" | "feature.detached-dispatch" | "feature.pages" | "feature.voice-input" | "feature.desktop-pet" | "feature.desktop-cards" | "setting.application.general" | "setting.application.appearance" | "setting.application.pet" | "setting.application.input" | "setting.application.shortcuts" | "setting.application.development" | "setting.ai.models" | "setting.ai.memory" | "setting.workspace.session" | "setting.workspace.worktrees" | "setting.tools.execution" | "setting.application.terminal" | "setting.tools.desktop-control" | "setting.tools.browser-control" | "setting.tools.automation" | "setting.tools.web-search" | "setting.tools.mcp" | "setting.tools.acp" | "setting.data.usage" | "setting.data.archived" | "setting.data.diagnostics"; @@ -69,7 +69,7 @@ export interface ProductControlOperationIdsByCapability { "feature.ecosystem-compatibility": never; "feature.remote-workspaces": never; "feature.remote-connect": "status" | "start" | "stop" | "configure-bot" | "weixin-login-start" | "weixin-login-poll" | "set-bot-verbose"; - "feature.detached-dispatch": never; + "feature.detached-dispatch": "list-targets" | "submit" | "status" | "list-jobs" | "cancel"; "feature.pages": never; "feature.voice-input": never; "feature.desktop-pet": never; From 0ddce443d4fdcc3aa8ce999126526bdee1cf97b5 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 31 Oct 2026 18:03:19 +0800 Subject: [PATCH 3/4] feat(agent): point device and dispatch questions at list-targets Tell the Claw and OpenBitFun agents, and the OpenBitFunControl description, that same-account devices and Detached Dispatch live under feature.detached-dispatch, to read list-targets before stating a device count, and to submit only on the user's request without falling back to this host. --- .../agent-content/prompts/agents/claw_mode.md | 11 +++++++++++ .../agent-content/prompts/agents/openbitfun_agent.md | 4 ++++ .../tools/implementations/openbitfun_control_tool.rs | 2 +- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/crates/assembly/agent-content/prompts/agents/claw_mode.md b/src/crates/assembly/agent-content/prompts/agents/claw_mode.md index c98f9ccfd4..96a6a90ef1 100644 --- a/src/crates/assembly/agent-content/prompts/agents/claw_mode.md +++ b/src/crates/assembly/agent-content/prompts/agents/claw_mode.md @@ -31,6 +31,17 @@ and failed states. Use the supported private credential-file flow for other bots never print or ask users to paste tokens into chat. A remote workspace's files are not the executing host's credential files. Report unsupported methods as returned. +# Devices and Detached Dispatch + +Other OpenBitFun hosts signed in to the same account are dispatch targets. Before +stating how many devices the user has, or whether work can go to another +machine, run `list-targets` on `feature.detached-dispatch` through +`OpenBitFunControl`; Remote Connect status describes only this host. Submit only +when the user asked to send work elsewhere, choose `approvalPolicy` explicitly, +and pass an absolute Git workspace on this host. The target keeps running if +this host disconnects; follow it with `status` and `list-jobs`. An offline or +unknown device is an error: report it and never run the task here instead. + # Session Coordination For complex coding tasks or office-style multi-step tasks, prefer multi-session coordination when the required session tools are available. Otherwise, keep ownership in the current session. diff --git a/src/crates/assembly/agent-content/prompts/agents/openbitfun_agent.md b/src/crates/assembly/agent-content/prompts/agents/openbitfun_agent.md index 02169bef1e..b29f859c4a 100644 --- a/src/crates/assembly/agent-content/prompts/agents/openbitfun_agent.md +++ b/src/crates/assembly/agent-content/prompts/agents/openbitfun_agent.md @@ -12,6 +12,10 @@ Never claim an action succeeded without its returned result. If an operation is interactive-only or unavailable on the target host, say so and open the supported surface when useful. Ask only for missing intent or a required permission. +Same-account devices and Detached Dispatch are under `feature.detached-dispatch`: +read `list-targets` before stating how many devices exist, and submit work to +another host only on the user's request, never falling back to this host. + For OpenBitFun settings, including configuring MCP servers, prefer the structured OpenBitFunControl operations over ComputerUse. Search for MCP, get the matching capability, and use its returned server list, configuration and lifecycle schemas. diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/openbitfun_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/openbitfun_control_tool.rs index 69dff77434..0fe11a9acd 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/openbitfun_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/openbitfun_control_tool.rs @@ -310,7 +310,7 @@ impl Tool for OpenBitFunControlTool { async fn description(&self) -> OpenBitFunResult { Ok( - "Control OpenBitFun features and settings, including MCP servers and Remote Connect bots/Mobile Web. Prefer this tool over ComputerUse. Use the two-step flow: `list` or `search`, then `get` the matching capability and follow its schema and availability to `configure`, `execute`, or `open`. Open a root with capability_id only. destination.actionId is presentation metadata, never an item_id or operation_id. The catalog loads on demand; never guess IDs or values." + "Control OpenBitFun features and settings, including MCP servers, Remote Connect bots/Mobile Web, and same-account devices with Detached Dispatch to other hosts. Prefer this tool over ComputerUse. Use the two-step flow: `list` or `search`, then `get` the matching capability and follow its schema and availability to `configure`, `execute`, or `open`. Open a root with capability_id only. destination.actionId is presentation metadata, never an item_id or operation_id. The catalog loads on demand; never guess IDs or values." .to_string(), ) } From c6250a34a3ae50c1cb696a0278920c78dfd22707 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 31 Oct 2026 18:44:20 +0800 Subject: [PATCH 4/4] fix(web-ui): align dispatch search locales and widget theme contract --- src/web-ui/src/locales/en-US/scenes/capabilities.json | 7 ++++--- src/web-ui/src/locales/zh-CN/scenes/capabilities.json | 7 ++++--- src/web-ui/src/locales/zh-TW/scenes/capabilities.json | 7 ++++--- .../src/tools/generative-widget/appearancePayload.test.ts | 5 +++++ 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/web-ui/src/locales/en-US/scenes/capabilities.json b/src/web-ui/src/locales/en-US/scenes/capabilities.json index 1aab22cf6c..46e5658090 100644 --- a/src/web-ui/src/locales/en-US/scenes/capabilities.json +++ b/src/web-ui/src/locales/en-US/scenes/capabilities.json @@ -317,13 +317,14 @@ "title": "Detached Dispatch", "summary": "Dispatch durable work to another OpenBitFun host; the controller may disconnect while the target continues and keeps the record.", "items": { - "targets": "List dispatch targets and probe their version, connectivity, and runtime capabilities", + "targets": "List same-account devices and saved SSH targets", "provision": "Provision the target runtime and install or update the OpenBitFun CLI", "model-sync": "Synchronize required model configuration before submitting a job", - "submit": "Submit a durable job with workspace, session, model, and permission policy to the target", + "submit": "Submit a durable job from the current Git workspace to a same-account device or SSH target", "status-list": "List dispatched jobs and query their status, result, and errors", "transcript": "Save, append, and reload reconnectable execution transcripts", - "permission-continuation": "Answer permission requests remotely and continue or cancel target-side work", + "permission-continuation": "Answer permission requests remotely and continue target-side work", + "cancel": "Cancel a job submitted from this host", "result-sync": "Synchronize target-side results back without treating the controller as a remote-filesystem proxy" } }, diff --git a/src/web-ui/src/locales/zh-CN/scenes/capabilities.json b/src/web-ui/src/locales/zh-CN/scenes/capabilities.json index 535c42e816..e8b657e4bb 100644 --- a/src/web-ui/src/locales/zh-CN/scenes/capabilities.json +++ b/src/web-ui/src/locales/zh-CN/scenes/capabilities.json @@ -317,13 +317,14 @@ "title": "Detached Dispatch 远程派发", "summary": "把可持续运行的任务派发到另一台 OpenBitFun 主机;控制端可以断开,目标主机继续执行并保留记录。", "items": { - "targets": "列出可派发目标并探测版本、连接和运行能力", + "targets": "列出同账户设备与已保存的 SSH 目标", "provision": "为目标主机配置运行环境并安装或更新 OpenBitFun CLI", "model-sync": "在提交任务前同步目标所需的模型配置", - "submit": "把包含工作区、会话、模型和权限策略的持久任务提交到目标", + "submit": "从当前 Git 工作区把持久任务提交到同账户设备或 SSH 目标", "status-list": "列出派发任务并查询运行状态、结果和错误", "transcript": "保存、追加和重新加载可断线恢复的执行记录", - "permission-continuation": "远程回答权限请求,并继续或取消目标端任务", + "permission-continuation": "远程回答权限请求并继续目标端任务", + "cancel": "取消本机派发的任务", "result-sync": "将目标端结果同步回控制端,而不把控制端当作远程文件系统代理" } }, diff --git a/src/web-ui/src/locales/zh-TW/scenes/capabilities.json b/src/web-ui/src/locales/zh-TW/scenes/capabilities.json index 709f892571..0516ec948b 100644 --- a/src/web-ui/src/locales/zh-TW/scenes/capabilities.json +++ b/src/web-ui/src/locales/zh-TW/scenes/capabilities.json @@ -317,13 +317,14 @@ "title": "Detached Dispatch 遠程派發", "summary": "把可持續運行的任務派發到另一台 OpenBitFun 主機;控制端可以斷開,目標主機繼續執行並保留記錄。", "items": { - "targets": "列出可派發目標並探測版本、連接和運行能力", + "targets": "列出同帳戶設備與已保存的 SSH 目標", "provision": "為目標主機配置運行環境並安裝或更新 OpenBitFun CLI", "model-sync": "在提交任務前同步目標所需的模型配置", - "submit": "把包含工作區、工作階段、模型和權限策略的持久任務提交到目標", + "submit": "從當前 Git 工作區把持久任務提交到同帳戶設備或 SSH 目標", "status-list": "列出派發任務並查詢運行狀態、結果和錯誤", "transcript": "保存、追加和重新加載可斷線恢復的執行記錄", - "permission-continuation": "遠程回答權限請求,並繼續或取消目標端任務", + "permission-continuation": "遠程回答權限請求並繼續目標端任務", + "cancel": "取消本機派發的任務", "result-sync": "將目標端結果同步回控制端,而不把控制端當作遠程檔案系統代理" } }, diff --git a/src/web-ui/src/tools/generative-widget/appearancePayload.test.ts b/src/web-ui/src/tools/generative-widget/appearancePayload.test.ts index 2e50c0108f..ff8ec97001 100644 --- a/src/web-ui/src/tools/generative-widget/appearancePayload.test.ts +++ b/src/web-ui/src/tools/generative-widget/appearancePayload.test.ts @@ -98,6 +98,9 @@ const PROJECT_IDENTITY_THEME_VARIABLE_NAMES = [ '--openbitfun-color-identity-project-red', '--openbitfun-color-identity-project-yellow', ] as const; +// Translucent floating panels such as global search need a wider frost than the +// shared blur scale; assert this addition without changing the shared baseline. +const STRONG_BLUR_THEME_VARIABLE_NAME = '--openbitfun-effect-blur-strong'; const RETIRED_WIDGET_VARIABLE_NAMES = [ '--background-primary', '--bg-primary', @@ -156,6 +159,7 @@ describe('generated widget appearance payload contract', () => { && !COMPOSER_THEME_VARIABLE_NAMES.some(composerName => composerName === name) && !name.startsWith('--openbitfun-color-code-diff-') && !name.startsWith('--openbitfun-color-identity-project-') + && name !== STRONG_BLUR_THEME_VARIABLE_NAME )); expect(buttonNames).toEqual(BUTTON_THEME_VARIABLE_NAMES); expect(fieldStateNames).toEqual(FIELD_STATE_THEME_VARIABLE_NAMES); @@ -166,6 +170,7 @@ describe('generated widget appearance payload contract', () => { expect(WIDGET_APPEARANCE_VAR_NAMES).toContain(FIELD_GROUP_THEME_VARIABLE_NAME); expect(WIDGET_APPEARANCE_VAR_NAMES).toContain(CAPTION_THEME_VARIABLE_NAME); expect(WIDGET_APPEARANCE_VAR_NAMES).toContain(ACTION_CARD_THEME_VARIABLE_NAME); + expect(WIDGET_APPEARANCE_VAR_NAMES).toContain(STRONG_BLUR_THEME_VARIABLE_NAME); expect({ count: sharedNames.length, hash: hashNames(sharedNames),