import type { Context, JsonValue } from "@earendil-works/chord"; import { BACKGROUND_CONTEXT, withAbortSignal } from "@earendil-works/chord/context"; import { type ModelThinkingLevel, Type } from "@earendil-works/pi-ai"; import { AgentDoc, AssistantEntry, type ConversationId, type Cursor, defineDoc, defineExtension as defineDurableExtension, defineTask, defineTool, type Harness, type ModelRef, section, type TaskId, type TaskOutcome, type ToolExecutionApi, type Tx, } from "@earendil-works/pi-durable"; import { defineExtension } from "@berry/api"; import { EventEntry } from "./entries.ts"; import { startTool } from "./tool-timing.ts"; export const SUBAGENT = "subagent"; export const SUBAGENT_DESCRIPTION = "delegate tasks to blocking subagents"; const PROSE_DEFAULT = "subagents:\n" + [ "work locally by default; delegate only for parallelizable work, independent review, " + "cheaper-model tasks, or noisy exploration you want kept out of your context", "subagents share the working tree: don't run parallel workers that edit overlapping files " + "or mutate VCS state", "when continuing a `conversation`, omit `model` and `thinkingLevel` so its prompt cache " + "stays warm", "background subagents report back on their own—don't poll `subagent_wait` unless you are " + "blocked on the result, and `subagent_stop` work you no longer need", ].map((g) => `- ${g}`).join("\n"); const Call = defineDoc<{ delegation?: { id: TaskId; child: ConversationId } }>({ kind: "berry.delegation-call", version: 1, scope: "task", initial: () => ({}), checkpointWhen: () => true, }); /** How a subagent's turn ended. */ type Answered = { status: "completed"; answer: string } | { status: "failed"; error: string }; type Settled = Answered | { status: "stopped" }; /** The input a delegation sends its subagent, which a retry finds again by its request ID. */ const turn = (task: { id: TaskId; input: { task: string } }) => ({ type: "input" as const, content: task.input.task, requestId: `delegation:${task.id}`, }); /** A background delegation's report to its parent: the entry the web shows, and what the model reads. */ const reportOf = (tid: string, task: string, outcome: Settled) => ({ kind: "berry.subagent-report", data: { tid, task, ...outcome }, content: outcome.status === "completed" ? `Background subagent ${tid} finished its task.\nTask: ${task}\n\n${outcome.answer}\n\n(conversation ${tid}; pass it as conversation to continue it)` : outcome.status === "failed" ? `Background subagent ${tid} failed: ${outcome.error}\nTask: ${task}\n\n(conversation ${tid})` : `Background subagent ${tid} stopped.\nTask: ${task}\n\n(conversation ${tid})`, }); const terminal = (outcome: Answered) => ({ status: "terminal" as const, outcome: outcome.status === "completed" ? { status: "completed" as const, result: outcome.answer } : { status: "failed" as const, error: { message: outcome.error } }, }); type SubagentArgs = { task: string; /** The subagent conversation to continue, by its TID. */ conversation?: string; model?: ModelRef; thinkingLevel?: ModelThinkingLevel; /** Run without waiting, reporting back when finished. */ background?: boolean; }; type Seed = { model: ModelRef; thinkingLevel: ModelThinkingLevel; extensions: string[]; events: { ext: string; event: JsonValue }[]; }; /** What a conversation delegates to: its worker models (none means those enabled in settings), its * background limit, and the background delegations it started. */ export type Subagent = { models: ModelRef[]; /** How many background delegations may run at once. */ limit: number; /** Background delegations this conversation started, by their child conversation id. */ children: Record; }; type Child = { task: string; /** The delegation task driving it, by id; absent before ids were tracked. */ delegation?: string; model: ModelRef; startedAt: number; settled?: { status: Settled["status"]; answer?: string; error?: string }; }; type SubagentEvent = | { type: "models"; models: ModelRef[] } | { type: "limit"; limit: number } | { type: "spawned"; conversationId: number; task: string; delegation?: string; model: ModelRef; startedAt: number; } | { type: "settled"; conversationId: number; status: Settled["status"]; answer?: string; error?: string; }; const keyOf = (model: ModelRef) => `${model.provider}/${model.modelId}`; type CallOptions = { signal?: AbortSignal; progress?(details: JsonValue): void; }; /** Delegation, blocking or in the background, with one owned task and transcript per invocation. */ export function subagentExtension(host: { harness: Harness; /** The models enabled in settings. */ enabled(): Promise; workers(id: ConversationId): Promise; title(tx: Tx, id: ConversationId, task: string): Promise; /** Editable delegation guidance, with `$USER` replaced and blank prose omitted. */ prose(): Promise; /** A new subagent's conversation, on `model`. */ prepare( api: ToolExecutionApi, model: ModelRef, thinkingLevel: ModelThinkingLevel | undefined, context: Context, ): Promise; /** The level `model` runs `requested` at. */ supported(model: ModelRef, requested: ModelThinkingLevel): ModelThinkingLevel; /** A conversation by its TID, with the TID of the conversation that owns it. */ resolve(tid: string): Promise<{ id: ConversationId; owner?: string } | undefined>; tidOf(id: ConversationId): Promise; }) { async function offered(id: ConversationId) { const { models } = await host.workers(id); return models.length > 0 ? models : await host.enabled(); } /** A subagent's turn: the first in a conversation it owns, or one more in a conversation it continues. */ const Delegation = defineTask< { task: string; child?: ConversationId }, { phase: "run" } | { phase: "report"; outcome: Answered; child: ConversationId }, string >({ name: "berry.delegation", version: 1, initial: () => ({ phase: "run" }), phases: { async run(task, runtime, context) { let child = task.input.child; if (child === undefined) { await runtime.commit(async (tx) => { const record = (await tx.scanConversations({ ownerTaskId: task.id }, 1)).items[0]; if (!record) throw new Error("Delegation has no child conversation"); child = record.id; }, context); } const conversation = (await runtime.conversation(child!, context))!; const settled = await (await conversation.submit(turn(task), context)).wait(context); // the child's answer lives in its own conversation, so read it where any entry is visible; // a background settle lands in the same commit, before the report it will send await runtime.commit(async (tx) => { let outcome: Answered; if (settled.status !== "done" || settled.type !== "input") { outcome = { status: "failed", error: settled.status === "unanswered" ? settled.reason : settled.status, }; } else { const message = (await tx.entry(AssistantEntry, settled.answer))?.model?.[0]; if (message?.role === "assistant") { const answer = message.content.flatMap((part) => part.type === "text" ? [part.text] : [] ); outcome = { status: "completed", answer: answer.join("\n") }; } else if (task.background) { outcome = { status: "failed", error: "Subagent has no answer" }; } else throw new Error("Subagent has no answer"); } if (!task.background) return terminal(outcome); await tx.appendEntry(EventEntry, runtime.conversationId, { data: { ext: SUBAGENT, event: { type: "settled", conversationId: child!, ...outcome } }, }); return { status: "running", checkpoint: { phase: "report", outcome, child: child! } }; }, context); }, async report(task, runtime, context) { const { outcome, child } = task.state.checkpoint; const { content, ...entry } = reportOf(await host.tidOf(child), task.input.task, outcome); const parent = (await runtime.conversation(runtime.conversationId, context))!; await parent.submit({ type: "input", content, entry, whenBusy: "steer", requestId: `subagent-report:${task.id}`, }, context); await runtime.commit(() => terminal(outcome), context); }, }, async abort(task, runtime, context) { // a continued conversation is owned by its first delegation: nothing else stops its turn when // only this call is cancelled, as when a script ends without awaiting it const { child } = task.input; if (child !== undefined) { try { let pending = false; await runtime.commit(async (tx) => { const status = (await tx.submissionByRequest(child, turn(task).requestId))?.status; pending = status === "queued" || status === "placed"; }, context); if (pending) { const conversation = (await runtime.conversation(child, context))!; const submission = await conversation.submit(turn(task), context); if ((await submission.abort(context)) === "already_placed") { await conversation.abort(context); } } } catch (error) { // a stop still leaves its note and settles if (!task.background) throw error; } } if (!task.background) { await runtime.commit( () => ({ status: "terminal", outcome: { status: "aborted" } }), context, ); return; } // a child the delegation created was stopped with it, as work it owns let stopped = child; if (stopped === undefined) { await runtime.commit(async (tx) => { stopped = (await tx.scanConversations({ ownerTaskId: task.id }, 1)).items[0]?.id; }, context).catch(() => {}); } const tid = stopped === undefined ? String(task.id) : await host.tidOf(stopped).catch(() => String(stopped)); const { content, ...entry } = reportOf(tid, task.input.task, { status: "stopped" }); await runtime.commit(async (tx) => { await tx.appendEntry(runtime.conversationId, { ...entry, model: [{ role: "user", content, timestamp: Date.now() }], }); if (stopped !== undefined) { await tx.appendEntry(EventEntry, runtime.conversationId, { data: { ext: SUBAGENT, event: { type: "settled", conversationId: stopped, status: "stopped" }, }, }); } return { status: "terminal", outcome: { status: "aborted" } }; }, context); }, }); /** The configuration of which models a conversation delegates to, which the harness folds as an extension's. */ const workers = defineExtension({ name: SUBAGENT, description: SUBAGENT_DESCRIPTION, sortOrder: 30, initial: () => ({ models: [], limit: 4, children: {} }), apply: (state, event) => { switch (event.type) { case "models": return { ...state, models: event.models }; case "limit": return { ...state, limit: event.limit }; case "spawned": { const { type: _, conversationId, ...child } = event; return { ...state, children: { ...state.children, [conversationId]: child } }; } case "settled": { const { type: _, conversationId, ...settled } = event; const child = state.children[conversationId]; if (!child) return state; return { ...state, children: { ...state.children, [conversationId]: { ...child, settled } }, }; } } }, setup(ext) { ext.promptProse({ default: PROSE_DEFAULT }); ext.configuration({ parameters: Type.Object({ models: Type.Array(Type.String(), { title: "worker models", default: [], description: "permitted subagent models (empty = all globally-enabled models)", }), limit: Type.Integer({ title: "background subagent limit", default: 4, minimum: 1, description: "how many background delegations may run at once", }), }), values: ({ models, limit }) => ({ models: models.map(keyOf), limit }), async events({ models: keys, limit }, state) { const known = new Map( [...state.models, ...await host.enabled()].map((model) => [keyOf(model), model]), ); const models = [...new Set(keys)].map((key) => { const model = known.get(key); if (!model) throw new Error(`${key} is not enabled in settings`); return { provider: model.provider, modelId: model.modelId }; }); return [{ type: "models", models }, { type: "limit", limit }]; }, }); }, }); async function invoke( args: SubagentArgs, api: ToolExecutionApi, context: Context, options: CallOptions, reuse: boolean, ): Promise< | { conversation: string; status: "running" } | { conversation: string; status: "done"; answer: string } > { const signal = options.signal ?? context.abortSignal; const { task, background = false } = args; const during = options.signal ? withAbortSignal(options.signal, context) : context; let delegation: { id: TaskId; child: ConversationId } | undefined; try { signal?.throwIfAborted(); await api.commit(async (tx) => { if ((await tx.conversation(api.conversationId))?.owner) { throw new Error("Only top-level agents can delegate"); } if (reuse) { const stored = (await tx.doc(Call, api.taskId)).delegation; if (stored) delegation = { id: stored.id, child: stored.child }; } }, during); if (!delegation) { const offers = await offered(api.conversationId); const require = (model: ModelRef) => { if (!offers.some((m) => keyOf(m) === keyOf(model))) { throw new Error(`${keyOf(model)} is not enabled for delegation`); } }; const { conversation, model, thinkingLevel } = args; const limit = background ? (await host.workers(api.conversationId)).limit : undefined; let target: { seed: Seed } | { child: ConversationId }; if (conversation === undefined) { if (!model) throw new Error("model is required to start a subagent"); require(model); target = { seed: await host.prepare(api, model, thinkingLevel, during) }; } else { const found = await host.resolve(conversation); if (found?.owner !== await host.tidOf(api.conversationId)) { throw new Error(`${conversation} is not one of this conversation's subagents`); } target = { child: found.id }; } await api.commit(async (tx) => { // the cap holds within the commit that would exceed it if (limit !== undefined) { let live = 0; let cursor: Cursor | undefined; do { const page = await tx.scanTasks( { conversationId: api.conversationId, kind: "berry.delegation", background: true }, 100, cursor, ); live += page.items.filter((record) => record.state.status !== "terminal").length; cursor = page.next; } while (cursor !== undefined); if (live >= limit) { throw new Error( `Subagent limit reached (${limit} running); use subagent_wait or subagent_stop first.`, ); } } // a background delegation belongs to the conversation, not this call, so it outlives // the call, the script, and the parent's abort without keeping the parent busy const id = await tx.createTask( Delegation, "child" in target ? { task, child: target.child } : { task }, background ? { ownership: { kind: "conversation" }, conversationId: api.conversationId, background: true, } : { ownership: { kind: "task", taskId: api.taskId } }, ); const child = "child" in target ? target.child : (await tx.createConversation({ ownership: { kind: "task", taskId: id } })).id; const agent = await tx.doc(AgentDoc, child); const worker = "seed" in target ? target.seed.model : model ?? agent.model; if (!worker) throw new Error(`${conversation} has no model`); require(worker); if ("seed" in target) { const { seed } = target; agent.model = seed.model; agent.thinkingLevel = seed.thinkingLevel; agent.extensions = { add: seed.extensions.filter((name) => name !== SUBAGENT), remove: [SUBAGENT], }; for (const event of seed.events) { await tx.appendEntry(EventEntry, child, { data: event }); } } else if (model || thinkingLevel) { agent.model = { provider: worker.provider, modelId: worker.modelId }; agent.thinkingLevel = host.supported( worker, thinkingLevel ?? agent.thinkingLevel ?? "off", ); } if (background) { await tx.appendEntry(EventEntry, api.conversationId, { data: { ext: SUBAGENT, event: { type: "spawned", conversationId: child, task, delegation: String(id), model: { provider: worker.provider, modelId: worker.modelId }, startedAt: Date.now(), }, }, }); } await host.title(tx, child, task); delegation = { id, child }; if (reuse) (await tx.doc(Call, api.taskId)).delegation = delegation; }, during); } const { id, child } = delegation!; const tid = await host.tidOf(child); const details = { tid, conversationId: child, ...(background ? { status: "running" } : {}) }; if (options.progress) options.progress(details); else await api.details(details, during); if (background) return { conversation: tid, status: "running" }; const { state: { outcome } } = await api.waitForTask(id, during); if (outcome.status !== "completed") { throw new Error( `Subagent failed: ${"error" in outcome ? outcome.error?.message : outcome.status}\n\n` + `(subagent conversation ${tid}; pass it as conversation to continue)`, ); } return { conversation: tid, status: "done", answer: outcome.result }; } finally { // a nested call's cancellation stops its work, not merely its wait; a background // spawn returning early is the whole point, so it never aborts its child if (!background && delegation && signal?.aborted && !context.abortSignal?.aborted) { await host.harness.abortTask(delegation.id, BACKGROUND_CONTEXT); await host.harness.waitForTask(delegation.id, BACKGROUND_CONTEXT); } } } /** This conversation's background subagents by TID: those named, or every live one. */ async function targets(api: ToolExecutionApi, conversations: string[] | undefined) { const { children } = await host.workers(api.conversationId); const found: [string, Child][] = []; if (conversations === undefined) { for (const [id, child] of Object.entries(children)) { if (!child.settled) found.push([await host.tidOf(Number(id) as ConversationId), child]); } return found; } const owner = await host.tidOf(api.conversationId); for (const tid of conversations) { const resolved = await host.resolve(tid); const child = resolved?.owner === owner ? children[resolved.id] : undefined; if (!child) throw new Error(`${tid} is not one of this conversation's background subagents`); found.push([tid, child]); } return found; } const waited = (conversation: string, settled: NonNullable) => settled.status === "completed" ? { conversation, status: "done", answer: settled.answer ?? "" } : settled.status === "failed" ? { conversation, status: "failed", error: settled.error ?? "failed" } : { conversation, status: "stopped" }; const settledOf = (outcome: TaskOutcome): Settled => outcome.status === "completed" ? { status: "completed", answer: outcome.result } : outcome.status === "aborted" ? { status: "stopped" } : { status: "failed", error: outcome.error?.message ?? outcome.reason ?? outcome.status }; const tool = defineTool({ name: SUBAGENT, description: "Do the task yourself by default; delegate only when the benefit outweighs handoff overhead. " + "Delegate a self-contained task to a worker model, or follow up in a subagent's conversation. " + "No parent transcript or further delegation; shared files. Returns the final answer and the " + "subagent's conversation.", parameters: Type.Object({ task: Type.String({ minLength: 1, description: "Task and all needed context, or a follow-up to a continued conversation.", }), conversation: Type.Optional(Type.String({ minLength: 1, description: "Subagent conversation to continue, from an earlier result.", })), model: Type.Optional(Type.Object({ provider: Type.String({ minLength: 1 }), modelId: Type.String({ minLength: 1 }), }, { description: "Enabled worker model; required unless continuing, which keeps the subagent's.", })), thinkingLevel: Type.Optional(Type.Union([ Type.Literal("off"), Type.Literal("minimal"), Type.Literal("low"), Type.Literal("medium"), Type.Literal("high"), Type.Literal("xhigh"), Type.Literal("max"), ], { description: "Inherits the parent's, or a continued subagent's, when omitted; clamped to " + "worker support.", })), background: Type.Optional(Type.Boolean({ description: "Start in the background and return at once; the report arrives as a message.", })), }), replay: "safe", executionMode: "parallel", execute: async (args, api, context) => { await startTool(api, context); const result = await invoke(args, api, context, {}, true); const text = result.status === "running" ? `spawned subagent ${result.conversation} in the background; ` + "its report arrives as a message when it finishes" : `${result.answer}\n\n` + `(subagent conversation ${result.conversation}; pass it as conversation to continue)`; return { output: [{ type: "text", text }] }; }, }); return { extension: defineDurableExtension({ name: SUBAGENT, tasks: [Delegation], tools: [tool], sections: [ section(SUBAGENT, async ({ conversationId }) => { const prose = await host.prose(); const lines = [ "Enabled models:", ...(await offered(conversationId)).map((model) => JSON.stringify(model)), ]; const { children, limit } = await host.workers(conversationId); const live = Object.entries(children).filter(([, child]) => !child.settled) .sort(([, a], [, b]) => a.startedAt - b.startedAt); if (live.length > 0) lines.push(`Background subagents (${live.length}/${limit}):`); // no elapsed time: this is the system prompt, whose every change costs a cold prompt cache for (const [id, child] of live) { const [first] = child.task.split("\n"); lines.push( `- ${await host.tidOf(Number(id) as ConversationId)}: ` + `${first.length > 80 ? `${first.slice(0, 80)}…` : first} ` + `(${keyOf(child.model)}, running)`, ); } const dynamic = lines.join("\n"); return prose ? `${prose}\n${dynamic}` : dynamic; }, { tag: false }), ], }), workers, /** What scripts may call: delegation, and the waiting and stopping the model is never offered directly. */ scripted: [{ declaration: { name: tool.name, extension: SUBAGENT, description: tool.description, parameters: tool.parameters, returns: Type.Object({ conversation: Type.String(), status: Type.Union([Type.Literal("done"), Type.Literal("running")]), answer: Type.Optional(Type.String()), }), direct: true, }, call: (args: SubagentArgs, api: ToolExecutionApi, context: Context, options: CallOptions) => invoke(args, api, context, options, false), }, { declaration: { name: "subagent_wait", extension: SUBAGENT, description: "Wait for background subagents to finish and collect their answers; " + "a timeout only ends the wait, never the work.", parameters: Type.Object({ conversations: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { description: "Background subagents to wait for; default every live one.", })), timeoutMs: Type.Optional(Type.Number({ minimum: 1, description: "How long to wait before returning running ones; default 600000.", })), }), returns: Type.Array(Type.Object({ conversation: Type.String(), status: Type.Union([ Type.Literal("done"), Type.Literal("running"), Type.Literal("failed"), Type.Literal("stopped"), ]), answer: Type.Optional(Type.String()), error: Type.Optional(Type.String()), })), direct: false, }, call: async ( args: { conversations?: string[]; timeoutMs?: number }, api: ToolExecutionApi, context: Context, ) => { const wanted = await targets(api, args.conversations); const timed = withAbortSignal(AbortSignal.timeout(args.timeoutMs ?? 600_000), context); return await Promise.all(wanted.map(async ([tid, child]) => { if (child.settled) return waited(tid, child.settled); // a spawn from before ids were tracked has nothing to wait on if (child.delegation === undefined) return { conversation: tid, status: "running" }; const id = Number(child.delegation) as TaskId; try { return waited(tid, settledOf((await api.waitForTask(id, timed)).state.outcome)); } catch (error) { if (context.abortSignal?.aborted) throw error; // the timeout may have raced the end of the work const known = await api.getTask(id, context).catch(() => undefined); return known?.state.status === "terminal" ? waited(tid, settledOf(known.state.outcome)) : { conversation: tid, status: "running" }; } })); }, }, { declaration: { name: "subagent_stop", extension: SUBAGENT, description: "Stop background subagents; their note arrives without starting a run.", parameters: Type.Object({ conversations: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { description: "Background subagents to stop; default every live one.", })), }), returns: Type.Array(Type.Object({ conversation: Type.String(), status: Type.Literal("stopped"), })), direct: false, }, call: async (args: { conversations?: string[] }, api: ToolExecutionApi, context: Context) => { const wanted = await targets(api, args.conversations); const ids = wanted.flatMap(([, child]) => child.settled || child.delegation === undefined ? [] : [Number(child.delegation) as TaskId] ); for (const id of ids) await host.harness.abortTask(id, context); await Promise.all(ids.map((id) => host.harness.waitForTask(id, context))); return wanted.map(([conversation]) => ({ conversation, status: "stopped" })); }, }], }; }