diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 8963a5cb..2952b7d0 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -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 ( + setToolOpenForTab(tabId, item.id, open)} + /> + ) + } const appActionData = getAppActionCardData(item) if (appActionData) { return diff --git a/apps/x/apps/renderer/src/components/sub-agent-block.tsx b/apps/x/apps/renderer/src/components/sub-agent-block.tsx new file mode 100644 index 00000000..14deb928 --- /dev/null +++ b/apps/x/apps/renderer/src/components/sub-agent-block.tsx @@ -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(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 ( + + + +
+ {task && ( +
+ + {task} +
+ )} + {transcript ? ( + + ) : ( +
+ {item.subAgent + ? 'Loading sub-agent transcript…' + : running + ? 'Starting sub-agent…' + : 'No sub-agent transcript was recorded.'} +
+ )} +
+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts index 4e2db5de..ff3206a1 100644 --- a/apps/x/apps/renderer/src/lib/chat-conversation.ts +++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts @@ -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 diff --git a/apps/x/apps/renderer/src/lib/session-chat/turn-view.test.ts b/apps/x/apps/renderer/src/lib/session-chat/turn-view.test.ts index 8bcf8c21..d4fc78a6 100644 --- a/apps/x/apps/renderer/src/lib/session-chat/turn-view.test.ts +++ b/apps/x/apps/renderer/src/lib/session-chat/turn-view.test.ts @@ -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, diff --git a/apps/x/apps/renderer/src/lib/session-chat/turn-view.ts b/apps/x/apps/renderer/src/lib/session-chat/turn-view.ts index 8db36e6a..cfef1249 100644 --- a/apps/x/apps/renderer/src/lib/session-chat/turn-view.ts +++ b/apps/x/apps/renderer/src/lib/session-chat/turn-view.ts @@ -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 { + 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) } } diff --git a/apps/x/packages/core/docs/turn-runtime-design.md b/apps/x/packages/core/docs/turn-runtime-design.md index 1c61a7b8..f4169b64 100644 --- a/apps/x/packages/core/docs/turn-runtime-design.md +++ b/apps/x/packages/core/docs/turn-runtime-design.md @@ -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. diff --git a/apps/x/packages/core/src/agents/headless.ts b/apps/x/packages/core/src/agents/headless.ts index d974a5fd..0f17eae4 100644 --- a/apps/x/packages/core/src/agents/headless.ts +++ b/apps/x/packages/core/src/agents/headless.ts @@ -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; 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 { - 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 }, diff --git a/apps/x/packages/core/src/agents/spawn-agent.test.ts b/apps/x/packages/core/src/agents/spawn-agent.test.ts new file mode 100644 index 00000000..55e093f7 --- /dev/null +++ b/apps/x/packages/core/src/agents/spawn-agent.test.ts @@ -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 extends never ? never : unknown; + } & Record = {}, +): Array> { + 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, + ]; +} + +function fakeServices(opts: { + parentEvents?: Array>; + childResult?: Partial; + 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 => { + 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", + }); + }); +}); diff --git a/apps/x/packages/core/src/agents/spawn-agent.ts b/apps/x/packages/core/src/agents/spawn-agent.ts new file mode 100644 index 00000000..4466e62c --- /dev/null +++ b/apps/x/packages/core/src/agents/spawn-agent.ts @@ -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; + // 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> { + 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 | 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 | 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 = 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>; + 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 +> { + const { default: container } = await import("../di/container.js"); + return { + turnRuntime: + container.resolve( + "turnRuntime", + ), + headlessRunner: container.resolve< + import("./headless.js").IHeadlessAgentRunner + >("headlessAgentRunner"), + }; +} + +function spawnError(message: string): z.infer { + 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, +): 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); +} diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index edd04270..0190c309 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -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 = { } }, }, + + [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; + }, + }, }; diff --git a/apps/x/packages/core/src/di/container.ts b/apps/x/packages/core/src/di/container.ts index 306ebc33..2ba9ec84 100644 --- a/apps/x/packages/core/src/di/container.ts +++ b/apps/x/packages/core/src/di/container.ts @@ -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(EmitterTurnLifecycleBus).singleton(), usageReporter: asClass(RealUsageReporter).singleton(), - agentResolver: asFunction(() => new RealAgentResolver()).singleton(), + agentResolver: asFunction( + () => + new DispatchingAgentResolver( + new RealAgentResolver(), + new InlineAgentResolver(), + ), + ).singleton(), modelRegistry: asFunction(() => new RealModelRegistry()).singleton(), toolRegistry: asFunction(() => new RealToolRegistry()).singleton(), permissionChecker: asFunction(() => new RealPermissionChecker()).singleton(), diff --git a/apps/x/packages/core/src/sessions/sessions.ts b/apps/x/packages/core/src/sessions/sessions.ts index e4c262db..e996be14 100644 --- a/apps/x/packages/core/src/sessions/sessions.ts +++ b/apps/x/packages/core/src/sessions/sessions.ts @@ -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), }, ]; diff --git a/apps/x/packages/core/src/turns/bridges/agent-resolver-dispatch.test.ts b/apps/x/packages/core/src/turns/bridges/agent-resolver-dispatch.test.ts new file mode 100644 index 00000000..4b24d62d --- /dev/null +++ b/apps/x/packages/core/src/turns/bridges/agent-resolver-dispatch.test.ts @@ -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, + }; +} + +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"); + }); +}); diff --git a/apps/x/packages/core/src/turns/bridges/agent-resolver-dispatch.ts b/apps/x/packages/core/src/turns/bridges/agent-resolver-dispatch.ts new file mode 100644 index 00000000..95b4f6e4 --- /dev/null +++ b/apps/x/packages/core/src/turns/bridges/agent-resolver-dispatch.ts @@ -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, + ): Promise> { + return isInlineAgentRequest(requested) + ? this.inline.resolve(requested) + : this.byId.resolve(requested); + } +} diff --git a/apps/x/packages/core/src/turns/bridges/builtin-descriptors.ts b/apps/x/packages/core/src/turns/bridges/builtin-descriptors.ts new file mode 100644 index 00000000..1c6cc153 --- /dev/null +++ b/apps/x/packages/core/src/turns/bridges/builtin-descriptors.ts @@ -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 { + 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; + } +} diff --git a/apps/x/packages/core/src/turns/bridges/inline-agent-resolver.test.ts b/apps/x/packages/core/src/turns/bridges/inline-agent-resolver.test.ts new file mode 100644 index 00000000..ff69be97 --- /dev/null +++ b/apps/x/packages/core/src/turns/bridges/inline-agent-resolver.test.ts @@ -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"); + }); +}); diff --git a/apps/x/packages/core/src/turns/bridges/inline-agent-resolver.ts b/apps/x/packages/core/src/turns/bridges/inline-agent-resolver.ts new file mode 100644 index 00000000..fc2ae4e9 --- /dev/null +++ b/apps/x/packages/core/src/turns/bridges/inline-agent-resolver.ts @@ -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, + ): Promise> { + 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> = []; + 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, + }); + } +} diff --git a/apps/x/packages/core/src/turns/bridges/real-agent-resolver.test.ts b/apps/x/packages/core/src/turns/bridges/real-agent-resolver.test.ts index 974d7502..8bbcbf60 100644 --- a/apps/x/packages/core/src/turns/bridges/real-agent-resolver.test.ts +++ b/apps/x/packages/core/src/turns/bridges/real-agent-resolver.test.ts @@ -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, deps: Partial[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" }), { diff --git a/apps/x/packages/core/src/turns/bridges/real-agent-resolver.ts b/apps/x/packages/core/src/turns/bridges/real-agent-resolver.ts index 8f2ce1b0..674a5b48 100644 --- a/apps/x/packages/core/src/turns/bridges/real-agent-resolver.ts +++ b/apps/x/packages/core/src/turns/bridges/real-agent-resolver.ts @@ -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, + requested: z.infer, ): Promise> { 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, + options: { subagent: boolean }, ): Promise>> { const tools: Array> = []; 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; - } -} diff --git a/apps/x/packages/core/src/turns/bridges/real-tool-registry.ts b/apps/x/packages/core/src/turns/bridges/real-tool-registry.ts index d0048161..c3eb6214 100644 --- a/apps/x/packages/core/src/turns/bridges/real-tool-registry.ts +++ b/apps/x/packages/core/src/turns/bridges/real-tool-registry.ts @@ -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]; diff --git a/apps/x/packages/shared/src/turns.ts b/apps/x/packages/shared/src/turns.ts index df9a65f4..cb565246 100644 --- a/apps/x/packages/shared/src/turns.ts +++ b/apps/x/packages/shared/src/turns.ts @@ -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, +): requested is z.infer { + return "inline" in requested; +} + export const ToolDescriptor = z.object({ toolId: z.string(), name: z.string(),