feat(v2): add agent teams support with subagent pane spawning and routing
Detect subagent tool_call events (Agent/Task/dispatch_agent), auto-spawn child agent panes with parent/child navigation. Messages with parentId are routed to child panes; parent session keeps its own messages. - agents.svelte.ts: parent/child hierarchy fields, findChildByToolUseId, getChildSessions, parent-aware createAgentSession/removeAgentSession - agent-dispatcher.ts: SUBAGENT_TOOL_NAMES detection, toolUseToChildPane routing map, spawnSubagentPane with auto-grouping under parent title - AgentPane.svelte: parent link bar (SUB badge), children bar (chips with status colors), clickable navigation between parent/child - SessionList.svelte: subagent panes show arrow icon instead of asterisk
This commit is contained in:
parent
d021061b8a
commit
07fc52b958
4 changed files with 220 additions and 6 deletions
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
import { onSidecarMessage, onSidecarExited, restartAgent, type SidecarMessage } from './adapters/agent-bridge';
|
import { onSidecarMessage, onSidecarExited, restartAgent, type SidecarMessage } from './adapters/agent-bridge';
|
||||||
import { adaptSDKMessage } from './adapters/sdk-messages';
|
import { adaptSDKMessage } from './adapters/sdk-messages';
|
||||||
import type { InitContent, CostContent } from './adapters/sdk-messages';
|
import type { InitContent, CostContent, ToolCallContent } from './adapters/sdk-messages';
|
||||||
import {
|
import {
|
||||||
updateAgentStatus,
|
updateAgentStatus,
|
||||||
setAgentSdkSessionId,
|
setAgentSdkSessionId,
|
||||||
|
|
@ -11,7 +11,10 @@ import {
|
||||||
appendAgentMessages,
|
appendAgentMessages,
|
||||||
updateAgentCost,
|
updateAgentCost,
|
||||||
getAgentSessions,
|
getAgentSessions,
|
||||||
|
createAgentSession,
|
||||||
|
findChildByToolUseId,
|
||||||
} from './stores/agents.svelte';
|
} from './stores/agents.svelte';
|
||||||
|
import { addPane, getPanes } from './stores/layout.svelte';
|
||||||
import { notify } from './stores/notifications.svelte';
|
import { notify } from './stores/notifications.svelte';
|
||||||
|
|
||||||
let unlistenMsg: (() => void) | null = null;
|
let unlistenMsg: (() => void) | null = null;
|
||||||
|
|
@ -100,10 +103,31 @@ export async function startAgentDispatcher(): Promise<void> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tool names that indicate a subagent spawn
|
||||||
|
const SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task', 'dispatch_agent']);
|
||||||
|
|
||||||
|
// Map toolUseId -> child session pane id for routing
|
||||||
|
const toolUseToChildPane = new Map<string, string>();
|
||||||
|
|
||||||
function handleAgentEvent(sessionId: string, event: Record<string, unknown>): void {
|
function handleAgentEvent(sessionId: string, event: Record<string, unknown>): void {
|
||||||
const messages = adaptSDKMessage(event);
|
const messages = adaptSDKMessage(event);
|
||||||
|
|
||||||
|
// Route messages with parentId to the appropriate child pane
|
||||||
|
const mainMessages: typeof messages = [];
|
||||||
|
const childBuckets = new Map<string, typeof messages>();
|
||||||
|
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
|
if (msg.parentId && toolUseToChildPane.has(msg.parentId)) {
|
||||||
|
const childPaneId = toolUseToChildPane.get(msg.parentId)!;
|
||||||
|
if (!childBuckets.has(childPaneId)) childBuckets.set(childPaneId, []);
|
||||||
|
childBuckets.get(childPaneId)!.push(msg);
|
||||||
|
} else {
|
||||||
|
mainMessages.push(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process main session messages
|
||||||
|
for (const msg of mainMessages) {
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'init': {
|
case 'init': {
|
||||||
const init = msg.content as InitContent;
|
const init = msg.content as InitContent;
|
||||||
|
|
@ -112,6 +136,14 @@ function handleAgentEvent(sessionId: string, event: Record<string, unknown>): vo
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'tool_call': {
|
||||||
|
const tc = msg.content as ToolCallContent;
|
||||||
|
if (SUBAGENT_TOOL_NAMES.has(tc.name)) {
|
||||||
|
spawnSubagentPane(sessionId, tc);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'cost': {
|
case 'cost': {
|
||||||
const cost = msg.content as CostContent;
|
const cost = msg.content as CostContent;
|
||||||
updateAgentCost(sessionId, {
|
updateAgentCost(sessionId, {
|
||||||
|
|
@ -133,9 +165,70 @@ function handleAgentEvent(sessionId: string, event: Record<string, unknown>): vo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (messages.length > 0) {
|
if (mainMessages.length > 0) {
|
||||||
appendAgentMessages(sessionId, messages);
|
appendAgentMessages(sessionId, mainMessages);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Append messages to child panes and update their status
|
||||||
|
for (const [childPaneId, childMsgs] of childBuckets) {
|
||||||
|
for (const msg of childMsgs) {
|
||||||
|
if (msg.type === 'init') {
|
||||||
|
const init = msg.content as InitContent;
|
||||||
|
setAgentSdkSessionId(childPaneId, init.sessionId);
|
||||||
|
setAgentModel(childPaneId, init.model);
|
||||||
|
updateAgentStatus(childPaneId, 'running');
|
||||||
|
} else if (msg.type === 'cost') {
|
||||||
|
const cost = msg.content as CostContent;
|
||||||
|
updateAgentCost(childPaneId, {
|
||||||
|
costUsd: cost.totalCostUsd,
|
||||||
|
inputTokens: cost.inputTokens,
|
||||||
|
outputTokens: cost.outputTokens,
|
||||||
|
numTurns: cost.numTurns,
|
||||||
|
durationMs: cost.durationMs,
|
||||||
|
});
|
||||||
|
updateAgentStatus(childPaneId, cost.isError ? 'error' : 'done');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appendAgentMessages(childPaneId, childMsgs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnSubagentPane(parentSessionId: string, tc: ToolCallContent): void {
|
||||||
|
// Don't create duplicate pane for same tool_use
|
||||||
|
if (toolUseToChildPane.has(tc.toolUseId)) return;
|
||||||
|
const existing = findChildByToolUseId(parentSessionId, tc.toolUseId);
|
||||||
|
if (existing) {
|
||||||
|
toolUseToChildPane.set(tc.toolUseId, existing.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const childId = crypto.randomUUID();
|
||||||
|
const prompt = typeof tc.input === 'object' && tc.input !== null
|
||||||
|
? (tc.input as Record<string, unknown>).prompt as string ?? tc.name
|
||||||
|
: tc.name;
|
||||||
|
const label = typeof tc.input === 'object' && tc.input !== null
|
||||||
|
? (tc.input as Record<string, unknown>).name as string ?? tc.name
|
||||||
|
: tc.name;
|
||||||
|
|
||||||
|
// Register routing
|
||||||
|
toolUseToChildPane.set(tc.toolUseId, childId);
|
||||||
|
|
||||||
|
// Create agent session with parent link
|
||||||
|
createAgentSession(childId, prompt, {
|
||||||
|
sessionId: parentSessionId,
|
||||||
|
toolUseId: tc.toolUseId,
|
||||||
|
});
|
||||||
|
updateAgentStatus(childId, 'running');
|
||||||
|
|
||||||
|
// Create layout pane, auto-grouped under parent's title
|
||||||
|
const parentPane = getPanes().find(p => p.id === parentSessionId);
|
||||||
|
const groupName = parentPane?.title ?? `Agent ${parentSessionId.slice(0, 8)}`;
|
||||||
|
addPane({
|
||||||
|
id: childId,
|
||||||
|
type: 'agent',
|
||||||
|
title: `Sub: ${label}`,
|
||||||
|
group: groupName,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopAgentDispatcher(): void {
|
export function stopAgentDispatcher(): void {
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,10 @@
|
||||||
getAgentSession,
|
getAgentSession,
|
||||||
createAgentSession,
|
createAgentSession,
|
||||||
removeAgentSession,
|
removeAgentSession,
|
||||||
|
getChildSessions,
|
||||||
type AgentSession,
|
type AgentSession,
|
||||||
} from '../../stores/agents.svelte';
|
} from '../../stores/agents.svelte';
|
||||||
|
import { focusPane } from '../../stores/layout.svelte';
|
||||||
import { isSidecarAlive, setSidecarAlive } from '../../agent-dispatcher';
|
import { isSidecarAlive, setSidecarAlive } from '../../agent-dispatcher';
|
||||||
import AgentTree from './AgentTree.svelte';
|
import AgentTree from './AgentTree.svelte';
|
||||||
import { getHighlighter, highlightCode, escapeHtml } from '../../utils/highlight';
|
import { getHighlighter, highlightCode, escapeHtml } from '../../utils/highlight';
|
||||||
|
|
@ -37,6 +39,8 @@
|
||||||
let restarting = $state(false);
|
let restarting = $state(false);
|
||||||
let showTree = $state(false);
|
let showTree = $state(false);
|
||||||
let hasToolCalls = $derived(session?.messages.some(m => m.type === 'tool_call') ?? false);
|
let hasToolCalls = $derived(session?.messages.some(m => m.type === 'tool_call') ?? false);
|
||||||
|
let parentSession = $derived(session?.parentSessionId ? getAgentSession(session.parentSessionId) : undefined);
|
||||||
|
let childSessions = $derived(session ? getChildSessions(session.id) : []);
|
||||||
|
|
||||||
const mdRenderer = new Renderer();
|
const mdRenderer = new Renderer();
|
||||||
mdRenderer.code = function({ text, lang }: { text: string; lang?: string }) {
|
mdRenderer.code = function({ text, lang }: { text: string; lang?: string }) {
|
||||||
|
|
@ -188,6 +192,24 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
{#if parentSession}
|
||||||
|
<div class="parent-link">
|
||||||
|
<span class="parent-badge">SUB</span>
|
||||||
|
<button class="parent-btn" onclick={() => focusPane(parentSession!.id)}>
|
||||||
|
← {parentSession.prompt ? parentSession.prompt.slice(0, 40) : 'Parent agent'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if childSessions.length > 0}
|
||||||
|
<div class="children-bar">
|
||||||
|
<span class="children-label">{childSessions.length} subagent{childSessions.length > 1 ? 's' : ''}</span>
|
||||||
|
{#each childSessions as child (child.id)}
|
||||||
|
<button class="child-chip" class:running={child.status === 'running'} class:done={child.status === 'done'} class:error={child.status === 'error'} onclick={() => focusPane(child.id)}>
|
||||||
|
{child.prompt.slice(0, 20)}{child.prompt.length > 20 ? '...' : ''}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if hasToolCalls}
|
{#if hasToolCalls}
|
||||||
<div class="tree-toggle">
|
<div class="tree-toggle">
|
||||||
<button class="tree-btn" onclick={() => showTree = !showTree}>
|
<button class="tree-btn" onclick={() => showTree = !showTree}>
|
||||||
|
|
@ -320,6 +342,71 @@
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.parent-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parent-badge {
|
||||||
|
background: var(--ctp-mauve);
|
||||||
|
color: var(--ctp-crust);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parent-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--ctp-mauve);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parent-btn:hover { color: var(--text-primary); text-decoration: underline; }
|
||||||
|
|
||||||
|
.children-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.children-label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-chip {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-chip:hover { color: var(--text-primary); border-color: var(--accent); }
|
||||||
|
.child-chip.running { border-color: var(--ctp-blue); color: var(--ctp-blue); }
|
||||||
|
.child-chip.done { border-color: var(--ctp-green); color: var(--ctp-green); }
|
||||||
|
.child-chip.error { border-color: var(--ctp-red); color: var(--ctp-red); }
|
||||||
|
|
||||||
.tree-toggle {
|
.tree-toggle {
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,8 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function paneIcon(type: string): string {
|
function paneIcon(type: string, title: string): string {
|
||||||
|
if (type === 'agent' && title.startsWith('Sub: ')) return '↳';
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'terminal': return '>';
|
case 'terminal': return '>';
|
||||||
case 'agent': return '*';
|
case 'agent': return '*';
|
||||||
|
|
@ -153,7 +154,7 @@
|
||||||
{#snippet paneItem(pane: Pane)}
|
{#snippet paneItem(pane: Pane)}
|
||||||
<li class="pane-item" class:focused={pane.focused}>
|
<li class="pane-item" class:focused={pane.focused}>
|
||||||
<button class="pane-btn" onclick={() => focusPane(pane.id)} oncontextmenu={(e) => { e.preventDefault(); setGroup(pane.id); }}>
|
<button class="pane-btn" onclick={() => focusPane(pane.id)} oncontextmenu={(e) => { e.preventDefault(); setGroup(pane.id); }}>
|
||||||
<span class="pane-icon">{paneIcon(pane.type)}</span>
|
<span class="pane-icon">{paneIcon(pane.type, pane.title)}</span>
|
||||||
<span class="pane-name">{pane.title}</span>
|
<span class="pane-name">{pane.title}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="remove-btn" onclick={() => removePane(pane.id)}>×</button>
|
<button class="remove-btn" onclick={() => removePane(pane.id)}>×</button>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ export interface AgentSession {
|
||||||
numTurns: number;
|
numTurns: number;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
// Agent Teams: parent/child hierarchy
|
||||||
|
parentSessionId?: string;
|
||||||
|
parentToolUseId?: string;
|
||||||
|
childSessionIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let sessions = $state<AgentSession[]>([]);
|
let sessions = $state<AgentSession[]>([]);
|
||||||
|
|
@ -30,7 +34,7 @@ export function getAgentSession(id: string): AgentSession | undefined {
|
||||||
return sessions.find(s => s.id === id);
|
return sessions.find(s => s.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAgentSession(id: string, prompt: string): void {
|
export function createAgentSession(id: string, prompt: string, parent?: { sessionId: string; toolUseId: string }): void {
|
||||||
sessions.push({
|
sessions.push({
|
||||||
id,
|
id,
|
||||||
status: 'starting',
|
status: 'starting',
|
||||||
|
|
@ -41,7 +45,18 @@ export function createAgentSession(id: string, prompt: string): void {
|
||||||
outputTokens: 0,
|
outputTokens: 0,
|
||||||
numTurns: 0,
|
numTurns: 0,
|
||||||
durationMs: 0,
|
durationMs: 0,
|
||||||
|
parentSessionId: parent?.sessionId,
|
||||||
|
parentToolUseId: parent?.toolUseId,
|
||||||
|
childSessionIds: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Register as child of parent
|
||||||
|
if (parent) {
|
||||||
|
const parentSession = sessions.find(s => s.id === parent.sessionId);
|
||||||
|
if (parentSession) {
|
||||||
|
parentSession.childSessionIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateAgentStatus(id: string, status: AgentStatus, error?: string): void {
|
export function updateAgentStatus(id: string, status: AgentStatus, error?: string): void {
|
||||||
|
|
@ -86,6 +101,24 @@ export function updateAgentCost(
|
||||||
session.durationMs = cost.durationMs;
|
session.durationMs = cost.durationMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Find a child session that was spawned by a specific tool_use */
|
||||||
|
export function findChildByToolUseId(parentId: string, toolUseId: string): AgentSession | undefined {
|
||||||
|
return sessions.find(s => s.parentSessionId === parentId && s.parentToolUseId === toolUseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all child sessions for a given parent */
|
||||||
|
export function getChildSessions(parentId: string): AgentSession[] {
|
||||||
|
return sessions.filter(s => s.parentSessionId === parentId);
|
||||||
|
}
|
||||||
|
|
||||||
export function removeAgentSession(id: string): void {
|
export function removeAgentSession(id: string): void {
|
||||||
|
// Also remove from parent's childSessionIds
|
||||||
|
const session = sessions.find(s => s.id === id);
|
||||||
|
if (session?.parentSessionId) {
|
||||||
|
const parent = sessions.find(s => s.id === session.parentSessionId);
|
||||||
|
if (parent) {
|
||||||
|
parent.childSessionIds = parent.childSessionIds.filter(cid => cid !== id);
|
||||||
|
}
|
||||||
|
}
|
||||||
sessions = sessions.filter(s => s.id !== id);
|
sessions = sessions.filter(s => s.id !== id);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue