From d1dc2bc7e74f798580dcf89d959fe54c8b773845 Mon Sep 17 00:00:00 2001 From: wsp Date: Thu, 8 Oct 2026 12:46:31 +0800 Subject: [PATCH] feat(tools): Add workspace discovery and session targeting SessionControl previously ignored the requested workspace when creating or listing sessions, using the caller's workspace instead. - Add read-only ListWorkspaces discovery with recent-access sorting, filtering, cursor pagination, and readable model results. - Resolve SessionControl.workspace by registered ID or caller-scoped absolute path, enabling local assistants to select remote workspaces. - Reject missing or ambiguous selections and preserve worktree execution targets and session-bound mutation routing. - Update assistant defaults, prompts, catalogs, UI metadata, and regression coverage for workspace discovery and selection. --- .../capabilities.json | 15 +- .../capabilities/feature.ai-assistant.md | 4 +- .../technical/product-control-open-audit.json | 2 +- .../technical/tauri-command-map.json | 2 +- .../agent-content/prompts/agents/claw_mode.md | 1 + src/crates/assembly/core/AGENTS.md | 7 + .../create-agent/references/tool-catalog.md | 3 +- .../agentic/agents/definitions/modes/claw.rs | 2 + .../src/agentic/tools/agent-tool-exposure.md | 1 + .../implementations/list_workspaces_tool.rs | 619 +++++++++++++++++ .../src/agentic/tools/implementations/mod.rs | 2 + .../implementations/session_control_tool.rs | 651 ++++++++++++++++-- .../core/src/agentic/tools/product_runtime.rs | 10 +- .../tools/product_runtime/materialization.rs | 11 + .../core/src/agentic/tools/registry.rs | 3 + .../core/src/service_agent_runtime.rs | 82 +++ .../runtime_boundary.rs | 1 + .../generated/product-control-catalog.json | 15 +- .../contracts/runtime-ports/src/agent_api.rs | 27 + .../agent-runtime/src/session_control.rs | 7 +- .../session_control_contracts.rs | 19 + .../execution/tool-contracts/src/framework.rs | 4 + .../execution/tool-provider-groups/src/lib.rs | 8 +- .../interactive-capabilities/catalog.json | 6 +- .../generated/interactive-capabilities.json | 15 +- .../flow_chat/tool-cards/toolCardMetadata.ts | 12 + .../api/generated/productControl.ts | 2 +- 27 files changed, 1459 insertions(+), 72 deletions(-) create mode 100644 src/crates/assembly/core/src/agentic/tools/implementations/list_workspaces_tool.rs diff --git a/docs/interactive-capabilities/capabilities.json b/docs/interactive-capabilities/capabilities.json index cf6d754182..0633bfe9e8 100644 --- a/docs/interactive-capabilities/capabilities.json +++ b/docs/interactive-capabilities/capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "63afe6c13213f11a7089fbb6208db930d563e4d3098f7c6dcf4c20a1f4e87271", + "digest": "434dc78df2ba3fa225ab75ab2839d8dcacebbf2e65123a49da3ceb4081c2988f", "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", "searchAcceptance": [ { @@ -563,6 +563,7 @@ "additionalProperties": true }, "delegateTools": [ + "ListWorkspaces", "SessionControl", "SessionHistory", "Read", @@ -611,6 +612,7 @@ "additionalProperties": true }, "delegateTools": [ + "ListWorkspaces", "SessionControl", "SessionMessage" ], @@ -19317,6 +19319,7 @@ "control": { "kind": "delegate", "tools": [ + "ListWorkspaces", "SessionControl", "SessionHistory", "Read", @@ -19339,15 +19342,16 @@ "control": { "kind": "delegate", "tools": [ + "ListWorkspaces", "SessionControl", "SessionMessage" ], "workflowZh": [ - "用 SessionControl 在明确工作区内创建或列出会话;取消只终止当前运行轮次,删除是独立动作", + "用 ListWorkspaces 获取运行主机登记的 workspace_id,再传给 SessionControl.workspace 创建或列出本地或远程会话;绝对路径仅在调用者执行环境中解析;取消只终止当前轮次,删除是独立动作", "需要让另一持久会话接手工作时用 SessionMessage,并保留异步返回;跨远程边界时沿用目标会话绑定,不得复用控制器路径" ], "workflowEn": [ - "Use SessionControl to create or list sessions in an explicit workspace; cancel affects only the active turn and delete is separate", + "Use ListWorkspaces to discover runtime-host workspace IDs, then pass one as SessionControl.workspace to create or list local or remote sessions; absolute paths resolve in the caller environment; cancel affects only the active turn and delete is separate", "Use SessionMessage when another persisted session should take over work and retain its asynchronous result; preserve the target session binding across remote boundaries" ] } @@ -19575,6 +19579,7 @@ "Call FrontendWorkbench prepare on the local Desktop in Creative mode, read the packaged API, and edit only draft CSS, JavaScript and owned assets without source or build tools", "Apply with the original draft ID; the host starts the immutable review window's 15-second countdown only after the real shell and customization activate, and apply waits for a final confirmed or rolled_back outcome", "Compose commands, state and events into reusable capabilities; use FrontendWorkbench inspect to discover schemas and diagnostics, then invoke to verify execution", + "ListWorkspaces", "SessionControl", "SessionHistory", "Grep", @@ -19583,9 +19588,9 @@ "Use SessionControl list to find the target session, then export its indexed transcript with SessionHistory", "Read only the exported index first, then use Read or Grep for the required turns instead of loading the entire history into context", "SessionMessage", - "用 SessionControl 在明确工作区内创建或列出会话;取消只终止当前运行轮次,删除是独立动作", + "用 ListWorkspaces 获取运行主机登记的 workspace_id,再传给 SessionControl.workspace 创建或列出本地或远程会话;绝对路径仅在调用者执行环境中解析;取消只终止当前轮次,删除是独立动作", "需要让另一持久会话接手工作时用 SessionMessage,并保留异步返回;跨远程边界时沿用目标会话绑定,不得复用控制器路径", - "Use SessionControl to create or list sessions in an explicit workspace; cancel affects only the active turn and delete is separate", + "Use ListWorkspaces to discover runtime-host workspace IDs, then pass one as SessionControl.workspace to create or list local or remote sessions; absolute paths resolve in the caller environment; cancel affects only the active turn and delete is separate", "Use SessionMessage when another persisted session should take over work and retain its asynchronous result; preserve the target session binding across remote boundaries", "get_goal", "create_goal", diff --git a/docs/interactive-capabilities/capabilities/feature.ai-assistant.md b/docs/interactive-capabilities/capabilities/feature.ai-assistant.md index 1d671030ad..31925389d5 100644 --- a/docs/interactive-capabilities/capabilities/feature.ai-assistant.md +++ b/docs/interactive-capabilities/capabilities/feature.ai-assistant.md @@ -29,9 +29,9 @@ Work with AI in project context, create sessions, continue tasks, answer permiss - Start, steer, interrupt, cancel, or recover an agent turn - **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 查看并逐个或批量回答等待中的权限请求 - Review and answer pending permission requests individually or in a batch -- **由专用智能体工具控制 / Delegated Agent tool** · `SessionControl` / `SessionHistory` / `Read` / `Grep` · 分页加载、搜索和回填历史消息与会话内容 +- **由专用智能体工具控制 / Delegated Agent tool** · `ListWorkspaces` / `SessionControl` / `SessionHistory` / `Read` / `Grep` · 分页加载、搜索和回填历史消息与会话内容 - Page through, search, and backfill historical turns and session content -- **由专用智能体工具控制 / Delegated Agent tool** · `SessionControl` / `SessionMessage` · 让智能体创建、列出、取消、删除会话或向另一会话发送消息 +- **由专用智能体工具控制 / Delegated Agent tool** · `ListWorkspaces` / `SessionControl` / `SessionMessage` · 让智能体创建、列出、取消、删除会话或向另一会话发送消息 - Let an agent create, list, cancel, or delete sessions and send messages to another session - **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 在对话中通过 @ 选择当前模式可用的 MCP 服务 - Use @ in chat to select MCP servers available to the current mode diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json index 9aab10f073..11d7bbe2a6 100644 --- a/docs/interactive-capabilities/technical/product-control-open-audit.json +++ b/docs/interactive-capabilities/technical/product-control-open-audit.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "63afe6c13213f11a7089fbb6208db930d563e4d3098f7c6dcf4c20a1f4e87271", + "catalogDigest": "434dc78df2ba3fa225ab75ab2839d8dcacebbf2e65123a49da3ceb4081c2988f", "count": 212, "reasonCounts": { "externalAuth": 4, diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index 2fae62de86..08e163c700 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": "63afe6c13213f11a7089fbb6208db930d563e4d3098f7c6dcf4c20a1f4e87271", + "catalogDigest": "434dc78df2ba3fa225ab75ab2839d8dcacebbf2e65123a49da3ceb4081c2988f", "commandCount": 668, "coverage": { "commandCount": 668, 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 0c62545184..970faef188 100644 --- a/src/crates/assembly/agent-content/prompts/agents/claw_mode.md +++ b/src/crates/assembly/agent-content/prompts/agents/claw_mode.md @@ -43,6 +43,7 @@ Choose the session type intentionally: - `Standard` for implementation, debugging, code changes, and planning tasks; ask it to use the built-in `plan` Skill when a plan artifact is the deliverable. - `Cowork` for research, documents, presentations, summaries, and other office-related work. +- `DeepResearch` for systematic investigation and evidence-driven reports. Local computer/desktop work is not a SessionControl session type; use the `ComputerUse` tool directly when available. diff --git a/src/crates/assembly/core/AGENTS.md b/src/crates/assembly/core/AGENTS.md index de579e388e..62f3509a7f 100644 --- a/src/crates/assembly/core/AGENTS.md +++ b/src/crates/assembly/core/AGENTS.md @@ -283,6 +283,13 @@ Skill discovery, installation provenance, and local/remote registry regressions: cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git --lib agentic::tools::implementations::skills:: ``` +SessionControl workspace selection, remote routing, and session-targeted actions: + +```bash +cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git --lib session_control_tool::tests +cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git --lib list_workspaces +``` + Skill hook activation, session cleanup, and tool preflight/permission ordering: ```bash diff --git a/src/crates/assembly/core/builtin_skills/create-agent/references/tool-catalog.md b/src/crates/assembly/core/builtin_skills/create-agent/references/tool-catalog.md index 889b5f9099..e84c310ed5 100644 --- a/src/crates/assembly/core/builtin_skills/create-agent/references/tool-catalog.md +++ b/src/crates/assembly/core/builtin_skills/create-agent/references/tool-catalog.md @@ -109,7 +109,8 @@ Use these tables to select tools for the user's tasks, then put their exact name | `ReadMCPResource` | Read a connected MCP server's resource by URI. | | `ListMCPPrompts` | List prompt templates exposed by a connected MCP server. | | `GetMCPPrompt` | Fetch and render a named prompt template from a connected MCP server. | -| `SessionControl` | Create, list, rename, cancel, and delete persisted agent sessions. | +| `ListWorkspaces` | Discover registered local, assistant, and remote workspace IDs on the runtime host without activating them. | +| `SessionControl` | Create or list sessions by workspace ID or caller-scoped absolute path; rename, cancel, or delete by session ID. | | `SessionMessage` | Send a message to another agent session and receive its result asynchronously. | | `SessionHistory` | Export an agent session transcript and index for targeted history reads. | | `PortForward` | Forward a port from an SSH host to the user's machine. | diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs index bfb17c6076..15b5cf1410 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs @@ -43,6 +43,7 @@ impl ClawMode { "create_goal".to_string(), "update_goal".to_string(), "Skill".to_string(), + "ListWorkspaces".to_string(), "SessionControl".to_string(), "SessionMessage".to_string(), "SessionHistory".to_string(), @@ -112,6 +113,7 @@ mod tests { assert!(!tools.contains(&"PublishMiniApp".to_string())); assert!(!tools.contains(&"FrontendWorkbench".to_string())); assert!(tools.contains(&"ListModels".to_string())); + assert!(tools.contains(&"ListWorkspaces".to_string())); } #[test] diff --git a/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md b/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md index 457e610836..8a0723edbd 100644 --- a/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md +++ b/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md @@ -61,6 +61,7 @@ Notes: | `ReadCanvas` | Direct | None | - | | `UpdateCanvas` | Direct | None | - | | `PatchCanvas` | Direct | None | - | +| `ListWorkspaces` | Deferred | None | - | | `SessionControl` | Deferred | None | - | | `SessionMessage` | Deferred | None | - | | `SessionHistory` | Deferred | None | - | diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/list_workspaces_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/list_workspaces_tool.rs new file mode 100644 index 0000000000..d059627154 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/list_workspaces_tool.rs @@ -0,0 +1,619 @@ +//! Read-only discovery of registered workspaces on the runtime's owning host. + +use crate::agentic::tools::framework::{ + Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, +}; +use crate::service_agent_runtime::CoreWorkspaceCatalogPort; +use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; +use async_trait::async_trait; +use openbitfun_core_types::WorkspaceKind; +use openbitfun_runtime_ports::{AgentWorkspaceCatalogEntry, AgentWorkspaceCatalogPort}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::sync::Arc; + +pub struct ListWorkspacesTool { + catalog: Arc, +} + +impl Default for ListWorkspacesTool { + fn default() -> Self { + Self::new() + } +} + +fn default_limit() -> usize { + 50 +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Input { + query: Option, + kind: Option, + #[serde(default = "default_limit")] + limit: usize, + cursor: Option, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Cursor { + after: String, + last_accessed_at_ms: i64, + query: String, + kind: Option, +} + +impl Input { + fn parse(value: &Value) -> Result { + let input: Self = serde_json::from_value(value.clone()) + .map_err(|error| format!("Invalid input: {error}"))?; + if !(1..=200).contains(&input.limit) { + return Err("limit must be between 1 and 200".into()); + } + input.after()?; + Ok(input) + } + + fn query(&self) -> String { + self.query + .as_deref() + .unwrap_or_default() + .trim() + .to_lowercase() + } + + fn after(&self) -> Result, String> { + let Some(cursor) = self.cursor.as_deref() else { + return Ok(None); + }; + let cursor: Cursor = + serde_json::from_str(cursor).map_err(|_| "Invalid workspace cursor".to_string())?; + if cursor.query != self.query() || cursor.kind != self.kind { + return Err("Workspace cursor does not match this query and kind".into()); + } + Ok(Some(cursor)) + } +} + +impl ListWorkspacesTool { + pub fn new() -> Self { + Self { + catalog: Arc::new(CoreWorkspaceCatalogPort), + } + } + + fn result_for_assistant(output: &Value) -> String { + let mut lines = vec![ + format!( + "Registered workspaces on this runtime host: showing {} of {} matches (newest access first).", + output["count"].as_u64().unwrap_or_default(), + output["total"].as_u64().unwrap_or_default() + ), + format!( + "Current workspace ID: {}", + output["current_workspace_id"].as_str().unwrap_or("none") + ), + "Catalog metadata only; remote connectivity was not checked.".into(), + ]; + if let Some(workspaces) = output["workspaces"].as_array() { + if workspaces.is_empty() { + lines.push("\nNo workspaces on this page.".into()); + } + for (index, workspace) in workspaces.iter().enumerate() { + let current = if workspace["is_current"].as_bool() == Some(true) { + " (current)" + } else { + "" + }; + lines.push(format!( + "\n{}. {} [{}]{}", + index + 1, + workspace["name"].as_str().unwrap_or("Unnamed workspace"), + workspace["kind"].as_str().unwrap_or("unknown"), + current + )); + for (label, key) in [ + ("Workspace ID", "workspace_id"), + ("Project workspace ID", "project_workspace_id"), + ("Root path", "root_path"), + ] { + if key == "project_workspace_id" && workspace[key] == workspace["workspace_id"] + { + continue; + } + lines.push(format!( + " {label}: {}", + workspace[key].as_str().unwrap_or("not available") + )); + } + let accessed = workspace["last_accessed_at_ms"] + .as_i64() + .and_then(chrono::DateTime::from_timestamp_millis) + .map(|time| time.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) + .unwrap_or_else(|| "not available".into()); + lines.push(format!(" Last accessed (UTC): {accessed}")); + if workspace["remote"].is_object() { + for (label, key) in + [("SSH host", "host"), ("SSH connection ID", "connection_id")] + { + lines.push(format!( + " {label}: {}", + workspace["remote"][key].as_str().unwrap_or("not available") + )); + } + } + if let Some(error) = workspace["binding_error"].as_str() { + lines.push(format!(" Binding error: {error}")); + } + } + } + if let Some(cursor) = output["next_cursor"].as_str() { + lines.push(format!( + "\nMore workspaces are available. Call ListWorkspaces with the same query and kind, copying the following next_cursor unchanged into cursor:\n```text\n{cursor}\n```" + )); + } else { + lines.push("\nEnd of results; no next page.".into()); + } + lines.join("\n") + } + + fn result( + input: &Input, + records: Vec, + current: Option<&str>, + ) -> OpenBitFunResult { + let query = input.query(); + let after = input.after().map_err(OpenBitFunError::tool)?; + let mut records: Vec<_> = records + .into_iter() + .filter(|record| { + input.kind.as_ref().is_none_or(|kind| kind == &record.kind) + && query.split_whitespace().all(|term| { + let remote = record.remote.as_ref(); + [ + Some(record.workspace_id.as_str()), + Some(record.name.as_str()), + Some(record.root_path.as_str()), + remote.and_then(|route| route.host.as_deref()), + remote.and_then(|route| route.connection_id.as_deref()), + ] + .into_iter() + .flatten() + .any(|value| value.to_lowercase().contains(term)) + }) + }) + .collect(); + records.sort_by(|a, b| { + b.last_accessed_at_ms + .cmp(&a.last_accessed_at_ms) + .then_with(|| a.workspace_id.cmp(&b.workspace_id)) + }); + let total = records.len(); + records.retain(|record| { + after.as_ref().is_none_or(|cursor| { + record.last_accessed_at_ms < cursor.last_accessed_at_ms + || (record.last_accessed_at_ms == cursor.last_accessed_at_ms + && record.workspace_id > cursor.after) + }) + }); + let has_more = records.len() > input.limit; + records.truncate(input.limit); + let next_cursor = if has_more { + Some( + serde_json::to_string(&Cursor { + after: records.last().expect("nonempty page").workspace_id.clone(), + last_accessed_at_ms: records.last().expect("nonempty page").last_accessed_at_ms, + query, + kind: input.kind.clone(), + }) + .map_err(|error| OpenBitFunError::tool(error.to_string()))?, + ) + } else { + None + }; + let workspaces: Vec<_> = records + .into_iter() + .map(|record| { + let is_current = current == Some(record.workspace_id.as_str()); + let mut value = serde_json::to_value(record).expect("catalog DTO serializes"); + value["is_current"] = json!(is_current); + value + }) + .collect(); + Ok( + json!({ "success": true, "scope": "runtime_host", "current_workspace_id": current, + "count": workspaces.len(), "total": total, "workspaces": workspaces, "next_cursor": next_cursor }), + ) + } +} + +#[async_trait] +impl Tool for ListWorkspacesTool { + fn name(&self) -> &str { + "ListWorkspaces" + } + async fn description(&self) -> OpenBitFunResult { + Ok("List registered workspaces on this runtime host, including local, assistant, remote SSH, closed, and offline workspaces. Returns opaque workspace_id values, ordered by last recorded access from newest to oldest; equal timestamps use workspace ID as a stable tie-breaker. Pagination reads the live catalog, so recent activity can change ordering between calls.".into()) + } + fn short_description(&self) -> String { + "Discover registered workspace IDs for session operations.".into() + } + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Deferred + } + fn input_schema(&self) -> Value { + json!({"type":"object", "properties":{ + "query":{"type":"string", "description":"Optional search across workspace names, IDs, paths, and remote host/connection labels."}, + "kind":{"type":"string", "enum":["normal","assistant","remote"], "description":"Optional workspace kind; omit for all registered workspaces."}, + "limit":{"type":"integer", "minimum":1, "maximum":200, "description":"Page size; defaults to 50."}, + "cursor":{"type":"string", "description":"Opaque next_cursor from the preceding page; keep query and kind unchanged."} + }, "additionalProperties":false}) + } + fn is_readonly(&self) -> bool { + true + } + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + true + } + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + match Input::parse(input) { + Ok(_) => ValidationResult::default(), + Err(message) => ValidationResult { + result: false, + message: Some(message), + error_code: Some(400), + meta: None, + }, + } + } + fn render_tool_use_message(&self, _input: &Value, _options: &ToolRenderOptions) -> String { + "List registered workspaces".into() + } + fn render_tool_result_message(&self, output: &Value) -> String { + format!( + "Found {} workspace(s)", + output["count"].as_u64().unwrap_or_default() + ) + } + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> OpenBitFunResult> { + let input = Input::parse(input).map_err(OpenBitFunError::tool)?; + let records = self + .catalog + .list_workspaces() + .await + .map_err(|error| OpenBitFunError::tool(error.to_string()))?; + let data = Self::result(&input, records, context.workspace_id())?; + let assistant = Self::result_for_assistant(&data); + Ok(vec![ToolResult::ok(data, Some(assistant))]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agentic::WorkspaceBinding; + use crate::service::workspace::{ + WorkspaceActivityMode, WorkspaceCreateOptions, WorkspaceService, + }; + use openbitfun_runtime_ports::{ + AgentWorkspaceRemoteInfo, PortError, PortErrorKind, PortResult, + }; + use std::collections::HashMap; + use std::path::PathBuf; + + fn record(id: &str, kind: WorkspaceKind) -> AgentWorkspaceCatalogEntry { + AgentWorkspaceCatalogEntry { + workspace_id: id.into(), + project_workspace_id: Some(id.into()), + name: format!("Project {id}"), + root_path: "/project".into(), + remote: (kind == WorkspaceKind::Remote).then(|| AgentWorkspaceRemoteInfo { + connection_id: Some("offline-connection".into()), + host: Some("unreachable.example".into()), + }), + kind, + last_accessed_at_ms: 1_000, + binding_error: None, + } + } + + fn context() -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: Some("caller".into()), + dialog_turn_id: None, + workspace: Some(WorkspaceBinding::new( + Some("b".into()), + PathBuf::from("/caller"), + )), + loaded_deferred_tool_specs: Vec::new(), + primary_model_facts: Default::default(), + custom_data: HashMap::new(), + computer_use_host: None, + runtime_tool_restrictions: Default::default(), + runtime_handles: Default::default(), + } + } + + struct Catalog(PortResult>); + #[async_trait] + impl AgentWorkspaceCatalogPort for Catalog { + async fn list_workspaces(&self) -> PortResult> { + self.0.clone() + } + } + + #[test] + fn list_workspaces_orders_recent_access_and_paginates_ties() { + let records: Vec<_> = [ + ("a", 1_000), + ("z", 3_000), + ("c", 2_000), + ("b", 2_000), + ("u", 0), + ] + .into_iter() + .map(|(id, time)| { + let mut row = record(id, WorkspaceKind::Normal); + row.last_accessed_at_ms = time; + row + }) + .collect(); + let mut cursor: Option = None; + let mut ids = Vec::new(); + loop { + let page = ListWorkspacesTool::result( + &Input::parse(&json!({"limit":2, "cursor":cursor})).unwrap(), + records.clone(), + None, + ) + .unwrap(); + ids.extend( + page["workspaces"] + .as_array() + .unwrap() + .iter() + .map(|row| row["workspace_id"].as_str().unwrap().to_owned()), + ); + cursor = page["next_cursor"].as_str().map(str::to_owned); + if cursor.is_none() { + break; + } + } + assert_eq!(ids, ["z", "b", "c", "a", "u"]); + } + + #[test] + fn list_workspaces_paging_and_filters_preserve_identity() { + let records = vec![ + record("c", WorkspaceKind::Assistant), + record("b", WorkspaceKind::Remote), + record("a", WorkspaceKind::Normal), + ]; + let first = ListWorkspacesTool::result( + &Input::parse(&json!({"limit":1})).unwrap(), + records.clone(), + Some("b"), + ) + .unwrap(); + assert_eq!(first["workspaces"][0]["workspace_id"], "a"); + assert_eq!(first["total"], 3); + let cursor = first["next_cursor"].as_str().unwrap(); + let second = ListWorkspacesTool::result( + &Input::parse(&json!({"limit":2,"cursor":cursor})).unwrap(), + records.clone(), + Some("b"), + ) + .unwrap(); + assert_eq!(second["workspaces"][0]["workspace_id"], "b"); + assert_eq!(second["workspaces"][0]["is_current"], true); + assert_eq!(second["workspaces"][1]["workspace_id"], "c"); + assert!(second["next_cursor"].is_null()); + let filtered = ListWorkspacesTool::result( + &Input::parse(&json!({"kind":"remote","query":"PROJECT unreachable"})).unwrap(), + records, + None, + ) + .unwrap(); + assert_eq!(filtered["total"], 1); + assert_eq!( + filtered["workspaces"][0]["remote"]["connection_id"], + "offline-connection" + ); + for invalid in [ + json!({"limit":0}), + json!({"limit":201}), + json!({"kind":"ssh"}), + json!({"cursor":"bad"}), + json!({"query":"changed","cursor":cursor}), + json!({"kind":"remote","cursor":cursor}), + json!({"workspace":"unexpected"}), + ] { + assert!(Input::parse(&invalid).is_err(), "{invalid}"); + } + } + + #[tokio::test] + async fn list_workspaces_call_returns_model_visible_ids_and_provider_errors() { + let mut broken = record("b", WorkspaceKind::Remote); + broken.remote.as_mut().unwrap().connection_id = None; + broken.binding_error = Some("Missing saved route".into()); + let tool = ListWorkspacesTool { + catalog: Arc::new(Catalog(Ok(vec![broken]))), + }; + let output = tool.call_impl(&json!({}), &context()).await.unwrap(); + let ToolResult::Result { + data, + result_for_assistant, + .. + } = &output[0] + else { + panic!("expected result") + }; + assert_eq!(data["scope"], "runtime_host"); + assert_eq!(data["current_workspace_id"], "b"); + assert_eq!( + data["workspaces"][0]["binding_error"], + "Missing saved route" + ); + let assistant = result_for_assistant.as_ref().unwrap(); + assert!(assistant.contains("1. Project b [remote] (current)")); + assert!(assistant.contains("Workspace ID: b")); + assert!(assistant.contains("Root path: /project")); + assert!(assistant.contains("SSH host: unreachable.example")); + assert!(assistant.contains("SSH connection ID: not available")); + assert!(assistant.contains("Binding error: Missing saved route")); + assert!(assistant.contains("Last accessed (UTC): 1970-01-01T00:00:01Z")); + assert!(serde_json::from_str::(assistant).is_err()); + assert!(tool.is_readonly()); + assert!(tool.is_concurrency_safe(None)); + assert!(matches!(tool.default_exposure(), ToolExposure::Deferred)); + let unavailable = ListWorkspacesTool { + catalog: Arc::new(Catalog(Err(PortError::new( + PortErrorKind::NotAvailable, + "offline catalog", + )))), + }; + assert!(unavailable + .call_impl(&json!({}), &context()) + .await + .unwrap_err() + .to_string() + .contains("offline catalog")); + } + + #[test] + fn list_workspaces_readable_result_preserves_cursor_and_empty_pages() { + let mut worktree = record("local-id", WorkspaceKind::Normal); + worktree.project_workspace_id = Some("project-id".into()); + let records = vec![worktree, record("remote-id", WorkspaceKind::Remote)]; + let page = ListWorkspacesTool::result( + &Input::parse(&json!({"limit":1})).unwrap(), + records.clone(), + None, + ) + .unwrap(); + let rendered = ListWorkspacesTool::result_for_assistant(&page); + assert!(rendered.contains("showing 1 of 2 matches")); + assert!(rendered.contains("Current workspace ID: none")); + assert!(rendered.contains("1. Project local-id [normal]")); + assert!(rendered.contains("Project workspace ID: project-id")); + assert_eq!(page["workspaces"][0]["project_workspace_id"], "project-id"); + assert!(!rendered.contains("SSH host:")); + let cursor = rendered + .split("```text\n") + .nth(1) + .unwrap() + .split("\n```") + .next() + .unwrap(); + assert_eq!(cursor, page["next_cursor"].as_str().unwrap()); + let next = ListWorkspacesTool::result( + &Input::parse(&json!({"cursor":cursor})).unwrap(), + records, + None, + ) + .unwrap(); + assert_eq!(next["workspaces"][0]["workspace_id"], "remote-id"); + assert_eq!(next["workspaces"][0]["project_workspace_id"], "remote-id"); + assert!(!ListWorkspacesTool::result_for_assistant(&next).contains("Project workspace ID:")); + let empty = + ListWorkspacesTool::result(&Input::parse(&json!({})).unwrap(), Vec::new(), None) + .unwrap(); + let rendered = ListWorkspacesTool::result_for_assistant(&empty); + assert!(rendered.contains("showing 0 of 0 matches")); + assert!(rendered.contains("No workspaces on this page.")); + assert!(rendered.contains("End of results; no next page.")); + } + + #[tokio::test] + async fn list_workspaces_catalog_retains_closed_and_offline_records_without_activation() { + let root = std::env::temp_dir().join(format!("workspace-catalog-{}", uuid::Uuid::new_v4())); + let service = WorkspaceService::new_isolated_for_tests(root.join("user")).await; + std::fs::create_dir_all(root.join("local")).unwrap(); + let local = service.open_workspace(root.join("local")).await.unwrap(); + service.close_workspace(&local.id).await.unwrap(); + let remote = service + .track_workspace_activity( + PathBuf::from("/offline//project/"), + WorkspaceCreateOptions { + workspace_kind: WorkspaceKind::Remote, + remote_connection_id: Some("offline".into()), + remote_ssh_host: Some("offline.example".into()), + ..Default::default() + }, + WorkspaceActivityMode::TouchOnly, + ) + .await + .unwrap(); + // Retain legacy/incomplete routing metadata so discovery can report it. + let mut broken = remote.clone(); + broken.id = "legacy-remote".into(); + broken.metadata.remove("connectionId"); + service + .get_manager() + .write() + .await + .get_workspaces_mut() + .insert(broken.id.clone(), broken); + std::fs::create_dir_all(root.join("assistant")).unwrap(); + let assistant = service + .track_workspace_activity( + root.join("assistant"), + WorkspaceCreateOptions { + workspace_kind: WorkspaceKind::Assistant, + assistant_id: Some("assistant".into()), + ..Default::default() + }, + WorkspaceActivityMode::TouchOnly, + ) + .await + .unwrap(); + let before = service.get_opened_workspaces().await; + let rows = CoreWorkspaceCatalogPort::list_from_service(&service).await; + assert!(rows + .iter() + .any(|row| row.workspace_id == local.id && row.kind == WorkspaceKind::Normal)); + let row = rows + .iter() + .find(|row| row.workspace_id == remote.id) + .unwrap(); + assert_eq!(row.root_path, "/offline/project"); + assert_eq!( + row.last_accessed_at_ms, + remote.last_accessed.timestamp_millis() + ); + assert_eq!( + row.remote.as_ref().unwrap().connection_id.as_deref(), + Some("offline") + ); + let broken = rows + .iter() + .find(|row| row.workspace_id == "legacy-remote") + .unwrap(); + assert!(broken.binding_error.is_some()); + assert!(broken.remote.as_ref().unwrap().connection_id.is_none()); + assert!(rows + .iter() + .any(|row| row.workspace_id == assistant.id && row.kind == WorkspaceKind::Assistant)); + assert_eq!(service.get_opened_workspaces().await.len(), before.len()); + assert!(!service + .get_opened_workspaces() + .await + .iter() + .any(|row| row.id == local.id || row.id == remote.id)); + drop(service); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs index ae43d5de30..ceff3b8e3b 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs @@ -42,6 +42,7 @@ pub mod get_time_tool; pub mod glob_tool; pub mod grep_tool; pub mod list_models_tool; +pub mod list_workspaces_tool; pub mod ls_tool; #[cfg(feature = "tools-mcp")] pub mod mcp_tools; @@ -112,6 +113,7 @@ pub use get_time_tool::GetTimeTool; pub use glob_tool::GlobTool; pub use grep_tool::GrepTool; pub use list_models_tool::ListModelsTool; +pub use list_workspaces_tool::ListWorkspacesTool; pub use ls_tool::LSTool; #[cfg(feature = "tools-mcp")] pub use mcp_tools::{ diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs index 945f34a549..5e0f0b5f22 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs @@ -9,6 +9,7 @@ use crate::agentic::coordination::{get_global_coordinator, get_global_scheduler} use crate::agentic::tools::framework::{ Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; +use crate::service::workspace::{get_global_workspace_service, WorkspaceService}; use crate::service_agent_runtime::CoreServiceAgentRuntime; use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; use async_trait::async_trait; @@ -29,6 +30,7 @@ use openbitfun_runtime_ports::{ AgentSessionRenameRequest, AgentSessionSummary, AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, AgentSubmissionSource, AgentTurnCancellationRequest, }; +use openbitfun_services_core::workspace_identity::normalize_remote_workspace_path; use serde_json::{json, Value}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -61,14 +63,19 @@ impl SessionControlTool { fn current_workspace_session<'a>( &self, context: &'a ToolUseContext, - workspace: &str, + workspace: &SessionControlWorkspaceTarget, ) -> Option<&'a str> { let current_session_id = context.session_id.as_deref()?; - let current_workspace = context.workspace_root()?; - let normalized_current_workspace = - normalize_path(current_workspace.to_string_lossy().as_ref()); - - if normalized_current_workspace == workspace { + let current_workspace = context.workspace.as_ref()?; + let matches = match (¤t_workspace.workspace_id, &workspace.workspace_id) { + (Some(current), Some(target)) => current == target, + _ => { + let current = Self::workspace_target_from_context(current_workspace); + current.display_workspace == workspace.display_workspace + && current.remote_connection_id == workspace.remote_connection_id + } + }; + if matches { Some(current_session_id) } else { None @@ -98,6 +105,7 @@ impl SessionControlTool { &self, action: SessionControlAction, session_id: Option<&str>, + requested_workspace: Option<&str>, context: &ToolUseContext, runtime: &AgentRuntime, ) -> OpenBitFunResult { @@ -125,23 +133,139 @@ impl SessionControlTool { ))) } SessionControlAction::Create | SessionControlAction::List => { - let workspace = context.workspace.as_ref().ok_or_else(|| { - OpenBitFunError::tool(format!( - "workspace is required for {} when the current workspace is unavailable", - action.as_str() - )) + let workspace = requested_workspace.ok_or_else(|| { + OpenBitFunError::tool(format!("workspace is required for {}", action.as_str())) + })?; + let service = get_global_workspace_service().ok_or_else(|| { + OpenBitFunError::tool("Workspace service is unavailable".to_string()) })?; - Ok(Self::workspace_target_from_context(workspace)) + Self::resolve_requested_workspace(workspace, context, &service).await } } } + async fn resolve_requested_workspace( + workspace: &str, + context: &ToolUseContext, + service: &WorkspaceService, + ) -> OpenBitFunResult { + let current = context.workspace.as_ref(); + let operand = workspace.trim(); + let record = if let Some(record) = service.get_workspace(operand).await { + record + } else { + let remote = current.is_some_and(|binding| binding.is_remote()); + let absolute = if remote { + operand.starts_with('/') + } else { + std::path::Path::new(operand).is_absolute() + }; + if !absolute { + return Err(OpenBitFunError::NotFound(format!( + "Workspace ID '{}' is unavailable on this host; use ListWorkspaces to select a registered ID or provide an absolute path in the caller's environment", + operand + ))); + } + let path = if current.is_some_and(|binding| binding.is_remote()) { + normalize_remote_workspace_path(operand) + } else { + normalize_path(operand) + }; + // Paths are scoped to the caller's filesystem provider. Never inspect + // a remote path with local filesystem APIs or select another SSH host. + let canonical = (!remote).then(|| dunce::canonicalize(&path).ok()).flatten(); + let candidates: Vec<_> = service + .list_workspace_infos() + .await + .into_iter() + .filter(|record| { + if remote { + record.workspace_kind == crate::service::workspace::WorkspaceKind::Remote + && record.remote_ssh_connection_id() + == current.and_then(|binding| binding.connection_id()) + && normalize_remote_workspace_path(&record.root_path.to_string_lossy()) + == path + } else { + record.workspace_kind != crate::service::workspace::WorkspaceKind::Remote + && (normalize_path(&record.root_path.to_string_lossy()) == path + || canonical + .as_ref() + .is_some_and(|value| value == &record.root_path)) + } + }) + .collect(); + // A caller may execute in an unregistered worktree. Preserve that + // explicit binding, but only after rejecting ambiguous catalog paths. + if candidates.len() <= 1 { + if let Some(binding) = current { + let target = Self::workspace_target_from_context(binding); + if target.workspace_id.is_some() && target.display_workspace == path { + return Ok(target); + } + } + } + match candidates.as_slice() { + [] => { + return Err(OpenBitFunError::NotFound(format!( + "Workspace '{}' is not registered in the caller's environment", + workspace + ))) + } + [record] => record.clone(), + _ => { + return Err(OpenBitFunError::tool( + "Workspace path is ambiguous; use ListWorkspaces to select a workspace ID" + .to_string(), + )) + } + } + }; + if let Some(binding) = + current.filter(|binding| binding.workspace_id.as_deref() == Some(record.id.as_str())) + { + return Ok(Self::workspace_target_from_context(binding)); + } + let mut config = crate::agentic::core::SessionConfig::default(); + crate::agentic::workspace::apply_workspace_record(&mut config, &record)?; + let project = service + .require_workspace( + config + .project_workspace_id + .as_deref() + .expect("resolved project ID"), + ) + .await?; + let normalize = |path: &str| { + if config.is_remote_workspace() { + normalize_remote_workspace_path(path) + } else { + normalize_path(path) + } + }; + Ok(SessionControlWorkspaceTarget { + display_workspace: normalize(&record.root_path.to_string_lossy()), + project_workspace: normalize(&project.root_path.to_string_lossy()), + execution_target: None, + workspace_id: config.workspace_id, + remote_connection_id: config.remote_connection_id, + remote_ssh_host: config.remote_ssh_host, + }) + } + fn workspace_target_from_context( workspace: &crate::agentic::WorkspaceBinding, ) -> SessionControlWorkspaceTarget { SessionControlWorkspaceTarget { - display_workspace: normalize_path(&workspace.root_path_string()), - project_workspace: normalize_path(&workspace.project_root_path_string()), + display_workspace: if workspace.is_remote() { + normalize_remote_workspace_path(&workspace.root_path_string()) + } else { + normalize_path(&workspace.root_path_string()) + }, + project_workspace: if workspace.is_remote() { + normalize_remote_workspace_path(&workspace.project_root_path_string()) + } else { + normalize_path(&workspace.project_root_path_string()) + }, execution_target: workspace.execution_target.clone(), workspace_id: workspace.workspace_id.clone(), remote_connection_id: workspace.connection_id().map(ToOwned::to_owned), @@ -186,6 +310,15 @@ impl SessionControlTool { } } + fn list_request(workspace: &SessionControlWorkspaceTarget) -> AgentSessionListRequest { + AgentSessionListRequest { + workspace_id: workspace.workspace_id.clone(), + workspace_path: String::new(), + remote_connection_id: None, + remote_ssh_host: None, + } + } + fn validation_context(context: Option<&ToolUseContext>) -> SessionControlValidationContext<'_> { SessionControlValidationContext { current_session_id: context.and_then(|value| value.session_id.as_deref()), @@ -209,12 +342,7 @@ impl SessionControlTool { session_id: &str, ) -> OpenBitFunResult<()> { let existing_sessions = runtime - .list_sessions(AgentSessionListRequest { - workspace_id: workspace.workspace_id.clone(), - workspace_path: String::new(), - remote_connection_id: None, - remote_ssh_host: None, - }) + .list_sessions(Self::list_request(workspace)) .await .map_err(|error| { OpenBitFunError::tool(CoreServiceAgentRuntime::runtime_error_message(error)) @@ -287,14 +415,14 @@ impl Tool for SessionControlTool { r#"Manage persisted workspace-scoped agent sessions. Actions: +- "list": List sessions. - "create": Create a new session. You may optionally provide session_name and agent_type. - "cancel": Cancel the target session's currently running dialog turn. This does not delete the session or clear any queued messages that may still run later. - "delete": Delete an existing session by session_id. - "rename": Rename an existing session by session_id using session_name as the new title. -- "list": List all sessions. Arguments: -- "workspace": Absolute workspace path. Required for create and list. Ignored for cancel, delete, and rename. +- "workspace": Registered workspace ID or absolute path, required for create and list. Use ListWorkspaces to discover IDs. IDs select any workspace registered on this runtime host. Paths select only the current machine or SSH connection. Ignored for cancel, delete, and rename. - "session_name": Used by create (defaults to "New Session") and required as the new title for rename. - "agent_type": Only used by create. Defaults to "Standard". - "Standard": Coding-focused agent for implementation, debugging, and code changes. @@ -324,7 +452,7 @@ Arguments: }, "workspace": { "type": "string", - "description": "Required absolute workspace path for create and list. Ignored for cancel, delete, and rename." + "description": "Registered workspace ID or absolute path for create and list. Discover IDs with ListWorkspaces. IDs can select local or remote workspaces; paths resolve only in the caller's environment. Ignored for cancel, delete, and rename." }, "session_id": { "type": "string", @@ -337,7 +465,7 @@ Arguments: "agent_type": { "type": "string", "enum": ["Standard", "Cowork", "DeepResearch"], - "description": "Optional agent type when creating a session. Defaults to agentic." + "description": "Optional agent type when creating a session. Defaults to Standard." } }, "required": ["action"], @@ -394,6 +522,7 @@ Arguments: .resolve_effective_workspace( SessionControlAction::Create, None, + params.workspace.as_deref(), context, &runtime, ) @@ -455,13 +584,12 @@ Arguments: .resolve_effective_workspace( SessionControlAction::Cancel, Some(session_id), + params.workspace.as_deref(), context, &runtime, ) .await?; - if self.current_workspace_session(context, &workspace.display_workspace) - == Some(session_id) - { + if self.current_workspace_session(context, &workspace) == Some(session_id) { return Err(OpenBitFunError::tool( "cannot cancel the current session from SessionControl".to_string(), )); @@ -541,13 +669,12 @@ Arguments: .resolve_effective_workspace( SessionControlAction::Delete, Some(session_id), + params.workspace.as_deref(), context, &runtime, ) .await?; - if self.current_workspace_session(context, &workspace.display_workspace) - == Some(session_id) - { + if self.current_workspace_session(context, &workspace) == Some(session_id) { return Err(OpenBitFunError::tool( "cannot delete the current session from SessionControl".to_string(), )); @@ -613,13 +740,12 @@ Arguments: .resolve_effective_workspace( SessionControlAction::Rename, Some(session_id), + params.workspace.as_deref(), context, &runtime, ) .await?; - if self.current_workspace_session(context, &workspace.display_workspace) - == Some(session_id) - { + if self.current_workspace_session(context, &workspace) == Some(session_id) { return Err(OpenBitFunError::tool( "cannot rename the current session from SessionControl".to_string(), )); @@ -660,23 +786,18 @@ Arguments: .resolve_effective_workspace( SessionControlAction::List, None, + params.workspace.as_deref(), context, &runtime, ) .await?; let sessions = runtime - .list_sessions(AgentSessionListRequest { - workspace_id: workspace.workspace_id.clone(), - workspace_path: String::new(), - remote_connection_id: None, - remote_ssh_host: None, - }) + .list_sessions(Self::list_request(&workspace)) .await .map_err(|error| { OpenBitFunError::tool(CoreServiceAgentRuntime::runtime_error_message(error)) })?; - let current_session_id = - self.current_workspace_session(context, &workspace.display_workspace); + let current_session_id = self.current_workspace_session(context, &workspace); let result_for_assistant = self.build_list_result_for_assistant( &workspace.display_workspace, &sessions, @@ -705,6 +826,8 @@ mod tests { use super::*; use crate::agentic::tools::framework::ToolUseContext; use crate::agentic::WorkspaceBinding; + use crate::service::workspace::{WorkspaceActivityMode, WorkspaceCreateOptions, WorkspaceKind}; + use openbitfun_agent_runtime::sdk::AgentRuntimeBuilder; use openbitfun_core_types::{ SessionExecutionTarget, SessionExecutionTargetKind, WorktreeLifecycle, }; @@ -712,6 +835,7 @@ mod tests { use std::collections::HashMap; use std::fs; use std::path::PathBuf; + use std::sync::{Arc, Mutex}; use uuid::Uuid; fn empty_context() -> ToolUseContext { @@ -752,6 +876,451 @@ mod tests { } } + fn context_for_workspace(workspace_id: String, path: PathBuf) -> ToolUseContext { + let mut context = empty_context(); + context.session_id = Some("caller-session".into()); + context.workspace = Some(WorkspaceBinding::new(Some(workspace_id), path)); + context + } + + fn remote_context(workspace_id: &str, path: &str, connection: &str) -> ToolUseContext { + let mut context = empty_context(); + context.session_id = Some("caller-session".into()); + context.workspace = Some(WorkspaceBinding::new_remote( + Some(workspace_id.into()), + PathBuf::from(path), + connection.into(), + "remote.example".into(), + openbitfun_services_core::workspace_identity::WorkspaceSessionIdentity { + workspace_kind: WorkspaceKind::Remote, + hostname: "remote.example".into(), + logical_workspace_path: path.into(), + remote_connection_id: Some(connection.into()), + }, + )); + context + } + + struct SessionsPort { + binding: Option, + requests: Mutex>, + } + + #[async_trait] + impl openbitfun_runtime_ports::AgentSubmissionPort for SessionsPort { + async fn create_session( + &self, + _request: AgentSessionCreateRequest, + ) -> openbitfun_runtime_ports::PortResult + { + unreachable!("workspace resolution must not create sessions") + } + + async fn submit_message( + &self, + _request: openbitfun_runtime_ports::AgentSubmissionRequest, + ) -> openbitfun_runtime_ports::PortResult + { + unreachable!("workspace resolution must not submit messages") + } + + async fn resolve_session_agent_type( + &self, + _session_id: &str, + ) -> openbitfun_runtime_ports::PortResult> { + Ok(None) + } + } + + #[async_trait] + impl openbitfun_runtime_ports::AgentSessionManagementPort for SessionsPort { + async fn list_sessions( + &self, + request: AgentSessionListRequest, + ) -> openbitfun_runtime_ports::PortResult> { + self.requests.lock().unwrap().push(request); + Ok(Vec::new()) + } + + async fn delete_session( + &self, + _request: AgentSessionDeleteRequest, + ) -> openbitfun_runtime_ports::PortResult<()> { + unreachable!("workspace resolution must not delete sessions") + } + + async fn resolve_session_workspace_binding( + &self, + request: AgentSessionWorkspaceRequest, + ) -> openbitfun_runtime_ports::PortResult> { + assert_eq!(request.session_id, "target-session"); + Ok(self.binding.clone()) + } + } + + #[tokio::test] + async fn requested_workspace_queries_target_instead_of_caller() { + let root = TestTempDir::new("session-control-cross-workspace"); + let service = WorkspaceService::new_isolated_for_tests(root.path.join("user")).await; + let caller_path = root.path.join("caller"); + let target_path = root.path.join("target"); + fs::create_dir_all(&caller_path).unwrap(); + fs::create_dir_all(&target_path).unwrap(); + let caller = service.open_workspace(caller_path).await.unwrap(); + let target = service.open_workspace(target_path).await.unwrap(); + let mut context = context_for_workspace(caller.id, caller.root_path); + context.workspace.as_mut().unwrap().execution_target = + Some(SessionExecutionTarget::local("caller-execution-root")); + + let resolved = SessionControlTool::resolve_requested_workspace( + &target.root_path.to_string_lossy(), + &context, + &service, + ) + .await + .unwrap(); + assert_eq!(resolved.workspace_id.as_deref(), Some(target.id.as_str())); + assert_eq!(PathBuf::from(&resolved.display_workspace), target.root_path); + assert_eq!(resolved.project_workspace, resolved.display_workspace); + assert!(resolved.execution_target.is_none()); + assert!(resolved.remote_connection_id.is_none()); + assert_eq!( + SessionControlTool::new().current_workspace_session(&context, &resolved), + None + ); + + let port = Arc::new(SessionsPort { + binding: None, + requests: Mutex::new(Vec::new()), + }); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(port.clone()) + .with_session_management_port(port.clone()) + .build() + .unwrap(); + runtime + .list_sessions(SessionControlTool::list_request(&resolved)) + .await + .unwrap(); + assert_eq!( + port.requests.lock().unwrap()[0].workspace_id.as_deref(), + Some(target.id.as_str()) + ); + + // Explicit workspace selection also works without a caller workspace. + let resolved = SessionControlTool::resolve_requested_workspace( + &target.root_path.to_string_lossy(), + &empty_context(), + &service, + ) + .await + .unwrap(); + assert_eq!(resolved.workspace_id.as_deref(), Some(target.id.as_str())); + } + + #[tokio::test] + async fn requested_current_workspace_preserves_worktree_execution_target() { + let root = TestTempDir::new("session-control-current-worktree"); + let service = WorkspaceService::new_isolated_for_tests(root.path.join("user")).await; + fs::create_dir_all(root.path.join("project")).unwrap(); + let project = service + .open_workspace(root.path.join("project")) + .await + .unwrap(); + let mut context = context_for_workspace("worktree-id".into(), root.path.join("worktree")); + let target = SessionExecutionTarget { + kind: SessionExecutionTargetKind::ManagedWorktree, + worktree_id: Some("worktree-id".into()), + root_path: root.path.join("worktree").to_string_lossy().into_owned(), + base_ref: None, + base_commit: None, + branch: None, + lifecycle: Some(WorktreeLifecycle::Managed), + }; + context.workspace = context.workspace.take().map(|binding| { + binding + .with_project_root_path(root.path.join("project")) + .with_execution_target(Some(target.clone())) + }); + let resolved = SessionControlTool::resolve_requested_workspace( + &context.workspace.as_ref().unwrap().root_path_string(), + &context, + &service, + ) + .await + .unwrap(); + assert_eq!(resolved.execution_target, Some(target)); + assert_eq!( + PathBuf::from(&resolved.project_workspace), + root.path.join("project") + ); + assert_eq!(resolved.workspace_id.as_deref(), Some("worktree-id")); + assert_eq!( + SessionControlTool::new().current_workspace_session(&context, &resolved), + Some("caller-session") + ); + let resolved = SessionControlTool::resolve_requested_workspace( + &project.root_path.to_string_lossy(), + &context, + &service, + ) + .await + .unwrap(); + assert_eq!(resolved.workspace_id.as_deref(), Some(project.id.as_str())); + assert_eq!( + PathBuf::from(&resolved.display_workspace), + project.root_path + ); + assert!(resolved.execution_target.is_none()); + } + + #[tokio::test] + async fn requested_remote_workspace_stays_on_callers_connection() { + let root = TestTempDir::new("session-control-remote-workspace"); + let service = WorkspaceService::new_isolated_for_tests(root.path.join("user")).await; + let path = "/remote/target"; + let mut expected_id = String::new(); + for connection in ["caller-connection", "other-connection"] { + let record = service + .track_workspace_activity( + PathBuf::from(path), + WorkspaceCreateOptions { + workspace_kind: WorkspaceKind::Remote, + remote_connection_id: Some(connection.into()), + remote_ssh_host: Some(format!("{connection}.example")), + ..Default::default() + }, + WorkspaceActivityMode::TouchOnly, + ) + .await + .unwrap(); + if connection == "caller-connection" { + expected_id = record.id; + } + } + let context = remote_context("caller-workspace", "/remote/caller", "caller-connection"); + let resolved = + SessionControlTool::resolve_requested_workspace("/remote//target/", &context, &service) + .await + .unwrap(); + assert_eq!(resolved.workspace_id.as_deref(), Some(expected_id.as_str())); + assert_eq!(resolved.display_workspace, path); + assert_eq!(resolved.project_workspace, path); + assert_eq!( + resolved.remote_connection_id.as_deref(), + Some("caller-connection") + ); + assert_eq!( + resolved.remote_ssh_host.as_deref(), + Some("caller-connection.example") + ); + assert_eq!( + SessionControlTool::list_request(&resolved) + .workspace_id + .as_deref(), + Some(expected_id.as_str()) + ); + + let current = + SessionControlTool::resolve_requested_workspace("/remote//caller/", &context, &service) + .await + .unwrap(); + assert_eq!(current.display_workspace, "/remote/caller"); + assert_eq!( + current.remote_connection_id.as_deref(), + Some("caller-connection") + ); + } + + #[tokio::test] + async fn missing_or_foreign_path_does_not_fall_back_to_caller() { + let root = TestTempDir::new("session-control-missing-workspace"); + let service = WorkspaceService::new_isolated_for_tests(root.path.join("user")).await; + let context = context_for_workspace("caller-id".into(), root.path.join("caller")); + assert!(matches!( + SessionControlTool::resolve_requested_workspace( + &root.path.join("missing").to_string_lossy(), + &context, + &service, + ) + .await, + Err(OpenBitFunError::NotFound(_)) + )); + + let path = root.path.join("shared-path"); + fs::create_dir_all(&path).unwrap(); + service.open_workspace(path.clone()).await.unwrap(); + let mut remote_ids = Vec::new(); + for connection in ["other-connection", "another-connection"] { + let record = service + .track_workspace_activity( + PathBuf::from("/remote/ambiguous"), + WorkspaceCreateOptions { + workspace_kind: WorkspaceKind::Remote, + remote_connection_id: Some(connection.into()), + remote_ssh_host: Some(format!("{connection}.example")), + ..Default::default() + }, + WorkspaceActivityMode::TouchOnly, + ) + .await + .unwrap(); + remote_ids.push((record.id, connection)); + } + assert!(matches!( + SessionControlTool::resolve_requested_workspace( + "/remote/ambiguous", + &context, + &service + ) + .await, + Err(OpenBitFunError::NotFound(_)) + )); + + // Discovery exposes both same-path remote records to a local caller. + let catalog = + crate::service_agent_runtime::CoreWorkspaceCatalogPort::list_from_service(&service) + .await; + for (id, connection) in remote_ids { + assert!(catalog.iter().any(|record| record.workspace_id == id)); + for caller in [ + &context, + &remote_context("remote-caller", "/caller", "caller-connection"), + ] { + let resolved = + SessionControlTool::resolve_requested_workspace(&id, caller, &service) + .await + .unwrap(); + assert_eq!(resolved.workspace_id.as_deref(), Some(id.as_str())); + assert_eq!(resolved.remote_connection_id.as_deref(), Some(connection)); + assert_eq!(resolved.display_workspace, "/remote/ambiguous"); + } + } + for invalid in ["unknown-id", "relative/path", ""] { + assert!(matches!( + SessionControlTool::resolve_requested_workspace(invalid, &context, &service).await, + Err(OpenBitFunError::NotFound(_)) + )); + } + + // Imported legacy records can share a path even when IDs differ. + let mut duplicate = service + .list_workspace_infos() + .await + .into_iter() + .find(|row| row.root_path == dunce::canonicalize(&path).unwrap()) + .unwrap(); + duplicate.id = "legacy-duplicate".into(); + service + .get_manager() + .write() + .await + .get_workspaces_mut() + .insert(duplicate.id.clone(), duplicate); + let error = SessionControlTool::resolve_requested_workspace( + &path.to_string_lossy(), + &context, + &service, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("ambiguous")); + + let remote = remote_context("remote-caller", "/caller", "caller-connection"); + assert!(matches!( + SessionControlTool::resolve_requested_workspace( + &path.to_string_lossy(), + &remote, + &service + ) + .await, + Err(OpenBitFunError::NotFound(_)) + )); + } + + #[tokio::test] + async fn session_targeted_actions_ignore_workspace_and_use_target_session_binding() { + let port = Arc::new(SessionsPort { + binding: Some(AgentSessionWorkspaceBinding { + workspace_kind: Some(WorkspaceKind::Remote), + project_workspace_id: Some("target-workspace".into()), + workspace_id: Some("target-workspace".into()), + workspace_path: "/remote/target".into(), + project_workspace_path: None, + execution_target: None, + remote_connection_id: Some("target-connection".into()), + remote_ssh_host: Some("target.example".into()), + }), + requests: Mutex::new(Vec::new()), + }); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(port.clone()) + .with_session_management_port(port.clone()) + .build() + .unwrap(); + let context = remote_context("caller-workspace", "/remote/caller", "caller-connection"); + let tool = SessionControlTool::new(); + for action in [ + SessionControlAction::Cancel, + SessionControlAction::Delete, + SessionControlAction::Rename, + ] { + let resolved = tool + .resolve_effective_workspace( + action, + Some("target-session"), + Some("ignored-path"), + &context, + &runtime, + ) + .await + .unwrap(); + assert_eq!(resolved.workspace_id.as_deref(), Some("target-workspace")); + assert_eq!( + resolved.remote_connection_id.as_deref(), + Some("target-connection") + ); + assert_eq!(tool.current_workspace_session(&context, &resolved), None); + let _ = tool + .ensure_session_exists(&runtime, &resolved, "target-session") + .await; + assert_eq!( + port.requests + .lock() + .unwrap() + .last() + .unwrap() + .workspace_id + .as_deref(), + Some("target-workspace") + ); + } + } + + #[tokio::test] + async fn create_and_list_accept_ids_before_host_resolution() { + let tool = SessionControlTool::new(); + let context = remote_context("caller-workspace", "/remote/caller", "caller-connection"); + for action in ["create", "list"] { + assert!( + tool.validate_input( + &json!({ "action": action, "workspace": "/remote/target" }), + Some(&context) + ) + .await + .result + ); + assert!( + tool.validate_input( + &json!({ "action": action, "workspace": "relative/path" }), + Some(&context) + ) + .await + .result + ); + } + } + #[test] fn worktree_context_keeps_project_scope_for_session_operations() { let worktree_path = PathBuf::from("/worktrees/wt-1"); diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime.rs index f3e1754f24..cfbf4fedaa 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime.rs @@ -294,7 +294,15 @@ mod baseline_tests { .expect("agent-runtime guarantees its Basic and AgentControl owners"); let names = registry.get_tool_names(); - for required in ["LS", "Read", "Task", "AgentSpawn", "SessionControl", "Cron"] { + for required in [ + "LS", + "Read", + "Task", + "AgentSpawn", + "ListWorkspaces", + "SessionControl", + "Cron", + ] { assert!( names.iter().any(|name| name == required), "missing {required}" diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs index c3f4284c3b..d711948ffd 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs @@ -53,6 +53,7 @@ const PRODUCT_TOOL_REGISTRATION_ORDER: &[&str] = &[ "ReadCanvas", "UpdateCanvas", "PatchCanvas", + "ListWorkspaces", "SessionControl", "SessionMessage", "SessionHistory", @@ -146,6 +147,7 @@ impl StaticToolProviderFactory for ProductConcreteToolFactory { "CallDeferredTool" => Some(Arc::new(CallDeferredTool::new())), #[cfg(feature = "tools-git")] "GetFileDiff" => Some(Arc::new(GetFileDiffTool::new())), + "ListWorkspaces" => Some(Arc::new(ListWorkspacesTool::new())), "SessionControl" => Some(Arc::new(SessionControlTool::new())), "SessionMessage" => Some(Arc::new(SessionMessageTool::new())), "SessionHistory" => Some(Arc::new(SessionHistoryTool::new())), @@ -278,6 +280,15 @@ mod tests { } } + #[test] + fn list_workspaces_materializes_as_readonly_deferred_discovery() { + let tool = ProductConcreteToolFactory + .materialize_tool("ListWorkspaces") + .expect("workspace catalog belongs to the session tool provider"); + assert!(tool.is_readonly()); + assert!(matches!(tool.default_exposure(), ToolExposure::Deferred)); + } + #[cfg(all(feature = "tools-agent-control", feature = "remote-workspace"))] #[test] fn port_forward_materializes_as_a_deferred_tool() { diff --git a/src/crates/assembly/core/src/agentic/tools/registry.rs b/src/crates/assembly/core/src/agentic/tools/registry.rs index 3310a5715e..f0c5a7e813 100644 --- a/src/crates/assembly/core/src/agentic/tools/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/registry.rs @@ -591,6 +591,7 @@ mod tests { "ReadCanvas", "UpdateCanvas", "PatchCanvas", + "ListWorkspaces", "SessionControl", "SessionMessage", "SessionHistory", @@ -795,6 +796,7 @@ mod tests { vec![ "ListModels", "GetFileDiff", + "ListWorkspaces", "SessionControl", "SessionMessage", "SessionHistory", @@ -844,6 +846,7 @@ mod tests { "GetToolSpec", "GetFileDiff", "ReadCanvas", + "ListWorkspaces", "SessionHistory", "WebSearch", "WebFetch", diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index 8b5b44ea68..8baca8ac1e 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -91,6 +91,88 @@ use crate::service::config::types::{AIConfig, GlobalConfig, ModelCapability}; #[cfg(feature = "remote-connect")] use crate::service::session::{DialogTurnData, ToolItemIdentityExt, TurnStatus}; +/// Host-local catalog projection. Discovery never activates a workspace or SSH. +#[derive(Default)] +pub(crate) struct CoreWorkspaceCatalogPort; + +impl CoreWorkspaceCatalogPort { + pub(crate) async fn list_from_service( + service: &crate::service::workspace::WorkspaceService, + ) -> Vec { + use crate::service::workspace::WorkspaceKind; + use openbitfun_runtime_ports::{AgentWorkspaceCatalogEntry, AgentWorkspaceRemoteInfo}; + service + .list_workspace_infos() + .await + .into_iter() + .map(|record| { + let project = record.project_workspace_id().map(str::to_owned); + let remote = (record.workspace_kind == WorkspaceKind::Remote).then(|| { + AgentWorkspaceRemoteInfo { + connection_id: record.remote_ssh_connection_id().map(str::to_owned), + host: record + .metadata + .get("sshHost") + .and_then(serde_json::Value::as_str) + .map(str::to_owned), + } + }); + let binding_error = project.as_ref().err().cloned().or_else(|| { + remote.as_ref().and_then(|route| { + (route + .connection_id + .as_deref() + .is_none_or(|id| id.trim().is_empty()) + || route + .host + .as_deref() + .is_none_or(|host| host.trim().is_empty())) + .then(|| { + "Remote workspace is missing its saved SSH connection ID or host" + .to_string() + }) + }) + }); + let root_path = if record.workspace_kind == WorkspaceKind::Remote { + openbitfun_services_core::workspace_identity::normalize_remote_workspace_path( + &record.root_path.to_string_lossy(), + ) + } else { + record.root_path.to_string_lossy().into_owned() + }; + AgentWorkspaceCatalogEntry { + workspace_id: record.id, + project_workspace_id: project.ok(), + name: record.name, + kind: record.workspace_kind, + root_path, + last_accessed_at_ms: record.last_accessed.timestamp_millis(), + remote, + binding_error, + } + }) + .collect() + } +} + +#[async_trait::async_trait] +impl openbitfun_runtime_ports::AgentWorkspaceCatalogPort for CoreWorkspaceCatalogPort { + async fn list_workspaces( + &self, + ) -> openbitfun_runtime_ports::PortResult< + Vec, + > { + let service = + crate::service::workspace::get_global_workspace_service().ok_or_else(|| { + openbitfun_runtime_ports::PortError::new( + openbitfun_runtime_ports::PortErrorKind::NotAvailable, + "Workspace service is unavailable", + ) + })?; + Ok(Self::list_from_service(&service).await) + } +} + #[cfg(feature = "opencode-plugin-host")] #[derive(Clone)] struct ConfiguredPluginSubmissionPort { diff --git a/src/crates/assembly/product-capabilities/tests/product_capability_contracts/runtime_boundary.rs b/src/crates/assembly/product-capabilities/tests/product_capability_contracts/runtime_boundary.rs index 9fe64ad996..3b9b5cb0b1 100644 --- a/src/crates/assembly/product-capabilities/tests/product_capability_contracts/runtime_boundary.rs +++ b/src/crates/assembly/product-capabilities/tests/product_capability_contracts/runtime_boundary.rs @@ -53,6 +53,7 @@ fn code_agent_tools_are_selected_from_atomic_provider_groups() { "AgentControl", "AgentList", "PortForward", + "ListWorkspaces", "OpenBitFunControl", ] { assert!( 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 b2fc44fee8..fe27299e6b 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,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "63afe6c13213f11a7089fbb6208db930d563e4d3098f7c6dcf4c20a1f4e87271", + "digest": "434dc78df2ba3fa225ab75ab2839d8dcacebbf2e65123a49da3ceb4081c2988f", "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", "searchAcceptance": [ { @@ -563,6 +563,7 @@ "additionalProperties": true }, "delegateTools": [ + "ListWorkspaces", "SessionControl", "SessionHistory", "Read", @@ -611,6 +612,7 @@ "additionalProperties": true }, "delegateTools": [ + "ListWorkspaces", "SessionControl", "SessionMessage" ], @@ -19317,6 +19319,7 @@ "control": { "kind": "delegate", "tools": [ + "ListWorkspaces", "SessionControl", "SessionHistory", "Read", @@ -19339,15 +19342,16 @@ "control": { "kind": "delegate", "tools": [ + "ListWorkspaces", "SessionControl", "SessionMessage" ], "workflowZh": [ - "用 SessionControl 在明确工作区内创建或列出会话;取消只终止当前运行轮次,删除是独立动作", + "用 ListWorkspaces 获取运行主机登记的 workspace_id,再传给 SessionControl.workspace 创建或列出本地或远程会话;绝对路径仅在调用者执行环境中解析;取消只终止当前轮次,删除是独立动作", "需要让另一持久会话接手工作时用 SessionMessage,并保留异步返回;跨远程边界时沿用目标会话绑定,不得复用控制器路径" ], "workflowEn": [ - "Use SessionControl to create or list sessions in an explicit workspace; cancel affects only the active turn and delete is separate", + "Use ListWorkspaces to discover runtime-host workspace IDs, then pass one as SessionControl.workspace to create or list local or remote sessions; absolute paths resolve in the caller environment; cancel affects only the active turn and delete is separate", "Use SessionMessage when another persisted session should take over work and retain its asynchronous result; preserve the target session binding across remote boundaries" ] } @@ -19579,6 +19583,7 @@ "Call FrontendWorkbench prepare on the local Desktop in Creative mode, read the packaged API, and edit only draft CSS, JavaScript and owned assets without source or build tools", "Apply with the original draft ID; the host starts the immutable review window's 15-second countdown only after the real shell and customization activate, and apply waits for a final confirmed or rolled_back outcome", "Compose commands, state and events into reusable capabilities; use FrontendWorkbench inspect to discover schemas and diagnostics, then invoke to verify execution", + "ListWorkspaces", "SessionControl", "SessionHistory", "Grep", @@ -19587,9 +19592,9 @@ "Use SessionControl list to find the target session, then export its indexed transcript with SessionHistory", "Read only the exported index first, then use Read or Grep for the required turns instead of loading the entire history into context", "SessionMessage", - "用 SessionControl 在明确工作区内创建或列出会话;取消只终止当前运行轮次,删除是独立动作", + "用 ListWorkspaces 获取运行主机登记的 workspace_id,再传给 SessionControl.workspace 创建或列出本地或远程会话;绝对路径仅在调用者执行环境中解析;取消只终止当前轮次,删除是独立动作", "需要让另一持久会话接手工作时用 SessionMessage,并保留异步返回;跨远程边界时沿用目标会话绑定,不得复用控制器路径", - "Use SessionControl to create or list sessions in an explicit workspace; cancel affects only the active turn and delete is separate", + "Use ListWorkspaces to discover runtime-host workspace IDs, then pass one as SessionControl.workspace to create or list local or remote sessions; absolute paths resolve in the caller environment; cancel affects only the active turn and delete is separate", "Use SessionMessage when another persisted session should take over work and retain its asynchronous result; preserve the target session binding across remote boundaries", "get_goal", "create_goal", diff --git a/src/crates/contracts/runtime-ports/src/agent_api.rs b/src/crates/contracts/runtime-ports/src/agent_api.rs index 252c0b0767..3770eafe87 100644 --- a/src/crates/contracts/runtime-ports/src/agent_api.rs +++ b/src/crates/contracts/runtime-ports/src/agent_api.rs @@ -84,6 +84,33 @@ pub struct AgentSessionListRequest { pub remote_ssh_host: Option, } +/// Read-only projection of one workspace registered on the runtime's owning host. +/// Paths are display/IO facts; IDs are opaque selectors, scoped to that host. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentWorkspaceCatalogEntry { + pub workspace_id: String, + pub project_workspace_id: Option, + pub name: String, + pub kind: openbitfun_core_types::WorkspaceKind, + pub root_path: String, + /// Last recorded workspace access, as Unix milliseconds. + pub last_accessed_at_ms: i64, + pub remote: Option, + pub binding_error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentWorkspaceRemoteInfo { + pub connection_id: Option, + pub host: Option, +} + +/// Catalog discovery must not activate workspaces, inspect files, or connect SSH. +#[async_trait::async_trait] +pub trait AgentWorkspaceCatalogPort: Send + Sync { + async fn list_workspaces(&self) -> PortResult>; +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] diff --git a/src/crates/execution/agent-runtime/src/session_control.rs b/src/crates/execution/agent-runtime/src/session_control.rs index 01d5619f79..d580f21ed2 100644 --- a/src/crates/execution/agent-runtime/src/session_control.rs +++ b/src/crates/execution/agent-runtime/src/session_control.rs @@ -2,7 +2,6 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::path::Path; #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] @@ -143,10 +142,8 @@ fn validate_workspace_shape(workspace: &str) -> SessionControlValidationResult { return invalid("workspace is required and cannot be empty"); } - if !Path::new(workspace.trim()).is_absolute() { - return invalid("workspace must be an absolute path"); - } - + // IDs are opaque. The host resolves a registered ID first, then validates + // an unmatched operand as an absolute path in the caller's environment. SessionControlValidationResult::default() } diff --git a/src/crates/execution/agent-runtime/tests/agent_session_contracts/session_control_contracts.rs b/src/crates/execution/agent-runtime/tests/agent_session_contracts/session_control_contracts.rs index 5d9eb2f7df..eb3d9dced8 100644 --- a/src/crates/execution/agent-runtime/tests/agent_session_contracts/session_control_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/agent_session_contracts/session_control_contracts.rs @@ -27,6 +27,25 @@ fn validates_cancel_without_workspace_and_ignores_workspace_shape() { assert!(result.result, "{:?}", result.message); } +#[test] +fn create_and_list_accept_opaque_workspace_ids_before_host_resolution() { + let context = SessionControlValidationContext { + current_session_id: Some("caller"), + has_workspace_root: true, + }; + for action in [SessionControlAction::Create, SessionControlAction::List] { + let mut input = base_input(action); + input.workspace = Some("/remote/project".into()); + assert!(validate_session_control_input(&input, context).result); + for path in ["workspace-id", "C:/local/project", "relative/path"] { + input.workspace = Some(path.into()); + assert!(validate_session_control_input(&input, context).result); + } + input.workspace = Some(" ".into()); + assert!(!validate_session_control_input(&input, context).result); + } +} + #[test] fn rejects_current_session_mutation_when_context_matches() { let mut input = base_input(SessionControlAction::Delete); diff --git a/src/crates/execution/tool-contracts/src/framework.rs b/src/crates/execution/tool-contracts/src/framework.rs index ef01ba6da9..314c5333ea 100644 --- a/src/crates/execution/tool-contracts/src/framework.rs +++ b/src/crates/execution/tool-contracts/src/framework.rs @@ -2341,6 +2341,10 @@ pub fn miniapp_headless_agent_tool_restrictions() -> ToolRuntimeRestrictions { "Cron", "Cron is unavailable in MiniApp headless agent runs.", ), + ( + "ListWorkspaces", + "ListWorkspaces is unavailable in MiniApp headless agent runs.", + ), ( "SessionControl", "SessionControl is unavailable in MiniApp headless agent runs.", diff --git a/src/crates/execution/tool-provider-groups/src/lib.rs b/src/crates/execution/tool-provider-groups/src/lib.rs index 11e00f8c7b..36f0ee6b26 100644 --- a/src/crates/execution/tool-provider-groups/src/lib.rs +++ b/src/crates/execution/tool-provider-groups/src/lib.rs @@ -112,8 +112,10 @@ pub fn tool_feature_group(tool_name: &str) -> Option { "Task" | "AgentSpawn" | "AgentSendInput" | "AgentControl" | "AgentList" | "AgentWait" | "LaunchReviewAgent" | "Skill" | "AskUserQuestion" | "TodoWrite" | "get_goal" | "create_goal" | "update_goal" | "submit_code_review" | "GetToolSpec" - | "CallDeferredTool" | "SessionControl" | "SessionMessage" | "SessionHistory" | "Cron" - | "PortForward" | "OpenBitFunControl" => Some(ToolPackFeatureGroup::AgentControl), + | "CallDeferredTool" | "ListWorkspaces" | "SessionControl" | "SessionMessage" + | "SessionHistory" | "Cron" | "PortForward" | "OpenBitFunControl" => { + Some(ToolPackFeatureGroup::AgentControl) + } _ => None, } } @@ -212,6 +214,7 @@ const PRODUCT_TOOL_PROVIDER_GROUP_PLAN: &[ToolProviderGroupPlan] = &[ provider_id: "core.session", feature_groups: CORE_SESSION_FEATURE_GROUPS, tool_names: &[ + "ListWorkspaces", "SessionControl", "SessionMessage", "SessionHistory", @@ -543,6 +546,7 @@ mod tests { "GetToolSpec", "CallDeferredTool", "OpenBitFunControl", + "ListWorkspaces", "SessionControl", "SessionMessage", "SessionHistory", diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index 301e9f0b16..672fc4ad5d 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -441,6 +441,7 @@ "control": { "kind": "delegate", "tools": [ + "ListWorkspaces", "SessionControl", "SessionHistory", "Read", @@ -473,15 +474,16 @@ "control": { "kind": "delegate", "tools": [ + "ListWorkspaces", "SessionControl", "SessionMessage" ], "workflowZh": [ - "用 SessionControl 在明确工作区内创建或列出会话;取消只终止当前运行轮次,删除是独立动作", + "用 ListWorkspaces 获取运行主机登记的 workspace_id,再传给 SessionControl.workspace 创建或列出本地或远程会话;绝对路径仅在调用者执行环境中解析;取消只终止当前轮次,删除是独立动作", "需要让另一持久会话接手工作时用 SessionMessage,并保留异步返回;跨远程边界时沿用目标会话绑定,不得复用控制器路径" ], "workflowEn": [ - "Use SessionControl to create or list sessions in an explicit workspace; cancel affects only the active turn and delete is separate", + "Use ListWorkspaces to discover runtime-host workspace IDs, then pass one as SessionControl.workspace to create or list local or remote sessions; absolute paths resolve in the caller environment; cancel affects only the active turn and delete is separate", "Use SessionMessage when another persisted session should take over work and retain its asynchronous result; preserve the target session binding across remote boundaries" ] }, 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 cf6d754182..0633bfe9e8 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,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "63afe6c13213f11a7089fbb6208db930d563e4d3098f7c6dcf4c20a1f4e87271", + "digest": "434dc78df2ba3fa225ab75ab2839d8dcacebbf2e65123a49da3ceb4081c2988f", "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", "searchAcceptance": [ { @@ -563,6 +563,7 @@ "additionalProperties": true }, "delegateTools": [ + "ListWorkspaces", "SessionControl", "SessionHistory", "Read", @@ -611,6 +612,7 @@ "additionalProperties": true }, "delegateTools": [ + "ListWorkspaces", "SessionControl", "SessionMessage" ], @@ -19317,6 +19319,7 @@ "control": { "kind": "delegate", "tools": [ + "ListWorkspaces", "SessionControl", "SessionHistory", "Read", @@ -19339,15 +19342,16 @@ "control": { "kind": "delegate", "tools": [ + "ListWorkspaces", "SessionControl", "SessionMessage" ], "workflowZh": [ - "用 SessionControl 在明确工作区内创建或列出会话;取消只终止当前运行轮次,删除是独立动作", + "用 ListWorkspaces 获取运行主机登记的 workspace_id,再传给 SessionControl.workspace 创建或列出本地或远程会话;绝对路径仅在调用者执行环境中解析;取消只终止当前轮次,删除是独立动作", "需要让另一持久会话接手工作时用 SessionMessage,并保留异步返回;跨远程边界时沿用目标会话绑定,不得复用控制器路径" ], "workflowEn": [ - "Use SessionControl to create or list sessions in an explicit workspace; cancel affects only the active turn and delete is separate", + "Use ListWorkspaces to discover runtime-host workspace IDs, then pass one as SessionControl.workspace to create or list local or remote sessions; absolute paths resolve in the caller environment; cancel affects only the active turn and delete is separate", "Use SessionMessage when another persisted session should take over work and retain its asynchronous result; preserve the target session binding across remote boundaries" ] } @@ -19575,6 +19579,7 @@ "Call FrontendWorkbench prepare on the local Desktop in Creative mode, read the packaged API, and edit only draft CSS, JavaScript and owned assets without source or build tools", "Apply with the original draft ID; the host starts the immutable review window's 15-second countdown only after the real shell and customization activate, and apply waits for a final confirmed or rolled_back outcome", "Compose commands, state and events into reusable capabilities; use FrontendWorkbench inspect to discover schemas and diagnostics, then invoke to verify execution", + "ListWorkspaces", "SessionControl", "SessionHistory", "Grep", @@ -19583,9 +19588,9 @@ "Use SessionControl list to find the target session, then export its indexed transcript with SessionHistory", "Read only the exported index first, then use Read or Grep for the required turns instead of loading the entire history into context", "SessionMessage", - "用 SessionControl 在明确工作区内创建或列出会话;取消只终止当前运行轮次,删除是独立动作", + "用 ListWorkspaces 获取运行主机登记的 workspace_id,再传给 SessionControl.workspace 创建或列出本地或远程会话;绝对路径仅在调用者执行环境中解析;取消只终止当前轮次,删除是独立动作", "需要让另一持久会话接手工作时用 SessionMessage,并保留异步返回;跨远程边界时沿用目标会话绑定,不得复用控制器路径", - "Use SessionControl to create or list sessions in an explicit workspace; cancel affects only the active turn and delete is separate", + "Use ListWorkspaces to discover runtime-host workspace IDs, then pass one as SessionControl.workspace to create or list local or remote sessions; absolute paths resolve in the caller environment; cancel affects only the active turn and delete is separate", "Use SessionMessage when another persisted session should take over work and retain its asynchronous result; preserve the target session binding across remote boundaries", "get_goal", "create_goal", diff --git a/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts b/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts index 8f1c256f9b..01d5633964 100644 --- a/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts +++ b/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts @@ -31,6 +31,7 @@ const AMBIENT_TOOL_CARD_NAMES = new Set([ 'ListModels', 'Skill', 'TerminalControl', + 'ListWorkspaces', 'SessionControl', 'SessionMessage', 'AgentSendInput', @@ -364,6 +365,17 @@ const TOOL_CARD_DEFINITIONS: Record = { primaryColor: 'var(--openbitfun-color-status-warning-content)' }, + 'ListWorkspaces': { + toolName: 'ListWorkspaces', + displayName: 'List Workspaces', + icon: 'LW', + requiresConfirmation: false, + resultDisplayType: 'summary', + description: 'Discover registered local and remote workspaces', + displayMode: 'compact', + primaryColor: APPEARANCE_DOMAIN_TOKENS.toolIdentity.assistantAction + }, + 'SessionControl': { toolName: 'SessionControl', displayName: 'Session Control', diff --git a/src/web-ui/src/infrastructure/api/generated/productControl.ts b/src/web-ui/src/infrastructure/api/generated/productControl.ts index 3dfa7111d0..2edd5d32e0 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 = "63afe6c13213f11a7089fbb6208db930d563e4d3098f7c6dcf4c20a1f4e87271" as const; +export const PRODUCT_CONTROL_GRAPH_DIGEST = "434dc78df2ba3fa225ab75ab2839d8dcacebbf2e65123a49da3ceb4081c2988f" 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" | "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";