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..71a81a41 --- /dev/null +++ b/apps/x/apps/renderer/src/components/sub-agent-block.tsx @@ -0,0 +1,94 @@ +import { useEffect, useState } from '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 +} + +// "london-weather" / "meeting_prep" → "London weather" / "Meeting prep". +function humanizeName(name: string): string { + const words = name.replace(/[-_]+/g, ' ').trim() + return words ? words.charAt(0).toUpperCase() + words.slice(1) : '' +} + +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 rawName = item.subAgent?.agentName || input?.agent_id || input?.name || '' + const name = rawName === 'subagent' ? '' : humanizeName(rawName) + const task = (item.subAgent?.task || input?.task || '').trim() + // The collapsed row must say what is happening, not just that an agent + // exists: lead with the name when the model gave one, then the task (the + // header truncates with a hover tooltip carrying the full text). + const title = task ? `${name || 'Agent'}: ${task}` : name || 'Agent' + const running = item.status === 'pending' || item.status === 'running' + const transcript = useChildTranscript(item.subAgent?.childTurnId, running, open) + + return ( + + + +
+ {transcript ? ( + // The transcript opens with the child's user message — the task — + // so no separate task chip is rendered here. + + ) : ( +
+ {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 2b0e54fa..f4169b64 100644 --- a/apps/x/packages/core/docs/turn-runtime-design.md +++ b/apps/x/packages/core/docs/turn-runtime-design.md @@ -154,9 +154,18 @@ semantics after ambiguous interruptions. Tool calls may complete in any order. The next model request must always contain tool results in the original order emitted by the model. -The initial implementation may execute sync tools sequentially for simplicity. -Async tools naturally complete independently. No behavior may rely on physical -completion order. +Sync tools in one batch execute concurrently: invocation events are appended +serially in source order before any execution starts, then all sync executions +run at once, each appending its progress and result as it lands. Result order +in the log is therefore physical completion order and is not deterministic +across runs; any given log still replays deterministically. Async tools +naturally complete independently. No behavior may rely on physical completion +order. + +Durable appends are serialized through a single internal queue per invocation: +the persist → reduce → stream ritual runs to completion for one batch of +events before the next begins, so file order, in-memory order, and stream +order are identical by construction even while executions overlap. ## 5. Storage design @@ -904,7 +913,12 @@ For one completed assistant response: 2. Determine permission requirements. 3. Apply automatic permission decisions when enabled. 4. Advance each tool independently as its permission is resolved. -5. Execute allowed sync tools using a simple sequential policy initially. +5. Record invocations for allowed tools serially in source order (sync and + async alike), then execute all allowed sync tools concurrently. There is + no concurrency cap and no per-tool serialization; tools that share state + must tolerate racing (or reject stale operations, as file edits do via + their search/replace precondition). Secondary kill-path state (the abort + registry) is scoped per tool call, never per turn. 6. Expose allowed async tool requests. 7. Suspend when any permissions or async results remain outstanding. 8. Once all calls have terminal results, build the next model request with @@ -1881,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..fc5b91a3 --- /dev/null +++ b/apps/x/packages/core/src/agents/spawn-agent.test.ts @@ -0,0 +1,234 @@ +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("rejects agent_id and instructions together", async () => { + const { services } = fakeServices({}); + const result = await runSpawnedAgent( + { task: "t", agent_id: "a", instructions: "b" }, + { parentTurnId: "parent-1", signal, services }, + ); + expect(result.isError).toBe(true); + expect(result.output).toMatch(/at most one/); + }); + + it("spawns a default worker when neither agent_id nor instructions is given", async () => { + // Models routinely treat task (+ name/tools) as a complete spec; the + // task-only form must work rather than cost a correction round-trip. + const { services, started } = fakeServices({}); + const result = await runSpawnedAgent( + { task: "find the weather", name: "london-weather", tools: ["web-search"] }, + { parentTurnId: "parent-1", signal, services }, + ); + expect(result.isError).toBe(false); + const agent = started[0].agent as { + inline: { name: string; instructions: string; tools?: string[] }; + }; + expect(agent.inline.name).toBe("london-weather"); + expect(agent.inline.tools).toEqual(["web-search"]); + expect(agent.inline.instructions).toMatch(/london-weather/); + expect(agent.inline.instructions).toMatch(/headlessly/); + }); + + 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..432edaab --- /dev/null +++ b/apps/x/packages/core/src/agents/spawn-agent.ts @@ -0,0 +1,265 @@ +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`. Optional: omitting both `agent_id` and `instructions` spawns a general-purpose worker driven by `task` alone.", + ), + 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 at most 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, + // The task alone is usually a complete spec — models + // routinely omit `instructions` for ad-hoc workers, and + // rejecting that just costs a correction round-trip. + instructions: + input.instructions ?? defaultWorkerInstructions(agentName), + ...(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 }; +} + +function defaultWorkerInstructions(name: string): string { + return ( + `You are ${name}, a sub-agent spawned to complete a single task. ` + + "You run headlessly: no user is available to clarify or approve, and your final message is your only output. " + + "Work autonomously with the tools you have, then end with a complete, self-contained answer to the task." + ); +} + +// 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/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index 9518990c..1b709589 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -149,6 +149,12 @@ ${codeModeEnabled *Medium signals (load the skill, answer the one-off, then offer):* one-off questions about decaying info ("what's the weather?", "top HN stories?"), "what's the latest on X / catch me up on X / any updates on X" about a person, company, project, or topic, recurring artifacts ("morning briefing", "weekly review", "Acme deal dashboard"). **Heuristic:** if you reach for \`web-search\` or a news tool to answer a recurring question, the answer is the kind of thing a bg-task would refresh on a schedule. +**Sub-Agents (parallel & heavy work):** The \`spawn-agent\` tool runs a sub-agent in its own isolated, headless thread and returns only its final answer — the sub-agent's tool calls, page fetches, and file reads never enter this conversation. Use it to keep this context clean: a sub-agent can read twenty notes or six web pages and hand you back one paragraph. Issue several spawn-agent calls in ONE response to run them in parallel. + +*Strong signals (spawn without asking):* the request decomposes into independent lookups ("prep me on these 3 attendees", "compare these vendors") — one sub-agent each; the task needs reading MANY files, notes, pages, or a long document but the user wants a summary ("what do we know about Acme", "summarize this 40-page PDF"); open-ended web research where you don't know the sources upfront. **Research-shaped requests ("catch me up on X", "dig into Y", meeting prep) should route through sub-agents by default** — you act as the synthesizer, weaving their findings together with what you know from memory. + +*Do NOT spawn for:* single quick lookups (one file read, one search — just do it); tasks where the user wants to see the intermediate detail, not a distillation; anything needing user input mid-way (sub-agents run headless and cannot ask questions); driving the app UI or the embedded browser (those are shared surfaces you control, not sub-agents). Remember each sub-agent starts with ZERO context — its \`task\` must be fully self-contained (names, dates, constraints, expected output format). + **Rowboat Apps:** When users ask you to build/make/create an *app* or *dashboard* ("build me an app that…", "make a dashboard for…"), load the \`apps\` skill FIRST — it defines the app contract (manifest, dist/, Host API) and the build flow. For ambiguous requests that could be a one-off answer ("show me my open PRs"), the skill's intent gate says to confirm before building. Do not hand-roll app folders without the skill. **Live Notes:** If the user explicitly says "live note" or "live-note", load the \`live-note\` skill. Otherwise, do not propose live notes — prefer the \`background-task\` skill for anything recurring. 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/background-tasks/agent.ts b/apps/x/packages/core/src/background-tasks/agent.ts index d49f2c68..7016516a 100644 --- a/apps/x/packages/core/src/background-tasks/agent.ts +++ b/apps/x/packages/core/src/background-tasks/agent.ts @@ -53,6 +53,10 @@ Only available when the run message contains a **"# Coding task"** block (the ta - Group related items, then call \`launch-code-task\` once per group (\`taskSlug\` is your own slug). It runs full-auto in an isolated worktree and **owns the \`## Code Sessions\` section of \`index.md\`** — never edit those rows yourself. Write a complete, self-contained \`prompt\`: the coding agent has no other context and no human to ask. - If nothing is actionable, launch nothing and say so in your summary. +# Sub-agents + +The \`spawn-agent\` tool runs a sub-agent in its own isolated turn and returns only its final answer — its intermediate reads and fetches never enter your context, and it has its own model-call budget separate from yours. Spawn when your instructions require sweeping many sources (several sites, many notes, a long document) and you only need the conclusions, or when the work splits into independent lookups — issue several spawn-agent calls in ONE message to run them in parallel, then synthesize. Do not spawn for single quick lookups. Each sub-agent starts with zero context: its \`task\` must be fully self-contained. + # Triggers The run message tells you which trigger fired and how to interpret it: 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..0ec7b586 --- /dev/null +++ b/apps/x/packages/core/src/turns/bridges/inline-agent-resolver.test.ts @@ -0,0 +1,122 @@ +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, + }, + "app-navigation": { + description: "Drive the app UI", + inputSchema: z.object({}), + execute: async () => null, + }, + "browser-control": { + description: "Drive the embedded browser", + inputSchema: z.object({}), + 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"); + // Shared visible surfaces: a headless child must not drive the UI + // the user is watching or the single embedded browser pane. + expect(names).not.toContain("app-navigation"); + expect(names).not.toContain("browser-control"); + }); + + it("shared-surface tools remain available via explicit selection", async () => { + const resolved = await makeResolver().resolve({ + inline: { name: "a", instructions: "x", tools: ["browser-control"] }, + }); + expect(resolved.tools.map((t) => t.name)).toEqual(["browser-control"]); + }); +}); 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..22ba14f0 --- /dev/null +++ b/apps/x/packages/core/src/turns/bridges/inline-agent-resolver.ts @@ -0,0 +1,100 @@ +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 — and the shared visible surfaces: a headless child +// navigating the UI the user is looking at, or parallel children fighting +// over the one embedded browser pane, is broken behavior, not just noise. +// All remain available via an explicit `tools` selection. +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", + "app-navigation", // shared surface: drives the UI the user is watching + "browser-control", // shared surface: the single embedded browser pane + 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.test.ts b/apps/x/packages/core/src/turns/bridges/real-tool-registry.test.ts index 4ccb15a6..c92a4428 100644 --- a/apps/x/packages/core/src/turns/bridges/real-tool-registry.test.ts +++ b/apps/x/packages/core/src/turns/bridges/real-tool-registry.test.ts @@ -20,7 +20,9 @@ class FakeAbortRegistry implements IAbortRegistry { this.calls.push(`create:${runId}`); return new AbortController().signal; } - registerProcess(): void {} + registerProcess(runId: string): void { + this.calls.push(`register:${runId}`); + } unregisterProcess(): void {} abort(runId: string): void { this.calls.push(`abort:${runId}`); @@ -94,8 +96,30 @@ describe("RealToolRegistry", () => { expect(calls[0].attachment).toEqual({ type: "builtin", name: "echo" }); expect(calls[0].input).toEqual({ text: "hi" }); expect(calls[0].ctx).toMatchObject({ runId: "turn-1", toolCallId: "tc-1" }); - // Abort registry bracketed per call. - expect(abortRegistry.calls).toEqual(["create:turn-1", "cleanup:turn-1"]); + // Abort registry bracketed and keyed per tool call (sync tools in a + // turn execute concurrently; a shared turn key would let one call + // tear down its siblings' force-kill scope). + expect(abortRegistry.calls).toEqual([ + "create:turn-1:tc-1", + "cleanup:turn-1:tc-1", + ]); + }); + + it("re-keys registry calls a tool makes with ctx.runId to the call scope", async () => { + // Builtins address the abort registry with ctx.runId (the turn id). + // The scoped wrapper must pin those to the per-call key, or a + // process registered by one tool would land in no scope at all. + const { registry, abortRegistry } = makeRegistry(async ({ ctx }) => { + ctx.abortRegistry.registerProcess(ctx.runId, {} as never); + return "ok"; + }); + const tool = (await registry.resolve(descriptor())) as SyncRuntimeTool; + await tool.execute({}, makeCtx()); + expect(abortRegistry.calls).toEqual([ + "create:turn-1:tc-1", + "register:turn-1:tc-1", + "cleanup:turn-1:tc-1", + ]); }); it("normalizes undefined results to null and serializes objects", async () => { @@ -187,9 +211,9 @@ describe("RealToolRegistry", () => { const tool = (await registry.resolve(descriptor())) as SyncRuntimeTool; await tool.execute({}, makeCtx({ signal: controller.signal })); expect(abortRegistry.calls).toEqual([ - "create:turn-1", - "abort:turn-1", - "cleanup:turn-1", + "create:turn-1:tc-1", + "abort:turn-1:tc-1", + "cleanup:turn-1:tc-1", ]); }); @@ -199,7 +223,10 @@ describe("RealToolRegistry", () => { }); const tool = (await registry.resolve(descriptor())) as SyncRuntimeTool; await expect(tool.execute({}, makeCtx())).rejects.toThrowError("tool exploded"); - expect(abortRegistry.calls).toEqual(["create:turn-1", "cleanup:turn-1"]); + expect(abortRegistry.calls).toEqual([ + "create:turn-1:tc-1", + "cleanup:turn-1:tc-1", + ]); }); it("resolves mcp descriptors into mcp attachments (server:tool split on first colon)", async () => { 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 e891ccb9..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,6 +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 { @@ -22,6 +27,47 @@ export interface RealToolRegistryDeps { builtins?: typeof BuiltinTools; } +// Sync tools within a turn execute concurrently, so abort-registry state must +// be scoped per tool call: createForRun destroys any existing state under its +// key, and cleanup would otherwise tear down the force-kill scope of +// still-running siblings. Builtins address the registry with ctx.runId (the +// turn id, which keeps its meaning elsewhere), so this wrapper pins every +// operation to the call-scoped key regardless of the key the caller passes. +class CallScopedAbortRegistry implements IAbortRegistry { + constructor( + private readonly inner: IAbortRegistry, + private readonly key: string, + ) {} + + createForRun(): AbortSignal { + return this.inner.createForRun(this.key); + } + + registerProcess(_runId: string, process: ChildProcess): void { + this.inner.registerProcess(this.key, process); + } + + unregisterProcess(_runId: string, process: ChildProcess): void { + this.inner.unregisterProcess(this.key, process); + } + + abort(): void { + this.inner.abort(this.key); + } + + forceAbort(): void { + this.inner.forceAbort(this.key); + } + + isAborted(): boolean { + return this.inner.isAborted(this.key); + } + + cleanup(): void { + this.inner.cleanup(this.key); + } +} + // Bridges persisted tool descriptors to the existing dispatch: builtins via // the BuiltinTools catalog, MCP tools via execTool's MCP path. toolId encodes // the attachment: "builtin:" or "mcp::". ask-human is the @@ -47,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]; @@ -89,9 +157,14 @@ export class RealToolRegistry implements IToolRegistry { execute: async (input, ctx: ToolExecutionContext) => { // AbortSignal is the primary kill path; the abort registry is // the secondary force-kill for spawned child processes, - // bracketed per call and keyed by turn. - this.abortRegistry.createForRun(ctx.turnId); - const onAbort = () => this.abortRegistry.abort(ctx.turnId); + // bracketed and keyed per tool call (sync tools in one turn + // run concurrently). + const abortRegistry: IAbortRegistry = new CallScopedAbortRegistry( + this.abortRegistry, + `${ctx.turnId}:${ctx.toolCallId}`, + ); + abortRegistry.createForRun(ctx.turnId); + const onAbort = () => abortRegistry.abort(ctx.turnId); ctx.signal.addEventListener("abort", onAbort, { once: true }); try { const value = await this.execToolImpl( @@ -101,7 +174,7 @@ export class RealToolRegistry implements IToolRegistry { runId: ctx.turnId, toolCallId: ctx.toolCallId, signal: ctx.signal, - abortRegistry: this.abortRegistry, + abortRegistry, publish: async (event) => { if (event.type === "tool-output-stream") { await ctx.reportProgress({ @@ -147,7 +220,7 @@ export class RealToolRegistry implements IToolRegistry { }; } finally { ctx.signal.removeEventListener("abort", onAbort); - this.abortRegistry.cleanup(ctx.turnId); + abortRegistry.cleanup(ctx.turnId); } }, }; diff --git a/apps/x/packages/core/src/turns/runtime.test.ts b/apps/x/packages/core/src/turns/runtime.test.ts index 335250e4..83027868 100644 --- a/apps/x/packages/core/src/turns/runtime.test.ts +++ b/apps/x/packages/core/src/turns/runtime.test.ts @@ -1757,3 +1757,317 @@ describe("tool progress", () => { ]); }); }); + +describe("concurrent sync tool execution (10.5)", () => { + const slowDescriptor: z.infer = { + toolId: "tool.slow", + name: "slow", + description: "Slow tool", + inputSchema: {}, + execution: "sync", + requiresHuman: false, + }; + const fastDescriptor: z.infer = { + toolId: "tool.fast", + name: "fast", + description: "Fast tool", + inputSchema: {}, + execution: "sync", + requiresHuman: false, + }; + const agent: z.infer = { + ...defaultAgent, + tools: [slowDescriptor, fastDescriptor], + }; + + it("executes a batch concurrently: invocations in source order, results in completion order", async () => { + // slow (first in source order) only finishes after fast has fully + // completed AND reported progress. Under the old sequential loop this + // deadlocks (fast never starts), so settling at all proves overlap. + const order: string[] = []; + let releaseSlow!: () => void; + const slowGate = new Promise((resolve) => { + releaseSlow = resolve; + }); + const tools: RuntimeTool[] = [ + syncTool(slowDescriptor, async () => { + order.push("slow:start"); + await slowGate; + order.push("slow:end"); + return { output: "slow-done", isError: false }; + }), + syncTool(fastDescriptor, async (_input, ctx) => { + order.push("fast:start"); + await ctx.reportProgress({ note: "while slow is pending" }); + order.push("fast:end"); + releaseSlow(); + return { output: "fast-done", isError: false }; + }), + ]; + const { runtime, repo, models } = makeRuntime({ + agent, + tools, + models: [ + respond( + completedResp( + assistantCalls( + toolCallPart("S", "slow"), + toolCallPart("F", "fast"), + ), + ), + ), + respond(completedResp(assistantText("done"))), + ], + }); + const turnId = await newTurn(runtime); + const { outcome } = await advanceAndSettle(runtime, turnId); + expect(outcome?.status).toBe("completed"); + expect(order).toEqual(["slow:start", "fast:start", "fast:end", "slow:end"]); + + // The log stays legal under interleaving: both invocations precede + // any result (source order), fast's progress and result land while + // slow is still open, and the reducer accepts the whole history. + const log = await persisted(repo, turnId); + expect(typesOf(log)).toEqual([ + "turn_created", + "model_call_requested", + "model_call_completed", + "tool_invocation_requested", + "tool_invocation_requested", + "tool_progress", + "tool_result", + "tool_result", + "model_call_requested", + "model_call_completed", + "turn_completed", + ]); + const invocations = log.filter( + (e) => e.type === "tool_invocation_requested", + ); + expect(invocations.map((e) => e.toolCallId)).toEqual(["S", "F"]); + const results = log.filter((e) => e.type === "tool_result"); + expect(results.map((e) => e.toolCallId)).toEqual(["F", "S"]); + const state = reduceTurn(log); + expect(state.toolCalls.map((tc) => tc.result?.result.output)).toEqual([ + "slow-done", + "fast-done", + ]); + + // Wire ordering is insulated from completion order: the follow-up + // request references tool results in the assistant message's source + // order, and the composed payload sends them in that order. + const followUp = log.find( + (e) => e.type === "model_call_requested" && e.modelCallIndex === 1, + ); + expect(followUp).toMatchObject({ + request: { + messages: ["assistant:0", "toolResult:S", "toolResult:F"], + }, + }); + const sent = sentMessages(models.requests[1]); + expect( + sent + .filter((m) => m.role === "tool") + .map((m) => (m as { toolCallId?: string }).toolCallId), + ).toEqual(["S", "F"]); + }); + + it("one tool's failure never disturbs its concurrent siblings", async () => { + let releaseSlow!: () => void; + const slowGate = new Promise((resolve) => { + releaseSlow = resolve; + }); + const tools: RuntimeTool[] = [ + syncTool(slowDescriptor, async () => { + await slowGate; + return { output: "slow-done", isError: false }; + }), + syncTool(fastDescriptor, async () => { + releaseSlow(); + throw new Error("fast exploded"); + }), + ]; + const { runtime, repo } = makeRuntime({ + agent, + tools, + models: [ + respond( + completedResp( + assistantCalls( + toolCallPart("S", "slow"), + toolCallPart("F", "fast"), + ), + ), + ), + respond(completedResp(assistantText("done"))), + ], + }); + const turnId = await newTurn(runtime); + const { outcome } = await advanceAndSettle(runtime, turnId); + expect(outcome?.status).toBe("completed"); + const log = await persisted(repo, turnId); + const byId = new Map( + log + .filter((e) => e.type === "tool_result") + .map((e) => [e.toolCallId, e]), + ); + expect(byId.get("F")).toMatchObject({ + result: { output: "fast exploded", isError: true }, + }); + expect(byId.get("S")).toMatchObject({ + result: { output: "slow-done", isError: false }, + }); + }); + + it("cancellation mid-batch settles every in-flight tool", async () => { + const controller = new AbortController(); + const started: string[] = []; + function hangingTool(name: string) { + return async ( + _input: unknown, + ctx: ToolExecutionContext, + ): Promise<{ output: string; isError: boolean }> => { + started.push(name); + if (started.length === 2) { + controller.abort(); + } + await new Promise((resolve) => { + if (ctx.signal.aborted) { + resolve(); + } else { + ctx.signal.addEventListener("abort", () => resolve(), { + once: true, + }); + } + }); + throw new Error("aborted"); + }; + } + const tools: RuntimeTool[] = [ + syncTool(slowDescriptor, hangingTool("slow")), + syncTool(fastDescriptor, hangingTool("fast")), + ]; + const { runtime, repo } = makeRuntime({ + agent, + tools, + models: [ + respond( + completedResp( + assistantCalls( + toolCallPart("S", "slow"), + toolCallPart("F", "fast"), + ), + ), + ), + ], + }); + const turnId = await newTurn(runtime); + const { outcome } = await advanceAndSettle(runtime, turnId, undefined, { + signal: controller.signal, + }); + expect(outcome?.status).toBe("cancelled"); + expect(started).toEqual(["slow", "fast"]); + const log = await persisted(repo, turnId); + const results = log.filter((e) => e.type === "tool_result"); + expect(results).toHaveLength(2); + for (const result of results) { + expect(result).toMatchObject({ + result: { + output: "Tool execution was cancelled.", + isError: true, + }, + }); + } + expect(typesOf(log)).toContain("turn_cancelled"); + }); + + it("recovers a crash that left multiple sync invocations open", async () => { + const SEED_ID = "2026-07-02T10-00-00Z-0000001-000"; + const repo = new InMemoryTurnRepo(); + const batch = assistantCalls( + toolCallPart("S", "slow"), + toolCallPart("F", "fast"), + ); + repo.seed([ + { + type: "turn_created", + schemaVersion: 1, + turnId: SEED_ID, + ts: TS, + sessionId: null, + agent: { requested: { agentId: "copilot" }, resolved: agent }, + context: [], + input: user("hello"), + config: { + autoPermission: false, + humanAvailable: true, + maxModelCalls: 20, + }, + }, + { + type: "model_call_requested", + turnId: SEED_ID, + ts: TS, + modelCallIndex: 0, + request: { messages: ["input"], parameters: {} }, + }, + { + type: "model_call_completed", + turnId: SEED_ID, + ts: TS, + modelCallIndex: 0, + message: batch, + finishReason: "stop", + usage: {}, + }, + { + type: "tool_invocation_requested", + turnId: SEED_ID, + ts: TS, + toolCallId: "S", + toolId: "tool.slow", + toolName: "slow", + execution: "sync", + input: {}, + }, + { + type: "tool_invocation_requested", + turnId: SEED_ID, + ts: TS, + toolCallId: "F", + toolId: "tool.fast", + toolName: "fast", + execution: "sync", + input: {}, + }, + ]); + const { runtime } = makeRuntime({ + repo, + agent, + tools: [ + syncTool(slowDescriptor, async () => ({ + output: "never", + isError: false, + })), + syncTool(fastDescriptor, async () => ({ + output: "never", + isError: false, + })), + ], + models: [respond(completedResp(assistantText("done")))], + }); + const { outcome } = await advanceAndSettle(runtime, SEED_ID); + expect(outcome?.status).toBe("completed"); + const log = await persisted(repo, SEED_ID); + const indeterminate = log.filter( + (e) => + e.type === "tool_result" && + e.source === "runtime" && + e.result.isError === true, + ); + expect(indeterminate.map((e) => (e as { toolCallId: string }).toolCallId).sort()).toEqual([ + "F", + "S", + ]); + }); +}); diff --git a/apps/x/packages/core/src/turns/runtime.ts b/apps/x/packages/core/src/turns/runtime.ts index da2362a4..0fd4337c 100644 --- a/apps/x/packages/core/src/turns/runtime.ts +++ b/apps/x/packages/core/src/turns/runtime.ts @@ -346,8 +346,24 @@ class TurnAdvance { } // Durable barrier: persist, re-reduce (the reducer doubles as a runtime - // assertion that the appended history is legal), then stream. - private async append(...batch: TEvent[]): Promise { + // assertion that the appended history is legal), then stream. Commits are + // serialized through an internal queue so concurrently executing tools + // can never interleave the persist/reduce/stream ritual — file order, + // in-memory order, and stream order stay identical by construction. + private appendChain: Promise = Promise.resolve(); + + private append(...batch: TEvent[]): Promise { + const task = this.appendChain.then(() => this.commit(batch)); + // A failed commit rejects for its caller only; the chain stays alive + // so other in-flight tools can still record their results. + this.appendChain = task.then( + () => undefined, + () => undefined, + ); + return task; + } + + private async commit(batch: TEvent[]): Promise { await this.turnRepo.append(this.turnId, batch); this.events.push(...batch); this.state = reduceTurn(this.events); @@ -741,8 +757,13 @@ class TurnAdvance { } } - // §10.5: execute allowed sync tools sequentially and expose allowed async - // tools, in source order. Tool failures are conversational, not terminal. + // §10.5: record invocations for allowed tools serially in source order, + // then execute the sync ones concurrently (async tools are exposed by + // their invocation; results arrive through advanceTurn). Invocations are + // durable before any execution starts, and commits are serialized by + // append's internal queue, so the log prefix is deterministic while + // results land in completion order. Tool failures are conversational, + // not terminal. private async executeAllowedTools(): Promise { const executable = this.state.toolCalls.filter( (tc) => @@ -751,8 +772,11 @@ class TurnAdvance { (this.checkerAllowed.has(tc.toolCallId) || tc.permission?.resolved?.decision === "allow"), ); + const started: Array<{ tc: ToolCallState; tool: SyncRuntimeTool }> = []; for (const tc of executable) { if (this.signal.aborted) { + // Invoked-but-unexecuted calls get their cancelled results + // from cancel(), same as before this loop ran. return; } const tool = this.toolsByName.get(tc.toolName); @@ -771,52 +795,63 @@ class TurnAdvance { if (tool.descriptor.execution === "async") { continue; // exposed; the result arrives through advanceTurn } - const syncTool = tool as SyncRuntimeTool; - try { - const result = await syncTool.execute(tc.input, { - turnId: this.turnId, - toolCallId: tc.toolCallId, - signal: this.signal, - reportProgress: async (progress) => { - await this.append({ - type: "tool_progress", - turnId: this.turnId, - ts: this.now(), - toolCallId: tc.toolCallId, - source: "sync", - progress, - }); - }, - }); - await this.append({ - type: "tool_result", - turnId: this.turnId, - ts: this.now(), - toolCallId: tc.toolCallId, - toolName: tc.toolName, - source: "sync", - result: ToolResultData.parse(result), - }); - } catch (error) { - if (this.signal.aborted) { - await this.append( - runtimeResultEvent(this.turnId, this.now(), tc, { - output: "Tool execution was cancelled.", - isError: true, - }), - ); - return; - } - await this.append({ - type: "tool_result", - turnId: this.turnId, - ts: this.now(), - toolCallId: tc.toolCallId, - toolName: tc.toolName, - source: "sync", - result: { output: errorMessage(error), isError: true }, - }); + started.push({ tc, tool: tool as SyncRuntimeTool }); + } + // Each task settles its own call (result or error), so Promise.all + // never rejects and one slow tool never blocks its siblings. + await Promise.all( + started.map(({ tc, tool }) => this.executeSyncTool(tc, tool)), + ); + } + + private async executeSyncTool( + tc: ToolCallState, + syncTool: SyncRuntimeTool, + ): Promise { + try { + const result = await syncTool.execute(tc.input, { + turnId: this.turnId, + toolCallId: tc.toolCallId, + signal: this.signal, + reportProgress: async (progress) => { + await this.append({ + type: "tool_progress", + turnId: this.turnId, + ts: this.now(), + toolCallId: tc.toolCallId, + source: "sync", + progress, + }); + }, + }); + await this.append({ + type: "tool_result", + turnId: this.turnId, + ts: this.now(), + toolCallId: tc.toolCallId, + toolName: tc.toolName, + source: "sync", + result: ToolResultData.parse(result), + }); + } catch (error) { + if (this.signal.aborted) { + await this.append( + runtimeResultEvent(this.turnId, this.now(), tc, { + output: "Tool execution was cancelled.", + isError: true, + }), + ); + return; } + await this.append({ + type: "tool_result", + turnId: this.turnId, + ts: this.now(), + toolCallId: tc.toolCallId, + toolName: tc.toolName, + source: "sync", + result: { output: errorMessage(error), isError: true }, + }); } } 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(),