mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
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:
parent
d2b68a4684
commit
b62ed6a2f4
21 changed files with 1145 additions and 49 deletions
|
|
@ -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} />
|
||||
|
|
|
|||
92
apps/x/apps/renderer/src/components/sub-agent-block.tsx
Normal file
92
apps/x/apps/renderer/src/components/sub-agent-block.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue