Display delegate sub agents logs in UI (#7519)
Co-authored-by: Jack Amadeo <jackamadeo@squareup.com>
This commit is contained in:
@@ -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<JsonObject>,
|
||||
) -> Result<Vec<Content>, String> {
|
||||
) -> Result<CallToolResult, String> {
|
||||
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<JsonObject>,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Result<Vec<Content>, String> {
|
||||
) -> Result<CallToolResult, String> {
|
||||
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<Vec<Content>, String> {
|
||||
) -> Result<(Vec<Content>, 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<CallToolResult, Error> {
|
||||
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
|
||||
))])),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> };
|
||||
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<string, unknown>;
|
||||
if (record.type === 'subagent_tool_request' && typeof record.subagent_id === 'string') {
|
||||
return record.subagent_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getToolResultContent(toolResult: Record<string, unknown>): 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 (
|
||||
<div className="border-t border-border-primary">
|
||||
<button
|
||||
onClick={() => {
|
||||
window.electron.createChatWindow({
|
||||
resumeSessionId: subagentSessionId,
|
||||
viewType: 'pair',
|
||||
});
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-4 py-2 text-xs text-text-secondary hover:text-text-primary hover:bg-background-secondary transition-colors cursor-pointer"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 flex-shrink-0" />
|
||||
<span>View subagent session</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</ToolCallExpandable>
|
||||
);
|
||||
}
|
||||
@@ -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 <span className="font-sans text-sm text-textSubtle">{log}</span>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="font-sans text-sm text-textSubtle">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 flex-shrink-0" />
|
||||
<span className="font-medium text-text-secondary">{toolName}</span>
|
||||
{extensionName && <span className="text-textSubtle opacity-60">· {extensionName}</span>}
|
||||
</span>
|
||||
{detailLines.length > 0 && (
|
||||
<pre className="ml-3 mt-0.5 text-xs text-textSubtle whitespace-pre-wrap">
|
||||
{detailLines.join('\n')}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<ToolCallExpandable
|
||||
label={
|
||||
<span className="pl-4 py-1 font-sans text-sm flex items-center">
|
||||
<span>Logs</span>
|
||||
<span>{labelText}</span>
|
||||
{working && (
|
||||
<div className="mx-2 inline-block">
|
||||
<span
|
||||
@@ -960,9 +1060,7 @@ function ToolLogsView({
|
||||
className={`flex flex-col items-start space-y-2 overflow-y-auto p-4 ${working ? 'max-h-[4rem]' : 'max-h-[20rem]'}`}
|
||||
>
|
||||
{logs.map((log, i) => (
|
||||
<span key={i} className="font-sans text-sm text-textSubtle">
|
||||
{log}
|
||||
</span>
|
||||
<SubagentLogEntry key={i} log={log} />
|
||||
))}
|
||||
</div>
|
||||
</ToolCallExpandable>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export const Delegate = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
width="11"
|
||||
height="11"
|
||||
viewBox="0 0 11 11"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<rect width="11" height="11" rx="2" fill="#6366F1" />
|
||||
<path
|
||||
d="M3 4L6 5.5L3 7"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="8" cy="5.5" r="1" fill="white" />
|
||||
</svg>
|
||||
);
|
||||
@@ -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';
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Brain,
|
||||
Camera,
|
||||
Code2,
|
||||
Delegate,
|
||||
Eye,
|
||||
FileEdit,
|
||||
FilePlus,
|
||||
@@ -75,6 +76,12 @@ export const getToolIcon = (toolName: string): React.ComponentType<ToolIconProps
|
||||
case 'docs_tool':
|
||||
return FileText;
|
||||
|
||||
// Delegation Tools
|
||||
case 'delegate':
|
||||
return Delegate;
|
||||
case 'load':
|
||||
return Eye;
|
||||
|
||||
// Special Tools
|
||||
case 'final_output':
|
||||
return Tool; // Could be a checkmark icon if we had one
|
||||
|
||||
Reference in New Issue
Block a user