Files
dev/third_party/pi/packages/durable/test/examples/23-subagent-background.ts
alexion 6081e3e325 chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
2026-10-15 17:58:48 -04:00

364 lines
18 KiB
TypeScript

// Persistent background subagents. One `subagent` tool lets the main agent start named subagents, message them
// (steer or follow up), stop them mid-answer, and list them. Subagents keep working while the main agent
// answers the user, and each answer is delivered back to the main agent as a new message once it arrives. Everything
// survives a restart: the example closes the Harness while a subagent works and reopens it.
// Uses OpenAI when OPENAI_API_KEY is set, and a scripted faux model otherwise.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/23-subagent-background.ts
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { type AssistantMessage, type FauxResponseStep, Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
import {
type AgentEvent,
AssistantEntry,
type ConversationId,
configure,
createRegistry,
defineDoc,
defineExtension,
defineTask,
defineTool,
type EntryId,
Harness,
LiveDoc,
type TaskId,
watchEvents,
} from "../../src/index.ts";
import { openNodeSqliteStorage } from "../../src/storage/sqlite/node.ts";
const context = BACKGROUND_CONTEXT;
// ─── Product code ────────────────────────────────────────────────────────────
// Each subagent is its own conversation with its own transcript, so it remembers earlier messages. The main
// conversation keeps a small document that maps subagent names to their conversations. Documents are durable state
// next to a transcript, changed in commits like entries.
type Subagent = {
conversationId: ConversationId;
/** Answers already reported to the main agent: several messages can end in one answer, reported once. */
reported: EntryId[];
};
const Subagents = defineDoc<{ agents: Record<string, Subagent>; reporters: Record<string, TaskId> }>({
kind: "app.subagents",
version: 1,
scope: "conversation",
history: "latest",
// A fork of the main conversation starts without subagents.
fork: "initial",
initial: () => ({ agents: {}, reporters: {} }),
});
// Every task and conversation has an owner, and that decides what an abort or an idle wait reaches: aborting a task
// aborts what it owns, and waiting for a conversation to be idle waits for its work. A subagent must outlive the main
// agent's turns, so its conversation is owned by an anchor: a background task that finishes at once. A background task
// is a boundary: the main agent's Esc and idle waits stop there, but `abort({ background: true })` still reaches past
// it, so a host can stop everything.
const Anchor = defineTask<null, { phase: "done" }, null>({
name: "app.subagent-anchor",
version: 1,
initial: () => ({ phase: "done" }),
phases: {
done: (_anchor, runtime, taskContext) =>
runtime.commit(() => ({ status: "terminal", outcome: { status: "completed", result: null } }), taskContext),
},
abort: (_anchor, runtime, taskContext) =>
runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), taskContext),
});
// A reporter delivers one message to a subagent and reports the answer to the main agent. It is a background task
// too, so the main agent's Esc and idle waits leave it alone. Tasks are durable: each phase ends with a saved
// checkpoint, and after a restart the task continues from the last one. Request IDs make a repeated submission return
// the first one, so the subagent gets the message once and the main agent gets the report once.
type ReporterInput = { name: string; conversationId: ConversationId; message: string; followUp: boolean };
type ReporterState = { phase: "deliver" } | { phase: "report"; report?: string };
const Reporter = defineTask<ReporterInput, ReporterState, null>({
name: "app.subagent-reporter",
version: 1,
initial: () => ({ phase: "deliver" }),
phases: {
// Send the message, wait for its answer, and decide what to report.
deliver: async (reporter, runtime, taskContext) => {
const { name, conversationId, message, followUp } = reporter.input;
// While the subagent is busy, a steer reaches it at its next step and a follow-up after its current answer.
const subagent = (await runtime.conversation(conversationId, taskContext))!;
const request = { type: "input", content: message, whenBusy: followUp ? "followUp" : "steer" } as const;
const submission = await subagent.submit({ ...request, requestId: `subagent:${reporter.id}` }, taskContext);
const settled = await submission.wait(taskContext);
// One commit decides the report and records the answer as delivered, so a restart does not decide again.
await runtime.commit(async (tx) => {
const next = (report?: string) => ({ status: "running", checkpoint: { phase: "report", report } }) as const;
// `aborted`: stopped, or withdrawn while queued. Nothing to report.
if (settled.status === "unanswered") {
return next(settled.reason === "aborted" ? undefined : `[subagent ${name} failed: ${settled.reason}]`);
}
// Always an answered input here; the check tells TypeScript.
if (settled.type !== "input") return next();
const agent = (await tx.doc(Subagents, runtime.conversationId)).agents[name]!;
if (agent.reported.includes(settled.answer)) return next();
agent.reported.push(settled.answer);
const answer = (await tx.entry(AssistantEntry, settled.answer))?.model?.[0] as AssistantMessage;
return next(`[subagent ${name} answered, no reply needed] ${textOf(answer)}`);
}, taskContext);
},
// Post the report as a follow-up input: it starts a turn when the main agent is idle, or waits for its current
// answer. If the user presses Esc while it waits in the main agent's queue, it is dropped with the other input; a
// report that arrives after Esc starts a new turn.
report: async (reporter, runtime, taskContext) => {
const report = reporter.state.checkpoint.report;
if (report !== undefined) {
const main = (await runtime.conversation(runtime.conversationId, taskContext))!;
const input = { type: "input", content: report, whenBusy: "followUp" } as const;
await main.submit({ ...input, requestId: `subagent-report:${reporter.id}` }, taskContext);
}
await runtime.commit(
() => ({ status: "terminal", outcome: { status: "completed", result: null } }),
taskContext,
);
},
},
abort: (_reporter, runtime, taskContext) =>
runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), taskContext),
});
function textOf(message: AssistantMessage | undefined): string {
return (message?.content ?? []).flatMap((c) => (c.type === "text" ? [c.text] : [])).join("");
}
const subagentTool = defineTool({
name: "subagent",
description:
"Manage persistent subagents that work in the background. Actions: spawn (name, message), send (name, message; " +
"followUp: true queues it after the current answer instead of steering), stop (name: aborts its current work), " +
"status (name, or all subagents without one). Answers are reported back to you when they arrive.",
parameters: Type.Object({
action: Type.Union([Type.Literal("spawn"), Type.Literal("send"), Type.Literal("stop"), Type.Literal("status")]),
name: Type.Optional(Type.String()),
message: Type.Optional(Type.String()),
followUp: Type.Optional(Type.Boolean()),
}),
// A call interrupted by a crash is not rerun: repeating `stop` could stop newer work. The model sees that the call
// was interrupted and can check with `status`.
replay: "unsafe",
execute: async (args, api, callContext) => {
const { action, name, message, followUp } = args;
const reply = (text: string, conversationId?: ConversationId) => ({
content: [{ type: "text" as const, text }],
// A UI can attach to the subagent's conversation through the call's details.
...(conversationId === undefined || name === undefined ? {} : { details: { name, conversationId } }),
});
const registry = (await api.snapshot(Subagents, api.conversationId, callContext)) ?? {
agents: {},
reporters: {},
};
if (action === "status") {
const names = name === undefined ? Object.keys(registry.agents) : [name];
const lines: string[] = [];
for (const each of names) {
const found = Object.hasOwn(registry.agents, each) ? registry.agents[each] : undefined;
if (found === undefined) continue;
// A conversation is busy while it has a run: from an input until its final answer.
const busy = (await api.snapshot(LiveDoc, found.conversationId, callContext))?.run !== undefined;
lines.push(`${each}: ${busy ? "working" : "idle"}`);
}
return reply(lines.length === 0 ? "No subagents." : lines.join("\n"));
}
if (name === undefined) return reply(`${action} needs a name.`);
const agent = Object.hasOwn(registry.agents, name) ? registry.agents[name] : undefined;
if (action !== "spawn" && agent === undefined) return reply(`No subagent named ${name}.`);
if (action === "stop") {
// Aborts the subagent's current answer and tools and drops its queued messages. It stays usable.
await (await api.conversation(agent!.conversationId, callContext))!.abort(callContext);
return reply(`Stopped ${name}.`, agent!.conversationId);
}
if (message === undefined) return reply(`${action} needs a message.`);
// spawn and send: one commit starts a reporter for the message.
const result = await api.commit(async (tx) => {
const state = await tx.doc(Subagents, api.conversationId);
// Both tasks belong to the main conversation and are background: its Esc and idle waits skip them.
const background = { ownership: { kind: "conversation" }, background: true } as const;
if (action === "spawn") {
if (Object.hasOwn(state.agents, name)) return `${name} already exists; use send.`;
const anchor = await tx.createTask(Anchor, null, background);
// Owned by a task of the main conversation: starts as a copy of the main agent.
const child = await tx.createConversation({ ownership: { kind: "task", taskId: anchor } });
// Subagents cannot start subagents, and know who they are.
await configure(tx, child.id, {
extensions: { remove: [SubagentTools] },
instructions: `You are the subagent "${name}". Answer the main agent's requests.`,
});
state.agents[name] = { conversationId: child.id, reported: [] };
}
const conversationId = state.agents[name]!.conversationId;
const input = { name, conversationId, message, followUp: action === "send" && followUp === true };
state.reporters[api.taskId] = await tx.createTask(Reporter, input, background);
return action === "send" ? `Sent to ${name}.` : `Started ${name}.`;
}, callContext);
const current = (await api.snapshot(Subagents, api.conversationId, callContext))?.agents[name];
return reply(result, current?.conversationId);
},
});
// The task definitions come with the extension, so pending reporters resume after a restart once the host installs
// it again.
const SubagentTools = defineExtension({ name: "subagent-tools", tasks: [Anchor, Reporter], tools: [subagentTool] });
// ─── Host setup ─────────────────────────────────────────────────────────────
const models = createModels();
let model = { provider: "openai", modelId: "gpt-6-sol" };
if (process.env.OPENAI_API_KEY !== undefined) {
models.setProvider(openaiProvider());
} else {
// The main agent and the subagent share one scripted model, which answers each request by its last message.
// It streams its answers at 50 tokens per second, like a slow real model.
const faux = fauxProvider({ tokensPerSecond: 50 });
models.setProvider(faux.provider);
model = { provider: "faux", modelId: "faux-1" };
const call = (input: Record<string, string>) =>
fauxAssistantMessage([fauxToolCall("subagent", input)], { stopReason: "toolUse" });
const answer = (text: string) => fauxAssistantMessage([fauxText(text)]);
const route: FauxResponseStep = (request) => {
// System messages carry prompt changes; the request is about the message before them.
const last = request.messages.findLast((message) => message.role !== "system")!;
const content = typeof last.content === "string" ? [{ type: "text", text: last.content }] : last.content;
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");
// The main agent repeats what the tool said.
if (last.role === "toolResult") return answer(`OK. ${text}`);
// The main agent.
if (text.includes("Start a subagent")) {
return call({ action: "spawn", name: "reader", message: "Summarize the plot of Moby Dick." });
}
if (text.includes("whale's name"))
return call({ action: "send", name: "reader", message: "What is the whale called?" });
if (text.includes("every chapter")) {
return call({ action: "send", name: "reader", message: "Now go through all chapters in detail." });
}
if (text.includes("Stop reader")) return call({ action: "stop", name: "reader" });
if (text.includes("my subagents")) return call({ action: "status" });
if (text.includes("[subagent")) return answer("Noted.");
// The subagent: short answers, and a long chapter walk-through that is stopped halfway.
if (text.includes("Summarize the plot")) return answer("A whale, a captain, an obsession.");
if (text.includes("whale called")) return answer("Moby Dick.");
const chapters = Array.from({ length: 135 }, (_, index) => `Chapter ${index + 1}: more whaling.`);
return answer(chapters.join("\n"));
};
// More responses than the script needs; each request takes the next one.
faux.setResponses(Array.from({ length: 40 }, () => route));
}
const registry = createRegistry();
registry.install(SubagentTools);
const directory = await mkdtemp(join(tmpdir(), "pi-durable-subagents-"));
const open = async () => {
const harness = await Harness.open(
await openNodeSqliteStorage(join(directory, "session.sqlite")),
{ models, registry },
context,
);
const root = await harness.root(context, { agent: { model } });
return { harness, root };
};
// ─── UI: the main conversation's transcript, as a user would see it ───
const color = (code: number) => (text: string) => (process.stdout.isTTY ? `\x1b[${code}m${text}\x1b[0m` : text);
const bold = color(1);
const dim = color(2);
const cyan = color(36);
const yellow = color(33);
const magenta = color(35);
function contentText(content: string | readonly { type: string; text?: string }[]): string {
return typeof content === "string"
? content
: content.flatMap((part) => (part.text === undefined ? [] : [part.text])).join("");
}
/** Print the main conversation's messages as they are committed. Subagents work in their own conversations. */
async function follow(harness: Harness, id: ConversationId): Promise<void> {
const stream = await watchEvents(harness, id, context);
stream.start(async (events: readonly AgentEvent[]) => {
for (const event of events) {
if (event.type !== "message_end") continue;
const message = event.entry.model?.[0];
if (message?.role === "user") {
const text = contentText(message.content);
// Input is either the user or a subagent's report, which arrives whenever the subagent is done.
const report = /^\[subagent (\S+) ([^\]]*)\] ?(.*)$/s.exec(text);
if (report === null) console.log(`\n${cyan(bold(">"))} ${bold(text)}`);
else console.log(`\n${magenta(bold(`> ${report[1]}:`))} ${report[3] || report[2]}`);
} else if (message?.role === "assistant") {
for (const part of message.content) {
if (part.type !== "toolCall") continue;
const { action, name, message: sent } = part.arguments as Record<string, string | undefined>;
const quoted = sent === undefined ? "" : ` ${JSON.stringify(sent)}`;
console.log(yellow(` ${part.name} ${action}${name === undefined ? "" : ` ${name}`}${quoted}`));
}
if (textOf(message) !== "") console.log(textOf(message));
} else if (message?.role === "toolResult") {
console.log(dim(` → ${contentText(message.content)}`));
}
}
});
}
let { harness, root } = await open();
await follow(harness, root.id);
/** Say something to the main agent and wait for its answer. */
const say = async (text: string): Promise<void> => {
await (await root.submit({ type: "input", content: text }, context)).wait(context);
};
/** Wait until every message to a subagent was answered and reported, and the main agent has reacted. */
const settle = async (): Promise<void> => {
const reporters = (await harness.snapshot(Subagents, root.id, context))?.reporters ?? {};
for (const id of Object.values(reporters)) await harness.waitForTask(id, context);
await root.waitForIdle(context);
// Event callbacks run after their commit; let the last ones print. A slow machine may need longer.
await new Promise((resolve) => setTimeout(resolve, 50));
};
/** Poll `check` for up to 10 seconds. */
const until = async (check: () => Promise<boolean>): Promise<void> => {
for (let tries = 0; tries < 1000 && !(await check()); tries++)
await new Promise((resolve) => setTimeout(resolve, 10));
};
const working = async (name: string): Promise<boolean> => {
const agent = (await harness.snapshot(Subagents, root.id, context))?.agents[name];
return agent !== undefined && (await harness.snapshot(LiveDoc, agent.conversationId, context))?.run !== undefined;
};
// The main agent answers at once; the subagent's answer is reported back when it arrives.
await say("Start a subagent named reader that summarizes Moby Dick.");
await settle();
// A long request, stopped while the subagent is still answering.
await say("Ask reader to summarize every chapter.");
await until(() => working("reader"));
await say("Stop reader.");
await settle();
await say("What are my subagents doing?");
await settle();
// The process stops while the subagent works on a message; after the restart its answer still arrives.
const reporters = async () =>
Object.keys((await harness.snapshot(Subagents, root.id, context))?.reporters ?? {}).length;
const before = await reporters();
await root.submit({ type: "input", content: "Ask reader for the whale's name." }, context);
await until(async () => (await reporters()) > before);
await harness.close(context);
console.log(dim("\n (process restarts)"));
({ harness, root } = await open());
await follow(harness, root.id);
await settle();
await harness.close(context);
await rm(directory, { recursive: true, force: true });