diff --git a/crates/goose/src/agents/platform_extensions/summon.rs b/crates/goose/src/agents/platform_extensions/summon.rs index 2d330526..4d857717 100644 --- a/crates/goose/src/agents/platform_extensions/summon.rs +++ b/crates/goose/src/agents/platform_extensions/summon.rs @@ -22,7 +22,7 @@ use crate::session::SessionType; use anyhow::Result; use async_trait::async_trait; use rmcp::model::{ - CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListToolsResult, + CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListToolsResult, Meta, ServerCapabilities, ServerNotification, Tool, }; use serde::Deserialize; @@ -857,7 +857,7 @@ impl SummonClient { &self, session_id: &str, arguments: Option, - ) -> Result, String> { + ) -> Result { self.cleanup_completed_tasks().await; let source_name = arguments @@ -874,17 +874,27 @@ impl SummonClient { let working_dir = self.get_working_dir(session_id).await; if source_name.is_none() { - return self.handle_load_discovery(session_id, &working_dir).await; + return self + .handle_load_discovery(session_id, &working_dir) + .await + .map(CallToolResult::success); } let name = source_name.unwrap(); if is_session_id(name) { - return self.handle_load_task_result(name, cancel).await; + let content = self.handle_load_task_result(name, cancel).await?; + let mut meta = Meta::new(); + meta.0.insert( + "subagent_session_id".to_string(), + serde_json::Value::String(name.to_string()), + ); + return Ok(CallToolResult::success(content).with_meta(Some(meta))); } self.handle_load_source(session_id, name, &working_dir) .await + .map(CallToolResult::success) } async fn handle_load_task_result( @@ -1197,7 +1207,7 @@ impl SummonClient { session_id: &str, arguments: Option, cancellation_token: CancellationToken, - ) -> Result, String> { + ) -> Result { self.cleanup_completed_tasks().await; let params: DelegateParams = arguments @@ -1229,7 +1239,13 @@ impl SummonClient { } if params.r#async { - return self.handle_async_delegate(session_id, params).await; + let (content, task_id) = self.handle_async_delegate(session_id, params).await?; + let mut meta = Meta::new(); + meta.0.insert( + "subagent_session_id".to_string(), + serde_json::Value::String(task_id), + ); + return Ok(CallToolResult::success(content).with_meta(Some(meta))); } let working_dir = session.working_dir.clone(); @@ -1273,6 +1289,8 @@ impl SummonClient { Arc::new(Mutex::new(Vec::new())), ); + let subagent_session_id = subagent_session.id.clone(); + let result = run_subagent_task(SubagentRunParams { config: agent_config, recipe, @@ -1283,10 +1301,24 @@ impl SummonClient { on_message: None, notification_tx: Some(notif_tx), }) - .await - .map_err(|e| format!("Delegation failed: {}", e))?; + .await; - Ok(vec![Content::text(result)]) + let mut meta = Meta::new(); + meta.0.insert( + "subagent_session_id".to_string(), + serde_json::Value::String(subagent_session_id), + ); + + match result { + Ok(text) => { + Ok(CallToolResult::success(vec![Content::text(text)]).with_meta(Some(meta))) + } + Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( + "Delegation failed: {}", + e + ))]) + .with_meta(Some(meta))), + } } fn validate_delegate_params(&self, params: &DelegateParams) -> Result<(), String> { @@ -1678,7 +1710,7 @@ impl SummonClient { &self, session_id: &str, params: DelegateParams, - ) -> Result, String> { + ) -> Result<(Vec, String), String> { let task_count = self.background_tasks.lock().await.len(); let max_tasks = max_background_tasks(); if task_count >= max_tasks { @@ -1786,11 +1818,12 @@ impl SummonClient { .await .insert(task_id.clone(), task); - Ok(vec![Content::text(format!( + let content = vec![Content::text(format!( "Task {} started in background: \"{}\"\n\ Continue with other work. When you need the result, use load(source: \"{}\").", task_id, description, task_id - ))]) + ))]; + Ok((content, task_id)) } } @@ -1833,20 +1866,29 @@ impl McpClientTrait for SummonClient { cancellation_token: CancellationToken, ) -> Result { let session_id = &ctx.session_id; - let content = match name { - "load" => self.handle_load(session_id, arguments).await, + match name { + "load" => match self.handle_load(session_id, arguments).await { + Ok(result) => Ok(result), + Err(error) => Ok(CallToolResult::error(vec![Content::text(format!( + "Error: {}", + error + ))])), + }, "delegate" => { - self.handle_delegate(session_id, arguments, cancellation_token) + match self + .handle_delegate(session_id, arguments, cancellation_token) .await + { + Ok(result) => Ok(result), + Err(error) => Ok(CallToolResult::error(vec![Content::text(format!( + "Error: {}", + error + ))])), + } } - _ => Err(format!("Unknown tool: {}", name)), - }; - - match content { - Ok(content) => Ok(CallToolResult::success(content)), - Err(error) => Ok(CallToolResult::error(vec![Content::text(format!( - "Error: {}", - error + _ => Ok(CallToolResult::error(vec![Content::text(format!( + "Error: Unknown tool: {}", + name ))])), } } diff --git a/ui/desktop/src/components/ToolCallWithResponse.tsx b/ui/desktop/src/components/ToolCallWithResponse.tsx index 033c584d..529c10be 100644 --- a/ui/desktop/src/components/ToolCallWithResponse.tsx +++ b/ui/desktop/src/components/ToolCallWithResponse.tsx @@ -13,7 +13,7 @@ import { } from '../types/message'; import { cn, snakeToTitleCase } from '../utils'; import { LoadingStatus } from './ui/Dot'; -import { ChevronRight, FlaskConical } from 'lucide-react'; +import { ChevronRight, ExternalLink, FlaskConical } from 'lucide-react'; import { TooltipWrapper } from './settings/providers/subcomponents/buttons/TooltipWrapper'; import MCPUIResourceRenderer from './MCPUIResourceRenderer'; import { isUIResource } from '@mcp-ui/client'; @@ -33,6 +33,7 @@ type UiMeta = { ui?: { resourceUri?: string; }; + subagent_session_id?: string; }; type ToolResultWithMeta = { @@ -66,6 +67,33 @@ interface ToolCallWithResponseProps { isApprovalClicked?: boolean; } +function getSubagentSessionId( + toolResponse?: ToolResponseMessageContent, + notifications?: NotificationEvent[] +): string | null { + const result = toolResponse?.toolResult as ToolResultWithMeta | undefined; + const sessionId = + result?.status === 'success' ? result?.value?._meta?.subagent_session_id : undefined; + if (typeof sessionId === 'string') return sessionId; + + // Fallback: extract from subagent notifications (e.g. when delegate was cancelled mid-stream) + if (notifications) { + for (const n of notifications) { + const message = n.message as { method?: string; params?: Record }; + if (message.method !== 'notifications/message') continue; + const data = message.params?.data; + if (data && typeof data === 'object' && 'type' in data && 'subagent_id' in data) { + const record = data as Record; + if (record.type === 'subagent_tool_request' && typeof record.subagent_id === 'string') { + return record.subagent_id; + } + } + } + } + + return null; +} + function getToolResultContent(toolResult: Record): ContentBlock[] { if (toolResult.status !== 'success') { return []; @@ -635,6 +663,25 @@ function ToolCallView({ } break; + case 'delegate': { + if (args.instructions) { + const instr = getStringValue(args.instructions); + const truncated = instr.length > 80 ? instr.substring(0, 80) + '…' : instr; + return `delegating: ${truncated}`; + } + if (args.source) { + return `delegating to ${getStringValue(args.source)}`; + } + return 'delegating task'; + } + + case 'load': { + if (args.source) { + return `loading ${getStringValue(args.source)}`; + } + return 'loading source'; + } + case 'final_output': return 'final output'; @@ -790,6 +837,28 @@ function ToolCallView({ ))} )} + + {(() => { + if (loadingStatus === 'loading') return null; + const subagentSessionId = getSubagentSessionId(toolResponse, notifications); + if (!subagentSessionId) return null; + return ( +
+ +
+ ); + })()} ); } @@ -912,6 +981,34 @@ function ToolResultView({ result, isStartExpanded }: ToolResultViewProps) { ); } +function SubagentLogEntry({ log }: { log: string }) { + const subagentMatch = log.match(/^\[subagent:(\w+)\]\s*([\s\S]*)/); + if (!subagentMatch) { + return {log}; + } + + const [, , rest] = subagentMatch; + const [firstLine, ...detailLines] = rest.split('\n'); + const parts = firstLine.split(' | '); + const toolName = parts[0]?.trim() || firstLine; + const extensionName = parts[1]?.trim(); + + return ( +
+ + + {toolName} + {extensionName && · {extensionName}} + + {detailLines.length > 0 && ( +
+          {detailLines.join('\n')}
+        
+ )} +
+ ); +} + function ToolLogsView({ logs, working, @@ -936,11 +1033,14 @@ function ToolLogsView({ // in this case, this is array of strings which once added do not change so this cuts // down on the possibility of unwanted runs + const subagentLogCount = logs.filter((l) => l.startsWith('[subagent:')).length; + const labelText = subagentLogCount > 0 ? `Activity (${subagentLogCount})` : 'Logs'; + return ( - Logs + {labelText} {working && (
{logs.map((log, i) => ( - - {log} - + ))}
diff --git a/ui/desktop/src/components/icons/toolcalls/Delegate.tsx b/ui/desktop/src/components/icons/toolcalls/Delegate.tsx new file mode 100644 index 00000000..62072a38 --- /dev/null +++ b/ui/desktop/src/components/icons/toolcalls/Delegate.tsx @@ -0,0 +1,20 @@ +export const Delegate = ({ className }: { className?: string }) => ( + + + + + +); diff --git a/ui/desktop/src/components/icons/toolcalls/index.tsx b/ui/desktop/src/components/icons/toolcalls/index.tsx index c77ccff5..533f252d 100644 --- a/ui/desktop/src/components/icons/toolcalls/index.tsx +++ b/ui/desktop/src/components/icons/toolcalls/index.tsx @@ -2,6 +2,7 @@ export { Archive } from './Archive'; export { Brain } from './Brain'; export { Camera } from './Camera'; export { Code2 } from './Code2'; +export { Delegate } from './Delegate'; export { Eye } from './Eye'; export { FileEdit } from './FileEdit'; export { FilePlus } from './FilePlus'; diff --git a/ui/desktop/src/utils/toolIconMapping.tsx b/ui/desktop/src/utils/toolIconMapping.tsx index d3ba7b23..9e7b03d9 100644 --- a/ui/desktop/src/utils/toolIconMapping.tsx +++ b/ui/desktop/src/utils/toolIconMapping.tsx @@ -4,6 +4,7 @@ import { Brain, Camera, Code2, + Delegate, Eye, FileEdit, FilePlus, @@ -75,6 +76,12 @@ export const getToolIcon = (toolName: string): React.ComponentType