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)
}
}

View file

@ -1895,6 +1895,17 @@ tool handler may create and execute child turns, forward progress, and return a
parent tool result. No subflow or parent-turn concept is added to this initial
turn schema.
Implemented (v1) as the `spawn-agent` builtin: a sync tool whose handler
(`RealToolRegistry`) runs the child as a standalone headless turn
(`runSpawnedAgent`), records `{kind: "subagent", childTurnId}` as durable
tool progress (the only parent→child link), and returns the child's final
text plus a status envelope. `RequestedAgent` is a union of by-id and inline
variants; inline agents resolve through `InlineAgentResolver`. Depth is
capped at 1: both resolvers strip the spawn tool from children, and the
handler refuses child-shaped parents. Parallel fan-out comes from concurrent
sync-tool execution (§10.5), not from async suspension; async (restart
survivability for long children) remains future work.
### 29.3 Reliability enhancements
- External-input idempotency.

View file

@ -1,6 +1,7 @@
import type { z } from "zod";
import type { AssistantMessage } from "@x/shared/dist/message.js";
import {
type RequestedAgent,
reduceTurn,
type TurnState,
} from "@x/shared/dist/turns.js";
@ -26,7 +27,10 @@ export class HeadlessRunError extends Error {
}
export interface HeadlessAgentOptions {
agentId: string;
agentId?: string;
// Full agent request, used verbatim when set (inline agents, composition
// overrides). Takes precedence over agentId/model/provider.
agent?: z.infer<typeof RequestedAgent>;
message: string;
// Model id; when set without provider, the app-default provider applies.
model?: string;
@ -121,22 +125,29 @@ export class HeadlessAgentRunner implements IHeadlessAgentRunner {
}
async start(options: HeadlessAgentOptions): Promise<HeadlessAgentHandle> {
let modelOverride: { provider: string; model: string } | undefined;
if (options.model && options.provider) {
modelOverride = { provider: options.provider, model: options.model };
} else if (options.model || options.provider) {
const defaults = await this.defaultModelResolver.resolve();
modelOverride = {
provider: options.provider ?? defaults.provider,
model: options.model ?? defaults.model,
let agent = options.agent;
if (!agent) {
if (!options.agentId) {
throw new Error("headless agent needs an agentId or an agent request");
}
let modelOverride: { provider: string; model: string } | undefined;
if (options.model && options.provider) {
modelOverride = { provider: options.provider, model: options.model };
} else if (options.model || options.provider) {
const defaults = await this.defaultModelResolver.resolve();
modelOverride = {
provider: options.provider ?? defaults.provider,
model: options.model ?? defaults.model,
};
}
agent = {
agentId: options.agentId,
...(modelOverride ? { overrides: { model: modelOverride } } : {}),
};
}
const turnId = await this.turnRuntime.createTurn({
agent: {
agentId: options.agentId,
...(modelOverride ? { overrides: { model: modelOverride } } : {}),
},
agent,
sessionId: null,
context: [],
input: { role: "user", content: options.message },

View file

@ -0,0 +1,222 @@
import { describe, expect, it } from "vitest";
import type { z } from "zod";
import type { TurnEvent, TurnState } from "@x/shared/dist/turns.js";
import type { ITurnRuntime } from "../turns/api.js";
import type {
HeadlessAgentHandle,
HeadlessAgentOptions,
HeadlessAgentResult,
IHeadlessAgentRunner,
} from "./headless.js";
import { runSpawnedAgent } from "./spawn-agent.js";
const TS = "2026-07-07T10:00:00Z";
function parentCreated(
overrides: {
requested?: z.infer<typeof TurnEvent> extends never ? never : unknown;
} & Record<string, unknown> = {},
): Array<z.infer<typeof TurnEvent>> {
return [
{
type: "turn_created",
schemaVersion: 1,
turnId: "parent-1",
ts: TS,
sessionId: null,
agent: {
requested: (overrides.requested ?? { agentId: "copilot" }) as never,
resolved: {
agentId: "copilot",
systemPrompt: "s",
model: { provider: "parent-p", model: "parent-m" },
tools: [],
},
},
context: [],
input: { role: "user", content: "hi" },
config: {
autoPermission: true,
humanAvailable: false,
maxModelCalls: 20,
},
} as z.infer<typeof TurnEvent>,
];
}
function fakeServices(opts: {
parentEvents?: Array<z.infer<typeof TurnEvent>>;
childResult?: Partial<HeadlessAgentResult>;
startError?: string;
}) {
const started: HeadlessAgentOptions[] = [];
const turnRuntime = {
getTurn: async () => ({
turnId: "parent-1",
events: opts.parentEvents ?? parentCreated(),
}),
} as unknown as ITurnRuntime;
const headlessRunner: IHeadlessAgentRunner = {
start: async (options: HeadlessAgentOptions): Promise<HeadlessAgentHandle> => {
if (opts.startError) {
throw new Error(opts.startError);
}
started.push(options);
const result: HeadlessAgentResult = {
outcome: {
status: "completed",
output: { role: "assistant", content: "answer" },
finishReason: "stop",
usage: { totalTokens: 7 },
},
state: { modelCalls: [{}, {}] } as unknown as TurnState,
summary: "answer",
...opts.childResult,
};
return { turnId: "child-1", done: Promise.resolve(result) };
},
run: async () => {
throw new Error("unused");
},
};
return { services: { turnRuntime, headlessRunner }, started };
}
const signal = new AbortController().signal;
describe("runSpawnedAgent", () => {
it("runs an inline child on the parent's model and returns the result envelope", async () => {
const { services, started } = fakeServices({});
const progress: unknown[] = [];
const result = await runSpawnedAgent(
{ task: "find things", name: "researcher", instructions: "You research." },
{
parentTurnId: "parent-1",
signal,
services,
onChildStarted: async (info) => {
progress.push(info);
},
},
);
expect(started[0].agent).toEqual({
inline: {
name: "researcher",
instructions: "You research.",
model: { provider: "parent-p", model: "parent-m" },
},
});
expect(started[0].maxModelCalls).toBe(20);
expect(started[0].signal).toBe(signal);
expect(progress).toEqual([
{ childTurnId: "child-1", agentName: "researcher", task: "find things" },
]);
expect(result).toEqual({
isError: false,
output: {
status: "completed",
result: "answer",
childTurnId: "child-1",
agent: "researcher",
modelCalls: 2,
usage: { totalTokens: 7 },
},
});
});
it("marks by-id children with the subagent composition flag", async () => {
const { services, started } = fakeServices({});
await runSpawnedAgent(
{ task: "t", agent_id: "background-task-agent", max_model_calls: 5 },
{ parentTurnId: "parent-1", signal, services },
);
expect(started[0].agent).toEqual({
agentId: "background-task-agent",
overrides: {
model: { provider: "parent-p", model: "parent-m" },
composition: { subagent: true },
},
});
expect(started[0].maxModelCalls).toBe(5);
});
it("requires exactly one of agent_id and instructions", async () => {
const { services } = fakeServices({});
for (const input of [
{ task: "t" },
{ task: "t", agent_id: "a", instructions: "b" },
]) {
const result = await runSpawnedAgent(input, {
parentTurnId: "parent-1",
signal,
services,
});
expect(result.isError).toBe(true);
expect(result.output).toMatch(/exactly one/);
}
});
it("rejects a model-call budget above the cap at the schema boundary", async () => {
const { services } = fakeServices({});
const result = await runSpawnedAgent(
{ task: "t", instructions: "x", max_model_calls: 50 },
{ parentTurnId: "parent-1", signal, services },
);
expect(result.isError).toBe(true);
expect(result.output).toMatch(/invalid input/);
});
it("refuses to spawn from a child parent (depth cap)", async () => {
for (const requested of [
{ inline: { name: "c", instructions: "x" } },
{ agentId: "copilot", overrides: { composition: { subagent: true } } },
]) {
const { services, started } = fakeServices({
parentEvents: parentCreated({ requested }),
});
const result = await runSpawnedAgent(
{ task: "t", instructions: "x" },
{ parentTurnId: "parent-1", signal, services },
);
expect(result.isError).toBe(true);
expect(result.output).toMatch(/cannot spawn further/);
expect(started).toHaveLength(0);
}
});
it("reports resolution failures conversationally", async () => {
const { services } = fakeServices({ startError: "agent not found: nope" });
const result = await runSpawnedAgent(
{ task: "t", agent_id: "nope" },
{ parentTurnId: "parent-1", signal, services },
);
expect(result).toEqual({
isError: true,
output: "spawn-agent: agent not found: nope",
});
});
it("returns child failure as an error envelope with the partial answer", async () => {
const { services } = fakeServices({
childResult: {
outcome: {
status: "failed",
error: "model exploded",
usage: {},
},
summary: "got halfway",
},
});
const result = await runSpawnedAgent(
{ task: "t", instructions: "x" },
{ parentTurnId: "parent-1", signal, services },
);
expect(result.isError).toBe(true);
expect(result.output).toMatchObject({
status: "failed",
error: "model exploded",
partialResult: "got halfway",
childTurnId: "child-1",
});
});
});

View file

@ -0,0 +1,253 @@
import { z } from "zod";
import {
DEFAULT_MAX_MODEL_CALLS,
type JsonValue,
type ModelDescriptor,
type RequestedAgent,
type ToolResultData,
isInlineAgentRequest,
reduceTurn,
} from "@x/shared/dist/turns.js";
// The spawn-agent tool: runs a sub-agent as a standalone headless child turn
// and returns its final answer. The input schema and description live here
// (imported by the BuiltinTools catalog entry); execution resolves runtime
// services lazily so this module stays import-cycle-free.
export const SpawnAgentInput = z.object({
task: z
.string()
.describe(
"The task for the sub-agent. It starts with NO other context — include everything it needs (facts, constraints, expected output format).",
),
agent_id: z
.string()
.optional()
.describe(
"Run a stored agent by id. Mutually exclusive with `instructions`.",
),
name: z
.string()
.optional()
.describe("Short display name for an inline agent (e.g. 'researcher')."),
instructions: z
.string()
.optional()
.describe(
"System instructions for an agent constructed on the fly. Mutually exclusive with `agent_id`.",
),
model: z
.string()
.optional()
.describe("Model id for the sub-agent; defaults to the current model."),
provider: z
.string()
.optional()
.describe("Provider for `model`; defaults to the current provider."),
tools: z
.array(z.string())
.optional()
.describe(
"Builtin tool names for an inline agent. Omit for the default headless profile (files, web, search, knowledge).",
),
max_model_calls: z
.number()
.int()
.min(1)
.max(DEFAULT_MAX_MODEL_CALLS)
.optional()
.describe(
`Model-call budget for the sub-agent (default and cap: ${DEFAULT_MAX_MODEL_CALLS}).`,
),
});
export const SPAWN_AGENT_DESCRIPTION =
"Launch a sub-agent that works on a task in its own isolated, headless turn and returns its final answer. " +
"Issue several spawn-agent calls in ONE response to run sub-agents in parallel — use this to fan out independent research, analysis, or file work. " +
"Provide either `agent_id` (a stored agent) or `instructions` (construct a specialist on the fly, optionally with `name` and `tools`). " +
"The sub-agent cannot ask the user questions and cannot spawn further sub-agents; give it a complete, self-contained task.";
export interface SpawnedAgentCallbacks {
parentTurnId: string;
signal: AbortSignal;
// Invoked as soon as the child turn exists — the caller records the
// durable parent→child link before the child settles.
onChildStarted?: (info: {
childTurnId: string;
agentName: string;
task: string;
}) => Promise<void>;
// Test seam; production resolves these from the DI container.
services?: {
turnRuntime: import("../turns/api.js").ITurnRuntime;
headlessRunner: import("./headless.js").IHeadlessAgentRunner;
};
}
// Runs one spawned child to completion. Never throws for task-level problems
// (bad input, unknown agent/tool, child failure) — those come back as
// isError results so the parent model can react conversationally.
export async function runSpawnedAgent(
rawInput: unknown,
opts: SpawnedAgentCallbacks,
): Promise<z.infer<typeof ToolResultData>> {
const parsed = SpawnAgentInput.safeParse(rawInput);
if (!parsed.success) {
return spawnError(`invalid input: ${parsed.error.message}`);
}
const input = parsed.data;
if (!input.agent_id === !input.instructions) {
return spawnError(
"provide exactly one of `agent_id` or `instructions`",
);
}
// Lazy: this module is imported by the BuiltinTools catalog, which the
// DI container's bridges import at startup.
const { turnRuntime, headlessRunner } =
opts.services ?? (await resolveServices());
let parentModel: z.infer<typeof ModelDescriptor> | undefined;
try {
const parent = reduceTurn(
(await turnRuntime.getTurn(opts.parentTurnId)).events,
);
const parentAgent = parent.definition.agent;
// Defense in depth for the depth-1 cap: resolvers already strip the
// spawn tool from children, but a child that somehow holds it must
// still not recurse.
if (hasSubagentFlag(parentAgent.requested)) {
return spawnError("sub-agents cannot spawn further sub-agents");
}
parentModel = parentAgent.resolved.model;
} catch {
// Parent unreadable (legacy runs path): fall back to app defaults.
parentModel = undefined;
}
const model: z.infer<typeof ModelDescriptor> | undefined = input.model
? {
provider: input.provider ?? parentModel?.provider ?? "",
model: input.model,
}
: parentModel;
if (model && !model.provider) {
return spawnError(
"`model` was set but no provider could be determined; pass `provider` too",
);
}
const agentName = input.agent_id ?? input.name ?? "subagent";
const agent: z.infer<typeof RequestedAgent> = input.agent_id
? {
agentId: input.agent_id,
overrides: {
...(model ? { model } : {}),
composition: { subagent: true },
},
}
: {
inline: {
name: agentName,
instructions: input.instructions!,
...(model ? { model } : {}),
...(input.tools ? { tools: input.tools } : {}),
},
};
const maxModelCalls = Math.min(
input.max_model_calls ?? DEFAULT_MAX_MODEL_CALLS,
DEFAULT_MAX_MODEL_CALLS,
);
let handle: Awaited<ReturnType<typeof headlessRunner.start>>;
try {
handle = await headlessRunner.start({
agent,
message: input.task,
maxModelCalls,
signal: opts.signal,
});
} catch (error) {
// Resolution failures (unknown agent id, unknown tool name) reject
// createTurn before any turn file exists.
return spawnError(errorText(error));
}
await opts.onChildStarted?.({
childTurnId: handle.turnId,
agentName,
task: input.task,
});
const result = await handle.done;
const base = {
childTurnId: handle.turnId,
agent: agentName,
modelCalls: result.state.modelCalls.length,
usage: result.outcome.usage as JsonValue,
};
if (result.outcome.status === "completed") {
return {
output: {
status: "completed",
result: result.summary ?? "",
...base,
},
isError: false,
};
}
return {
output: {
status: result.outcome.status,
error:
result.outcome.status === "failed"
? result.outcome.error
: `sub-agent turn was ${result.outcome.status}`,
// A partial answer is often still useful to the parent.
...(result.summary ? { partialResult: result.summary } : {}),
...base,
},
isError: true,
};
}
async function resolveServices(): Promise<
NonNullable<SpawnedAgentCallbacks["services"]>
> {
const { default: container } = await import("../di/container.js");
return {
turnRuntime:
container.resolve<import("../turns/api.js").ITurnRuntime>(
"turnRuntime",
),
headlessRunner: container.resolve<
import("./headless.js").IHeadlessAgentRunner
>("headlessAgentRunner"),
};
}
function spawnError(message: string): z.infer<typeof ToolResultData> {
return { output: `spawn-agent: ${message}`, isError: true };
}
// True for any child-shaped parent: inline agents only exist as spawned
// children, and by-id children carry the subagent composition flag.
function hasSubagentFlag(
requested: z.infer<typeof RequestedAgent>,
): boolean {
if (isInlineAgentRequest(requested)) {
return true;
}
const composition = requested.overrides?.composition;
return (
typeof composition === "object" &&
composition !== null &&
!Array.isArray(composition) &&
composition.subagent === true
);
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -16,6 +16,8 @@ import { composioAccountsRepo } from "../../composio/repo.js";
import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, searchTools as searchComposioTools } from "../../composio/client.js";
import { CURATED_TOOLKITS, CURATED_TOOLKIT_SLUGS } from "@x/shared/dist/composio.js";
import { RowboatAppManifestSchema } from "@x/shared/dist/rowboat-app.js";
import { SPAWN_AGENT_TOOL_NAME } from "@x/shared/dist/turns.js";
import { SPAWN_AGENT_DESCRIPTION, SpawnAgentInput } from "../../agents/spawn-agent.js";
import { BrowserControlInputSchema, type BrowserControlInput } from "@x/shared/dist/browser-control.js";
import { BackgroundTaskSchema, TriggersSchema } from "@x/shared/dist/background-task.js";
import type { CodeModeManager } from "../../code-mode/acp/manager.js";
@ -2011,4 +2013,27 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
}
},
},
[SPAWN_AGENT_TOOL_NAME]: {
description: SPAWN_AGENT_DESCRIPTION,
inputSchema: SpawnAgentInput,
// Legacy runs-runtime path only: the turn runtime intercepts
// builtin:spawn-agent in RealToolRegistry with a dedicated handler
// that also records the parent→child link as durable tool progress.
execute: async (input: unknown, ctx?: ToolContext) => {
const { runSpawnedAgent } = await import("../../agents/spawn-agent.js");
const result = await runSpawnedAgent(input, {
parentTurnId: ctx?.runId ?? "",
signal: ctx?.signal ?? new AbortController().signal,
});
if (result.isError) {
throw new Error(
typeof result.output === "string"
? result.output
: JSON.stringify(result.output),
);
}
return result.output;
},
},
};

View file

@ -43,6 +43,8 @@ import type { IModelRegistry } from "../turns/model-registry.js";
import type { IToolRegistry } from "../turns/tool-registry.js";
import type { IPermissionChecker, IPermissionClassifier } from "../turns/permission.js";
import { RealAgentResolver } from "../turns/bridges/real-agent-resolver.js";
import { InlineAgentResolver } from "../turns/bridges/inline-agent-resolver.js";
import { DispatchingAgentResolver } from "../turns/bridges/agent-resolver-dispatch.js";
import { RealModelRegistry } from "../turns/bridges/real-model-registry.js";
import { RealToolRegistry } from "../turns/bridges/real-tool-registry.js";
import { RealPermissionChecker } from "../turns/bridges/real-permission-checker.js";
@ -123,7 +125,13 @@ container.register({
).singleton(),
lifecycleBus: asClass<ITurnLifecycleBus>(EmitterTurnLifecycleBus).singleton(),
usageReporter: asClass<IUsageReporter>(RealUsageReporter).singleton(),
agentResolver: asFunction<IAgentResolver>(() => new RealAgentResolver()).singleton(),
agentResolver: asFunction<IAgentResolver>(
() =>
new DispatchingAgentResolver(
new RealAgentResolver(),
new InlineAgentResolver(),
),
).singleton(),
modelRegistry: asFunction<IModelRegistry>(() => new RealModelRegistry()).singleton(),
toolRegistry: asFunction<IToolRegistry>(() => new RealToolRegistry()).singleton(),
permissionChecker: asFunction<IPermissionChecker>(() => new RealPermissionChecker()).singleton(),

View file

@ -14,6 +14,8 @@ import {
type ModelDescriptor,
type ToolResultData,
deriveTurnStatus,
inlineAgentId,
isInlineAgentRequest,
reduceTurn,
} from "@x/shared/dist/turns.js";
import type { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
@ -189,7 +191,9 @@ export class SessionsImpl implements ISessions {
ts: this.clock.now(),
turnId,
sessionSeq: state.turns.length + 1,
agentId: config.agent.agentId,
agentId: isInlineAgentRequest(config.agent)
? inlineAgentId(config.agent.inline.name)
: config.agent.agentId,
model: await this.resolvedModelOf(turnId),
},
];

View file

@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import type { ResolvedAgent } from "@x/shared/dist/turns.js";
import type { z } from "zod";
import { DispatchingAgentResolver } from "./agent-resolver-dispatch.js";
import type { InlineAgentResolver } from "./inline-agent-resolver.js";
import type { RealAgentResolver } from "./real-agent-resolver.js";
function stub(agentId: string) {
return {
resolve: async () =>
({
agentId,
systemPrompt: "s",
model: { provider: "p", model: "m" },
tools: [],
}) satisfies z.infer<typeof ResolvedAgent>,
};
}
describe("DispatchingAgentResolver", () => {
it("routes by-id requests to the by-id resolver and inline to the inline resolver", async () => {
const resolver = new DispatchingAgentResolver(
stub("from-by-id") as unknown as RealAgentResolver,
stub("from-inline") as unknown as InlineAgentResolver,
);
expect((await resolver.resolve({ agentId: "copilot" })).agentId).toBe(
"from-by-id",
);
expect(
(
await resolver.resolve({
inline: { name: "x", instructions: "y" },
})
).agentId,
).toBe("from-inline");
});
});

View file

@ -0,0 +1,27 @@
import type { z } from "zod";
import {
type RequestedAgent,
type ResolvedAgent,
isInlineAgentRequest,
} from "@x/shared/dist/turns.js";
import type { IAgentResolver } from "../agent-resolver.js";
import type { InlineAgentResolver } from "./inline-agent-resolver.js";
import type { RealAgentResolver } from "./real-agent-resolver.js";
// The only IAgentResolver implementation: narrows the RequestedAgent union
// exactly once and hands each variant to a resolver that never sees the
// other's concerns (loadAgent/composition vs. catalog validation/defaults).
export class DispatchingAgentResolver implements IAgentResolver {
constructor(
private readonly byId: RealAgentResolver,
private readonly inline: InlineAgentResolver,
) {}
resolve(
requested: z.infer<typeof RequestedAgent>,
): Promise<z.infer<typeof ResolvedAgent>> {
return isInlineAgentRequest(requested)
? this.inline.resolve(requested)
: this.byId.resolve(requested);
}
}

View file

@ -0,0 +1,43 @@
import { z } from "zod";
import type { JsonValue, ToolDescriptor } from "@x/shared/dist/turns.js";
import type { BuiltinTools } from "../../application/lib/builtin-tools.js";
type BuiltinToolDef = (typeof BuiltinTools)[string];
// The one place a builtin catalog entry becomes a persisted ToolDescriptor.
// Shared by the by-id and inline agent resolvers so both produce
// byte-identical descriptors (which is what makes agent-snapshot inheritance
// work across turns).
export function builtinToolDescriptor(
name: string,
builtin: BuiltinToolDef,
): z.infer<typeof ToolDescriptor> {
return {
toolId: `builtin:${name}`,
name,
description: builtin.description,
inputSchema: toJsonSchema(builtin.inputSchema),
execution: "sync",
requiresHuman: false,
};
}
export function toJsonSchema(schema: unknown): JsonValue {
try {
return toJsonValue(z.toJSONSchema(schema as z.ZodType)) ?? {
type: "object",
properties: {},
};
} catch {
// An exotic zod schema must not break the whole turn.
return { type: "object", properties: {} };
}
}
export function toJsonValue(value: unknown): JsonValue | undefined {
try {
return JSON.parse(JSON.stringify(value)) as JsonValue;
} catch {
return undefined;
}
}

View file

@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import type { BuiltinTools } from "../../application/lib/builtin-tools.js";
import { InlineAgentResolver } from "./inline-agent-resolver.js";
const fakeBuiltins = {
"web-search": {
description: "Search the web",
inputSchema: z.object({ query: z.string() }),
execute: async () => null,
},
"file-readText": {
description: "Read a file",
inputSchema: z.object({ path: z.string() }),
execute: async () => null,
},
"flaky-tool": {
description: "Sometimes available",
inputSchema: z.object({}),
execute: async () => null,
isAvailable: async () => false,
},
"spawn-agent": {
description: "Spawn",
inputSchema: z.object({}),
execute: async () => null,
},
executeCommand: {
description: "Run a command",
inputSchema: z.object({ command: z.string() }),
execute: async () => null,
},
} as unknown as typeof BuiltinTools;
function makeResolver() {
return new InlineAgentResolver({
builtins: fakeBuiltins,
defaultModel: async () => ({ model: "m-default", provider: "p-default" }),
});
}
describe("InlineAgentResolver", () => {
it("materializes the spec verbatim: inline agentId, instructions as prompt, spec model", async () => {
const resolved = await makeResolver().resolve({
inline: {
name: "researcher",
instructions: "You research things.",
model: { provider: "p1", model: "m1" },
tools: ["web-search"],
},
});
expect(resolved.agentId).toBe("inline:researcher");
expect(resolved.systemPrompt).toBe("You research things.");
expect(resolved.model).toEqual({ provider: "p1", model: "m1" });
expect(resolved.tools).toEqual([
expect.objectContaining({
toolId: "builtin:web-search",
name: "web-search",
execution: "sync",
requiresHuman: false,
}),
]);
});
it("falls back to the app-default model when the spec has none", async () => {
const resolved = await makeResolver().resolve({
inline: { name: "a", instructions: "x", tools: [] },
});
expect(resolved.model).toEqual({ provider: "p-default", model: "m-default" });
});
it("rejects unknown builtin names loudly", async () => {
await expect(
makeResolver().resolve({
inline: { name: "a", instructions: "x", tools: ["nope"] },
}),
).rejects.toThrowError(/unknown builtin tool: nope/);
});
it("strips spawn-agent (depth cap) and skips unavailable tools", async () => {
const resolved = await makeResolver().resolve({
inline: {
name: "a",
instructions: "x",
tools: ["spawn-agent", "flaky-tool", "file-readText"],
},
});
expect(resolved.tools.map((t) => t.name)).toEqual(["file-readText"]);
});
it("default profile is the catalog minus the headless/child exclusions", async () => {
const resolved = await makeResolver().resolve({
inline: { name: "a", instructions: "x" },
});
const names = resolved.tools.map((t) => t.name);
expect(names).toEqual(["web-search", "file-readText"]);
// Excluded by policy, not by absence:
expect(names).not.toContain("executeCommand");
expect(names).not.toContain("spawn-agent");
});
});

View file

@ -0,0 +1,95 @@
import type { z } from "zod";
import {
type InlineAgentRequest,
type ResolvedAgent,
SPAWN_AGENT_TOOL_NAME,
type ToolDescriptor,
inlineAgentId,
} from "@x/shared/dist/turns.js";
import { ResolvedAgent as ResolvedAgentSchema } from "@x/shared/dist/turns.js";
import { BuiltinTools } from "../../application/lib/builtin-tools.js";
import { getDefaultModelAndProvider } from "../../models/defaults.js";
import { builtinToolDescriptor } from "./builtin-descriptors.js";
// Default tool profile for inline agents that omit `tools`: every builtin
// except the ones that make no sense headlessly or in a child. Mirrors the
// background-task agent's exclusions (no interactive approval surface) plus
// the task/session launchers — an ephemeral child should do its own work,
// not schedule more.
const DEFAULT_PROFILE_EXCLUDED = new Set([
"executeCommand", // headless: no interactive approval
"code_agent_run", // headless: needs interactive permission UI
"launch-code-task",
"run-background-task-agent",
"create-background-task",
"patch-background-task",
"run-live-note-agent",
SPAWN_AGENT_TOOL_NAME,
]);
export interface InlineAgentResolverDeps {
builtins?: typeof BuiltinTools;
defaultModel?: () => Promise<{ model: string; provider: string }>;
}
// Materializes an inline (spawned) agent definition into the same immutable
// ResolvedAgent snapshot a stored agent gets. Deliberately knows nothing
// about loadAgent, composition overrides, or copilot context — an inline
// agent is exactly its persisted spec.
export class InlineAgentResolver {
private readonly builtins: typeof BuiltinTools;
private readonly defaultModel: () => Promise<{ model: string; provider: string }>;
constructor(deps: InlineAgentResolverDeps = {}) {
this.builtins = deps.builtins ?? BuiltinTools;
this.defaultModel = deps.defaultModel ?? getDefaultModelAndProvider;
}
async resolve(
requested: z.infer<typeof InlineAgentRequest>,
): Promise<z.infer<typeof ResolvedAgent>> {
const spec = requested.inline;
let model = spec.model;
if (!model) {
const fallback = await this.defaultModel();
model = { provider: fallback.provider, model: fallback.model };
}
const names =
spec.tools ??
Object.keys(this.builtins).filter(
(name) => !DEFAULT_PROFILE_EXCLUDED.has(name),
);
const tools: Array<z.infer<typeof ToolDescriptor>> = [];
for (const name of names) {
// Depth is capped at 1: a child never spawns, even when its spec
// naively asks for the tool. Stripped rather than rejected so a
// model that includes it out of habit still gets a working agent.
if (name === SPAWN_AGENT_TOOL_NAME) {
continue;
}
const builtin = this.builtins[name];
if (!builtin) {
// A typo'd explicit selection should fail the spawn loudly
// (createTurn rejects; the spawn tool reports it), not run a
// silently under-tooled agent.
throw new Error(
`inline agent requested unknown builtin tool: ${name}`,
);
}
if (builtin.isAvailable && !(await builtin.isAvailable())) {
continue;
}
tools.push(builtinToolDescriptor(name, builtin));
}
return ResolvedAgentSchema.parse({
agentId: inlineAgentId(spec.name),
systemPrompt: spec.instructions,
model,
tools,
});
}
}

View file

@ -31,6 +31,11 @@ const fakeBuiltins = {
execute: async () => null,
isAvailable: async () => false,
},
"spawn-agent": {
description: "Spawn a sub-agent",
inputSchema: z.object({ task: z.string() }),
execute: async () => null,
},
} as unknown as typeof BuiltinTools;
function makeResolver(agent: z.infer<typeof Agent>, deps: Partial<ConstructorParameters<typeof RealAgentResolver>[0]> = {}) {
@ -172,6 +177,26 @@ describe("RealAgentResolver", () => {
expect(b.systemPrompt).toBe(a.systemPrompt);
});
it("strips spawn-agent from by-id children (subagent composition flag)", async () => {
const agent = makeAgent({
tools: {
"spawn-agent": { type: "builtin", name: "spawn-agent" },
"file-list": { type: "builtin", name: "file-list" },
},
});
const asParent = await makeResolver(agent).resolve({ agentId: "copilot" });
expect(asParent.tools.map((t) => t.name)).toEqual([
"spawn-agent",
"file-list",
]);
const asChild = await makeResolver(agent).resolve({
agentId: "copilot",
overrides: { composition: { subagent: true } },
});
expect(asChild.tools.map((t) => t.name)).toEqual(["file-list"]);
});
it("does not load notes/work-dir for non-copilot agents", async () => {
let notesLoaded = 0;
const resolver = makeResolver(makeAgent({ name: "background-task-agent" }), {

View file

@ -1,9 +1,9 @@
import { z } from "zod";
import type { Agent } from "@x/shared/dist/agent.js";
import {
type JsonValue,
RequestedAgent,
AgentByIdRequest,
ResolvedAgent,
SPAWN_AGENT_TOOL_NAME,
type ToolDescriptor,
} from "@x/shared/dist/turns.js";
import {
@ -14,7 +14,10 @@ import {
} from "../../agents/runtime.js";
import { BuiltinTools } from "../../application/lib/builtin-tools.js";
import { getDefaultModelAndProvider } from "../../models/defaults.js";
import type { IAgentResolver } from "../agent-resolver.js";
import {
builtinToolDescriptor,
toJsonValue,
} from "./builtin-descriptors.js";
export const ASK_HUMAN_TOOL = "ask-human";
@ -56,6 +59,9 @@ const CompositionOverrides = z.object({
codeCwd: z.string().nullable().optional(),
videoMode: z.boolean().optional(),
coachMode: z.boolean().optional(),
// Set by spawn-agent for by-id children: strips the spawn tool so depth
// is capped at 1 regardless of which stored agent is spawned.
subagent: z.boolean().optional(),
});
export interface RealAgentResolverDeps {
@ -69,8 +75,10 @@ export interface RealAgentResolverDeps {
// Bridges the existing agent system (loadAgent + dynamic builders, the
// BuiltinTools catalog, MCP attachments) to the immutable ResolvedAgent
// snapshot. The composed system prompt is byte-identical to the old
// runtime's streamAgent assembly for the same inputs.
export class RealAgentResolver implements IAgentResolver {
// runtime's streamAgent assembly for the same inputs. Resolves only the
// by-id RequestedAgent variant; inline agents go through
// InlineAgentResolver via DispatchingAgentResolver.
export class RealAgentResolver {
private readonly load: typeof loadAgent;
private readonly builtins: typeof BuiltinTools;
private readonly defaultModel: () => Promise<{ model: string; provider: string }>;
@ -86,7 +94,7 @@ export class RealAgentResolver implements IAgentResolver {
}
async resolve(
requested: z.infer<typeof RequestedAgent>,
requested: z.infer<typeof AgentByIdRequest>,
): Promise<z.infer<typeof ResolvedAgent>> {
const agent = await this.load(requested.agentId);
if (!agent) {
@ -127,7 +135,9 @@ export class RealAgentResolver implements IAgentResolver {
coachMode: composition.coachMode ?? false,
});
const tools = await this.resolveTools(agent);
const tools = await this.resolveTools(agent, {
subagent: composition.subagent ?? false,
});
return ResolvedAgent.parse({
agentId: requested.agentId,
systemPrompt,
@ -138,6 +148,7 @@ export class RealAgentResolver implements IAgentResolver {
private async resolveTools(
agent: z.infer<typeof Agent>,
options: { subagent: boolean },
): Promise<Array<z.infer<typeof ToolDescriptor>>> {
const tools: Array<z.infer<typeof ToolDescriptor>> = [];
for (const [name, attachment] of Object.entries(agent.tools ?? {})) {
@ -161,6 +172,14 @@ export class RealAgentResolver implements IAgentResolver {
tools.push(ASK_HUMAN_DESCRIPTOR);
continue;
}
// Depth cap: a spawned child never spawns, whichever stored
// agent it happens to be.
if (
options.subagent &&
attachment.name === SPAWN_AGENT_TOOL_NAME
) {
continue;
}
const builtin = this.builtins[attachment.name];
if (!builtin) {
continue;
@ -169,34 +188,10 @@ export class RealAgentResolver implements IAgentResolver {
continue;
}
tools.push({
toolId: `builtin:${attachment.name}`,
...builtinToolDescriptor(attachment.name, builtin),
name,
description: builtin.description,
inputSchema: toJsonSchema(builtin.inputSchema),
execution: "sync",
requiresHuman: false,
});
}
return tools;
}
}
function toJsonSchema(schema: unknown): JsonValue {
try {
return toJsonValue(z.toJSONSchema(schema as z.ZodType)) ?? {
type: "object",
properties: {},
};
} catch {
// An exotic zod schema must not break the whole turn.
return { type: "object", properties: {} };
}
}
function toJsonValue(value: unknown): JsonValue | undefined {
try {
return JSON.parse(JSON.stringify(value)) as JsonValue;
} catch {
return undefined;
}
}

View file

@ -1,7 +1,11 @@
import type { ChildProcess } from "child_process";
import type { z } from "zod";
import type { ToolAttachment } from "@x/shared/dist/agent.js";
import type { JsonValue, ToolDescriptor } from "@x/shared/dist/turns.js";
import {
type JsonValue,
SPAWN_AGENT_TOOL_NAME,
type ToolDescriptor,
} from "@x/shared/dist/turns.js";
import { execTool } from "../../application/lib/exec-tool.js";
import { BuiltinTools } from "../../application/lib/builtin-tools.js";
import {
@ -89,6 +93,28 @@ export class RealToolRegistry implements IToolRegistry {
>,
};
}
if (descriptor.toolId === `builtin:${SPAWN_AGENT_TOOL_NAME}`) {
// The dedicated agent-tool handler (turn-runtime-design §29.2):
// runs the child as a standalone headless turn and records the
// parent→child link as durable tool progress. Bypasses execTool
// so it gets the runtime's reportProgress channel.
return {
descriptor: descriptor as { execution: "sync" } & z.infer<
typeof ToolDescriptor
>,
execute: async (input, ctx: ToolExecutionContext) => {
const { runSpawnedAgent } = await import(
"../../agents/spawn-agent.js"
);
return runSpawnedAgent(input, {
parentTurnId: ctx.turnId,
signal: ctx.signal,
onChildStarted: (info) =>
ctx.reportProgress({ kind: "subagent", ...info }),
});
},
};
}
if (descriptor.toolId.startsWith("builtin:")) {
const name = descriptor.toolId.slice("builtin:".length);
const builtin = this.builtins[name];

View file

@ -27,7 +27,7 @@ export const ModelDescriptor = z.object({
model: z.string(),
});
export const RequestedAgent = z.object({
export const AgentByIdRequest = z.object({
agentId: z.string(),
overrides: z
.object({
@ -42,6 +42,42 @@ export const RequestedAgent = z.object({
.optional(),
});
// An agent constructed at request time (sub-agents spawned by a parent turn).
// Persisted verbatim in turn_created.agent.requested, so the definition
// self-documents in the turn file; the resolver materializes it into the same
// immutable ResolvedAgent snapshot as a stored agent.
export const InlineAgentSpec = z.object({
name: z.string(),
instructions: z.string(),
model: ModelDescriptor.optional(),
// Builtin tool names; resolution validates against the live catalog and
// substitutes the default headless profile when omitted.
tools: z.array(z.string()).optional(),
});
export const InlineAgentRequest = z.object({
inline: InlineAgentSpec,
});
// The builtin that spawns sub-agent turns. Named here (not in core) because
// resolvers, the tool registry, and the renderer's card dispatch all key on
// it, and children must never receive it (depth is capped at 1).
export const SPAWN_AGENT_TOOL_NAME = "spawn-agent";
// The ResolvedAgent.agentId convention for inline agents; also what sessions
// denormalize into their index for inline-agent turns.
export function inlineAgentId(name: string): string {
return `inline:${name}`;
}
export const RequestedAgent = z.union([AgentByIdRequest, InlineAgentRequest]);
export function isInlineAgentRequest(
requested: z.infer<typeof RequestedAgent>,
): requested is z.infer<typeof InlineAgentRequest> {
return "inline" in requested;
}
export const ToolDescriptor = z.object({
toolId: z.string(),
name: z.string(),