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 { EmailView } from '@/components/email-view';
import { WorkspaceView } from '@/components/workspace-view'; import { WorkspaceView } from '@/components/workspace-view';
import { CodingRunBlock } from '@/components/coding-run'; import { CodingRunBlock } from '@/components/coding-run';
import { SubAgentBlock } from '@/components/sub-agent-block';
import { KnowledgeView, type KnowledgeViewMode } from '@/components/knowledge-view'; import { KnowledgeView, type KnowledgeViewMode } from '@/components/knowledge-view';
import { GoogleDocPickerDialog } from '@/components/google-doc-picker-dialog'; import { GoogleDocPickerDialog } from '@/components/google-doc-picker-dialog';
import { ChatHistoryView } from '@/components/chat-history-view'; 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) const appActionData = getAppActionCardData(item)
if (appActionData) { if (appActionData) {
return <AppActionCard key={item.id} data={appActionData} status={item.status} /> 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. // code_agent_run only: structured ACP stream items + the in-flight permission ask.
codeRunEvents?: CodeRunEvent[] codeRunEvents?: CodeRunEvent[]
pendingCodePermission?: { requestId: string; ask: PermissionAsk } | null 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 { export interface ErrorMessage {
@ -668,6 +670,7 @@ export const isToolGroup = (item: GroupedConversationItem): item is ToolGroup =>
const isPlainToolCall = (item: ConversationItem): item is ToolCall => { const isPlainToolCall = (item: ConversationItem): item is ToolCall => {
if (!isToolCall(item)) return false if (!isToolCall(item)) return false
if (item.name === 'code_agent_run') return false // rich standalone block, never grouped 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 (getWebSearchCardData(item)) return false
if (getComposioConnectCardData(item)) return false if (getComposioConnectCardData(item)) return false
if (getAppActionCardData(item)) return false if (getAppActionCardData(item)) return false

View file

@ -309,6 +309,49 @@ describe('buildTurnConversation', () => {
expect(settled.status).toBe('completed') 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', () => { it('renders user attachments and a failed turn as an error item', () => {
const input = { const input = {
role: 'user' as const, 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 // One turn's contribution to the conversation: the user input, then per
// completed model call its text and tool calls (with live status/results). // completed model call its text and tool calls (with live status/results).
export function buildTurnConversation(state: TurnState): ConversationItem[] { export function buildTurnConversation(state: TurnState): ConversationItem[] {
@ -295,6 +322,7 @@ export function buildTurnConversation(state: TurnState): ConversationItem[] {
status: tc ? toolStatus(tc) : 'running', status: tc ? toolStatus(tc) : 'running',
timestamp: ts(), timestamp: ts(),
...(tc ? codeRunViewOf(tc) : {}), ...(tc ? codeRunViewOf(tc) : {}),
...(tc ? subAgentViewOf(tc) : {}),
} satisfies ToolCall) } 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 parent tool result. No subflow or parent-turn concept is added to this initial
turn schema. 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 ### 29.3 Reliability enhancements
- External-input idempotency. - External-input idempotency.

View file

@ -1,6 +1,7 @@
import type { z } from "zod"; import type { z } from "zod";
import type { AssistantMessage } from "@x/shared/dist/message.js"; import type { AssistantMessage } from "@x/shared/dist/message.js";
import { import {
type RequestedAgent,
reduceTurn, reduceTurn,
type TurnState, type TurnState,
} from "@x/shared/dist/turns.js"; } from "@x/shared/dist/turns.js";
@ -26,7 +27,10 @@ export class HeadlessRunError extends Error {
} }
export interface HeadlessAgentOptions { 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; message: string;
// Model id; when set without provider, the app-default provider applies. // Model id; when set without provider, the app-default provider applies.
model?: string; model?: string;
@ -121,22 +125,29 @@ export class HeadlessAgentRunner implements IHeadlessAgentRunner {
} }
async start(options: HeadlessAgentOptions): Promise<HeadlessAgentHandle> { async start(options: HeadlessAgentOptions): Promise<HeadlessAgentHandle> {
let modelOverride: { provider: string; model: string } | undefined; let agent = options.agent;
if (options.model && options.provider) { if (!agent) {
modelOverride = { provider: options.provider, model: options.model }; if (!options.agentId) {
} else if (options.model || options.provider) { throw new Error("headless agent needs an agentId or an agent request");
const defaults = await this.defaultModelResolver.resolve(); }
modelOverride = { let modelOverride: { provider: string; model: string } | undefined;
provider: options.provider ?? defaults.provider, if (options.model && options.provider) {
model: options.model ?? defaults.model, 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({ const turnId = await this.turnRuntime.createTurn({
agent: { agent,
agentId: options.agentId,
...(modelOverride ? { overrides: { model: modelOverride } } : {}),
},
sessionId: null, sessionId: null,
context: [], context: [],
input: { role: "user", content: options.message }, 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 { 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 { CURATED_TOOLKITS, CURATED_TOOLKIT_SLUGS } from "@x/shared/dist/composio.js";
import { RowboatAppManifestSchema } from "@x/shared/dist/rowboat-app.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 { BrowserControlInputSchema, type BrowserControlInput } from "@x/shared/dist/browser-control.js";
import { BackgroundTaskSchema, TriggersSchema } from "@x/shared/dist/background-task.js"; import { BackgroundTaskSchema, TriggersSchema } from "@x/shared/dist/background-task.js";
import type { CodeModeManager } from "../../code-mode/acp/manager.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 { IToolRegistry } from "../turns/tool-registry.js";
import type { IPermissionChecker, IPermissionClassifier } from "../turns/permission.js"; import type { IPermissionChecker, IPermissionClassifier } from "../turns/permission.js";
import { RealAgentResolver } from "../turns/bridges/real-agent-resolver.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 { RealModelRegistry } from "../turns/bridges/real-model-registry.js";
import { RealToolRegistry } from "../turns/bridges/real-tool-registry.js"; import { RealToolRegistry } from "../turns/bridges/real-tool-registry.js";
import { RealPermissionChecker } from "../turns/bridges/real-permission-checker.js"; import { RealPermissionChecker } from "../turns/bridges/real-permission-checker.js";
@ -123,7 +125,13 @@ container.register({
).singleton(), ).singleton(),
lifecycleBus: asClass<ITurnLifecycleBus>(EmitterTurnLifecycleBus).singleton(), lifecycleBus: asClass<ITurnLifecycleBus>(EmitterTurnLifecycleBus).singleton(),
usageReporter: asClass<IUsageReporter>(RealUsageReporter).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(), modelRegistry: asFunction<IModelRegistry>(() => new RealModelRegistry()).singleton(),
toolRegistry: asFunction<IToolRegistry>(() => new RealToolRegistry()).singleton(), toolRegistry: asFunction<IToolRegistry>(() => new RealToolRegistry()).singleton(),
permissionChecker: asFunction<IPermissionChecker>(() => new RealPermissionChecker()).singleton(), permissionChecker: asFunction<IPermissionChecker>(() => new RealPermissionChecker()).singleton(),

View file

@ -14,6 +14,8 @@ import {
type ModelDescriptor, type ModelDescriptor,
type ToolResultData, type ToolResultData,
deriveTurnStatus, deriveTurnStatus,
inlineAgentId,
isInlineAgentRequest,
reduceTurn, reduceTurn,
} from "@x/shared/dist/turns.js"; } from "@x/shared/dist/turns.js";
import type { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js"; import type { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
@ -189,7 +191,9 @@ export class SessionsImpl implements ISessions {
ts: this.clock.now(), ts: this.clock.now(),
turnId, turnId,
sessionSeq: state.turns.length + 1, 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), 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, execute: async () => null,
isAvailable: async () => false, isAvailable: async () => false,
}, },
"spawn-agent": {
description: "Spawn a sub-agent",
inputSchema: z.object({ task: z.string() }),
execute: async () => null,
},
} as unknown as typeof BuiltinTools; } as unknown as typeof BuiltinTools;
function makeResolver(agent: z.infer<typeof Agent>, deps: Partial<ConstructorParameters<typeof RealAgentResolver>[0]> = {}) { 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); 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 () => { it("does not load notes/work-dir for non-copilot agents", async () => {
let notesLoaded = 0; let notesLoaded = 0;
const resolver = makeResolver(makeAgent({ name: "background-task-agent" }), { const resolver = makeResolver(makeAgent({ name: "background-task-agent" }), {

View file

@ -1,9 +1,9 @@
import { z } from "zod"; import { z } from "zod";
import type { Agent } from "@x/shared/dist/agent.js"; import type { Agent } from "@x/shared/dist/agent.js";
import { import {
type JsonValue, AgentByIdRequest,
RequestedAgent,
ResolvedAgent, ResolvedAgent,
SPAWN_AGENT_TOOL_NAME,
type ToolDescriptor, type ToolDescriptor,
} from "@x/shared/dist/turns.js"; } from "@x/shared/dist/turns.js";
import { import {
@ -14,7 +14,10 @@ import {
} from "../../agents/runtime.js"; } from "../../agents/runtime.js";
import { BuiltinTools } from "../../application/lib/builtin-tools.js"; import { BuiltinTools } from "../../application/lib/builtin-tools.js";
import { getDefaultModelAndProvider } from "../../models/defaults.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"; export const ASK_HUMAN_TOOL = "ask-human";
@ -56,6 +59,9 @@ const CompositionOverrides = z.object({
codeCwd: z.string().nullable().optional(), codeCwd: z.string().nullable().optional(),
videoMode: z.boolean().optional(), videoMode: z.boolean().optional(),
coachMode: 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 { export interface RealAgentResolverDeps {
@ -69,8 +75,10 @@ export interface RealAgentResolverDeps {
// Bridges the existing agent system (loadAgent + dynamic builders, the // Bridges the existing agent system (loadAgent + dynamic builders, the
// BuiltinTools catalog, MCP attachments) to the immutable ResolvedAgent // BuiltinTools catalog, MCP attachments) to the immutable ResolvedAgent
// snapshot. The composed system prompt is byte-identical to the old // snapshot. The composed system prompt is byte-identical to the old
// runtime's streamAgent assembly for the same inputs. // runtime's streamAgent assembly for the same inputs. Resolves only the
export class RealAgentResolver implements IAgentResolver { // by-id RequestedAgent variant; inline agents go through
// InlineAgentResolver via DispatchingAgentResolver.
export class RealAgentResolver {
private readonly load: typeof loadAgent; private readonly load: typeof loadAgent;
private readonly builtins: typeof BuiltinTools; private readonly builtins: typeof BuiltinTools;
private readonly defaultModel: () => Promise<{ model: string; provider: string }>; private readonly defaultModel: () => Promise<{ model: string; provider: string }>;
@ -86,7 +94,7 @@ export class RealAgentResolver implements IAgentResolver {
} }
async resolve( async resolve(
requested: z.infer<typeof RequestedAgent>, requested: z.infer<typeof AgentByIdRequest>,
): Promise<z.infer<typeof ResolvedAgent>> { ): Promise<z.infer<typeof ResolvedAgent>> {
const agent = await this.load(requested.agentId); const agent = await this.load(requested.agentId);
if (!agent) { if (!agent) {
@ -127,7 +135,9 @@ export class RealAgentResolver implements IAgentResolver {
coachMode: composition.coachMode ?? false, coachMode: composition.coachMode ?? false,
}); });
const tools = await this.resolveTools(agent); const tools = await this.resolveTools(agent, {
subagent: composition.subagent ?? false,
});
return ResolvedAgent.parse({ return ResolvedAgent.parse({
agentId: requested.agentId, agentId: requested.agentId,
systemPrompt, systemPrompt,
@ -138,6 +148,7 @@ export class RealAgentResolver implements IAgentResolver {
private async resolveTools( private async resolveTools(
agent: z.infer<typeof Agent>, agent: z.infer<typeof Agent>,
options: { subagent: boolean },
): Promise<Array<z.infer<typeof ToolDescriptor>>> { ): Promise<Array<z.infer<typeof ToolDescriptor>>> {
const tools: Array<z.infer<typeof ToolDescriptor>> = []; const tools: Array<z.infer<typeof ToolDescriptor>> = [];
for (const [name, attachment] of Object.entries(agent.tools ?? {})) { for (const [name, attachment] of Object.entries(agent.tools ?? {})) {
@ -161,6 +172,14 @@ export class RealAgentResolver implements IAgentResolver {
tools.push(ASK_HUMAN_DESCRIPTOR); tools.push(ASK_HUMAN_DESCRIPTOR);
continue; 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]; const builtin = this.builtins[attachment.name];
if (!builtin) { if (!builtin) {
continue; continue;
@ -169,34 +188,10 @@ export class RealAgentResolver implements IAgentResolver {
continue; continue;
} }
tools.push({ tools.push({
toolId: `builtin:${attachment.name}`, ...builtinToolDescriptor(attachment.name, builtin),
name, name,
description: builtin.description,
inputSchema: toJsonSchema(builtin.inputSchema),
execution: "sync",
requiresHuman: false,
}); });
} }
return tools; 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 { ChildProcess } from "child_process";
import type { z } from "zod"; import type { z } from "zod";
import type { ToolAttachment } from "@x/shared/dist/agent.js"; 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 { execTool } from "../../application/lib/exec-tool.js";
import { BuiltinTools } from "../../application/lib/builtin-tools.js"; import { BuiltinTools } from "../../application/lib/builtin-tools.js";
import { 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:")) { if (descriptor.toolId.startsWith("builtin:")) {
const name = descriptor.toolId.slice("builtin:".length); const name = descriptor.toolId.slice("builtin:".length);
const builtin = this.builtins[name]; const builtin = this.builtins[name];

View file

@ -27,7 +27,7 @@ export const ModelDescriptor = z.object({
model: z.string(), model: z.string(),
}); });
export const RequestedAgent = z.object({ export const AgentByIdRequest = z.object({
agentId: z.string(), agentId: z.string(),
overrides: z overrides: z
.object({ .object({
@ -42,6 +42,42 @@ export const RequestedAgent = z.object({
.optional(), .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({ export const ToolDescriptor = z.object({
toolId: z.string(), toolId: z.string(),
name: z.string(), name: z.string(),