feat(x): spawn-agent — run sub-agents as headless child turns

Adds the agent-as-tool capability deferred in turn-runtime-design §29.2:
a `spawn-agent` builtin that runs a sub-agent in its own standalone
headless turn and returns its final answer to the parent. Multiple
spawn calls in one assistant batch run concurrently (previous commit).

- RequestedAgent is now a union: by-id (unchanged shape) | inline
  {name, instructions, model?, tools?}. Inline definitions persist
  verbatim in turn_created and resolve to the same immutable snapshot.
- Agent resolution splits by variant: DispatchingAgentResolver narrows
  the union once; InlineAgentResolver materializes inline specs
  (builtin catalog validation, headless default profile when tools are
  omitted); RealAgentResolver keeps the by-id path byte-identical. The
  builtin→ToolDescriptor conversion is extracted to a shared helper.
- The spawn handler (RealToolRegistry branch → runSpawnedAgent) runs
  the child via HeadlessAgentRunner on the parent's model by default,
  clamps the model-call budget at 20, cascades the parent's abort
  signal, and records {kind:"subagent", childTurnId} as durable tool
  progress — the only parent→child link; no parentTurnId is added to
  the schema. Task-level failures return as conversational isError
  results, never terminal.
- Depth is capped at 1: both resolvers strip spawn-agent from children
  (inline always; by-id via the new subagent composition flag) and the
  handler refuses child-shaped parents outright.
- Renderer: spawn-agent calls render as a SubAgentBlock — a collapsed
  status card that expands to the child's live transcript
  (CompactConversation over sessions:getTurn, polled at 1s while
  running; standalone child turns don't reach the session bus).
- The BuiltinTools entry gives copilot (and other catalog-attached
  agents) the tool automatically; its execute is the degraded legacy
  path only, since the turn runtime intercepts builtin:spawn-agent.

Schema note: RequestedAgent widened under schemaVersion 1 (pre-release)
— requires wiping ~/.rowboat/storage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-07-08 23:31:41 +05:30
parent d2b68a4684
commit b62ed6a2f4
21 changed files with 1145 additions and 49 deletions

View file

@ -33,6 +33,7 @@ import { AppsView } from '@/components/apps/apps-view';
import { EmailView } from '@/components/email-view';
import { WorkspaceView } from '@/components/workspace-view';
import { CodingRunBlock } from '@/components/coding-run';
import { SubAgentBlock } from '@/components/sub-agent-block';
import { KnowledgeView, type KnowledgeViewMode } from '@/components/knowledge-view';
import { GoogleDocPickerDialog } from '@/components/google-doc-picker-dialog';
import { ChatHistoryView } from '@/components/chat-history-view';
@ -5950,6 +5951,16 @@ function App() {
/>
)
}
if (item.name === 'spawn-agent') {
return (
<SubAgentBlock
key={item.id}
item={item}
open={isToolOpenForTab(tabId, item.id)}
onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)}
/>
)
}
const appActionData = getAppActionCardData(item)
if (appActionData) {
return <AppActionCard key={item.id} data={appActionData} status={item.status} />

View file

@ -0,0 +1,92 @@
import { useEffect, useState } from 'react'
import { Bot } from 'lucide-react'
import { Tool, ToolContent, ToolHeader } from '@/components/ai-elements/tool'
import { CompactConversation } from '@/components/compact-conversation'
import { fetchAgentRunTranscript, type AgentRunTranscript } from '@/lib/agent-transcript'
import { toToolState, type ToolCall } from '@/lib/chat-conversation'
// Rendered for a spawn-agent tool call: a collapsed status card that expands
// into the child turn's live transcript. The child is a standalone turn
// (sessionId null) whose events never reach the session bus, so while it
// runs we poll sessions:getTurn via fetchAgentRunTranscript — the file is
// local and append-only, making this cheap — and do one final fetch when the
// parent tool call settles.
const POLL_MS = 1000
function useChildTranscript(
childTurnId: string | undefined,
running: boolean,
open: boolean,
): AgentRunTranscript | null {
const [transcript, setTranscript] = useState<AgentRunTranscript | null>(null)
useEffect(() => {
if (!childTurnId || !open) return
let alive = true
const fetchOnce = async () => {
try {
const next = await fetchAgentRunTranscript(childTurnId)
if (alive) setTranscript(next)
} catch {
// Child file may not be readable yet; the next tick retries.
}
}
void fetchOnce()
if (!running) return () => { alive = false }
const timer = setInterval(() => void fetchOnce(), POLL_MS)
return () => {
alive = false
clearInterval(timer)
}
}, [childTurnId, running, open])
return transcript
}
export function SubAgentBlock({
item,
open,
onOpenChange,
}: {
item: ToolCall
open: boolean
onOpenChange: (open: boolean) => void
}) {
const input = item.input as
| { name?: string; agent_id?: string; task?: string }
| undefined
const agentName = item.subAgent?.agentName ?? input?.agent_id ?? input?.name ?? 'subagent'
const task = item.subAgent?.task ?? input?.task ?? ''
const running = item.status === 'pending' || item.status === 'running'
const transcript = useChildTranscript(item.subAgent?.childTurnId, running, open)
return (
<Tool open={open} onOpenChange={onOpenChange}>
<ToolHeader
title={`Sub-agent: ${agentName}`}
type="tool-spawn-agent"
state={toToolState(item.status)}
/>
<ToolContent>
<div className="flex flex-col gap-3 px-4 pb-4">
{task && (
<div className="flex items-start gap-2 rounded-2xl bg-secondary/50 px-3 py-2 text-sm text-muted-foreground">
<Bot className="mt-0.5 size-4 shrink-0" />
<span className="whitespace-pre-wrap">{task}</span>
</div>
)}
{transcript ? (
<CompactConversation items={transcript.items} />
) : (
<div className="px-1 text-sm text-muted-foreground">
{item.subAgent
? 'Loading sub-agent transcript…'
: running
? 'Starting sub-agent…'
: 'No sub-agent transcript was recorded.'}
</div>
)}
</div>
</ToolContent>
</Tool>
)
}

View file

@ -34,6 +34,8 @@ export interface ToolCall {
// code_agent_run only: structured ACP stream items + the in-flight permission ask.
codeRunEvents?: CodeRunEvent[]
pendingCodePermission?: { requestId: string; ask: PermissionAsk } | null
// spawn-agent only: the durable parent→child link recorded as tool progress.
subAgent?: { childTurnId: string; agentName: string; task: string }
}
export interface ErrorMessage {
@ -668,6 +670,7 @@ export const isToolGroup = (item: GroupedConversationItem): item is ToolGroup =>
const isPlainToolCall = (item: ConversationItem): item is ToolCall => {
if (!isToolCall(item)) return false
if (item.name === 'code_agent_run') return false // rich standalone block, never grouped
if (item.name === 'spawn-agent') return false // rich standalone block, never grouped
if (getWebSearchCardData(item)) return false
if (getComposioConnectCardData(item)) return false
if (getAppActionCardData(item)) return false

View file

@ -309,6 +309,49 @@ describe('buildTurnConversation', () => {
expect(settled.status).toBe('completed')
})
it('derives the sub-agent child link from spawn-agent progress', () => {
const state = reduceTurn([
created(T1, S1),
requested(T1, 0),
completed(
T1,
0,
assistantCalls(
toolCallPart('sa1', 'spawn-agent', { task: 'research X', instructions: 'You research.' }),
),
),
invocation(T1, 'sa1', 'spawn-agent'),
{
type: 'tool_progress',
turnId: T1,
ts: TS,
toolCallId: 'sa1',
source: 'sync',
progress: {
kind: 'subagent',
childTurnId: 'child-turn-1',
agentName: 'researcher',
task: 'research X',
} as never,
},
])
const tool = buildTurnConversation(state).filter(isToolCall)[0]
expect(tool.subAgent).toEqual({
childTurnId: 'child-turn-1',
agentName: 'researcher',
task: 'research X',
})
// Without the progress entry the link is simply absent.
const bare = reduceTurn([
created(T1, S1),
requested(T1, 0),
completed(T1, 0, assistantCalls(toolCallPart('sa2', 'spawn-agent', { task: 't' }))),
invocation(T1, 'sa2', 'spawn-agent'),
])
expect(buildTurnConversation(bare).filter(isToolCall)[0].subAgent).toBeUndefined()
})
it('renders user attachments and a failed turn as an error item', () => {
const input = {
role: 'user' as const,

View file

@ -241,6 +241,33 @@ function codeRunViewOf(
}
}
// spawn-agent's durable trail in tool_progress: one 'subagent' entry recorded
// the moment the child turn exists (see the spawn-agent branch in
// real-tool-registry.ts). It is the parent→child link the card uses to fetch
// and render the child transcript.
function subAgentViewOf(tc: ToolCallState): Pick<ToolCall, 'subAgent'> {
for (const p of tc.progress) {
const entry = p.progress
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
const { kind, childTurnId, agentName, task } = entry as {
kind?: unknown
childTurnId?: unknown
agentName?: unknown
task?: unknown
}
if (kind === 'subagent' && typeof childTurnId === 'string') {
return {
subAgent: {
childTurnId,
agentName: typeof agentName === 'string' ? agentName : 'subagent',
task: typeof task === 'string' ? task : '',
},
}
}
}
return {}
}
// One turn's contribution to the conversation: the user input, then per
// completed model call its text and tool calls (with live status/results).
export function buildTurnConversation(state: TurnState): ConversationItem[] {
@ -295,6 +322,7 @@ export function buildTurnConversation(state: TurnState): ConversationItem[] {
status: tc ? toolStatus(tc) : 'running',
timestamp: ts(),
...(tc ? codeRunViewOf(tc) : {}),
...(tc ? subAgentViewOf(tc) : {}),
} satisfies ToolCall)
}
}