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

@ -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(),