feat(coding-agent): experimental durable vacation planner demo

A copy of the experimental durable coding agent with the coding tools and pi's
coding prompt replaced by a vacation planner: a search tool that is safe to
rerun, and a background research subagent that reports back as a new message.
This commit is contained in:
Mario Zechner 2026-10-01 19:55:16 +02:00
parent 6f1072cc08
commit 395315f484
7 changed files with 1378 additions and 0 deletions

View file

@ -0,0 +1,53 @@
# vacation
A durable vacation planning agent with a TUI, built on `@earendil-works/pi-durable`. It is a copy of the durable coding
agent in [`../durable`](../durable) with the coding tools and pi's coding prompt replaced by a vacation planner. It only
looks like a coding agent because it reuses pi's interactive TUI components.
From a pi checkout, after `npm install` and `npm run build`:
```bash
node packages/coding-agent/src/experimental/vacation/main.ts
node packages/coding-agent/src/experimental/vacation/main.ts --continue
```
Without a build, preload the source resolver so the workspace packages load from `src` instead of `dist`:
```bash
node --import ./packages/coding-agent/src/experimental/source-resolver.ts packages/coding-agent/src/experimental/vacation/main.ts
```
A new session starts with pi's default model and thinking level from `settings.json`. `--continue` opens the newest
session for the current directory. Sessions live under
`~/.pi/agent/experimental/vacation-sessions/<cwd-hash>/<session>/session.sqlite`. Log in with pi itself; credentials are
shared.
## What it shows
- **Background subagent:** the `research` tool starts a subagent in its own conversation and returns at once. A
background task delivers the request and posts the subagent's report back to the main conversation as a new message,
so you keep chatting with the main agent while the research runs.
- **Parallel durable tool calls:** the subagent's `search` calls run in parallel, each as its own task in the task
panel. The results are canned; each topic takes a different time.
- **Recovery:** `search` is safe to rerun. Kill the process while a search runs and start it again with `--continue`:
finished searches are kept, the unfinished one runs again, and the report still arrives. Request IDs keep a restart
from sending the request or the report twice.
- **Everything else from the durable coding agent:** steering, follow-ups, `/agents`, `/compact`, `/tasks`, `/model`.
## Try it
1. "Plan a weekend in Vienna for two. Hand the research to a subagent."
2. While the searches run, ask the main agent something else.
3. Ctrl+C while only the trains search is left, then start again with `--continue`.
4. `/agents` to watch or steer the subagent, `/agents` again to go back to main.
## Layout
| file | role |
| --- | --- |
| `vacation.ts` | the vacation extensions: `search`, `research`, the report task, and the prompt |
| `main.ts` | arguments, open, run, close |
| `sessions.ts` | session directories and the lock |
| `harness-setup.ts` | HTTP setup, settings, the registry, the initial model |
| `runtime.ts` | Harness and the plain `DurableView` and `DurableController` |
| `tui.ts` | rendering with pi's interactive components |

View file

@ -0,0 +1,89 @@
import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
import { createRegistry, type HarnessSettings, type ModelRef, type Registry } from "@earendil-works/pi-durable";
import { applyHttpProxySettings, configureHttpDispatcher } from "../../core/http-dispatcher.ts";
import { findInitialModel, resolveCliModel } from "../../core/model-resolver.ts";
import type { ModelRuntime } from "../../core/model-runtime.ts";
import type { SettingsManager } from "../../core/settings-manager.ts";
import { Search, Vacation } from "./vacation.ts";
/** pi's HTTP setup: proxy, idle timeouts, and one undici for fetch. Without it, some provider streams break off. */
export function configureHarnessHttp(settingsManager: SettingsManager): void {
applyHttpProxySettings(settingsManager.getGlobalSettings().httpProxy);
configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs());
}
/** Harness settings read at every use from pi's settings as loaded at startup. */
export function createHarnessSettings(settingsManager: SettingsManager): HarnessSettings {
return {
get stream() {
const provider = settingsManager.getProviderRetrySettings();
const idle = settingsManager.getHttpIdleTimeoutMs();
return {
timeoutMs: provider.timeoutMs ?? (idle === 0 ? 2147483647 : idle),
maxRetryDelayMs: provider.maxRetryDelayMs,
...(provider.maxRetries === undefined ? {} : { maxRetries: provider.maxRetries }),
};
},
get compaction() {
return settingsManager.getCompactionSettings();
},
get retry() {
return settingsManager.getRetrySettings();
},
get steeringMode() {
return settingsManager.getSteeringMode();
},
get followUpMode() {
return settingsManager.getFollowUpMode();
},
};
}
/** A registry with the vacation planner and its research subagent's search. No coding tools, no pi prompt. */
export function createVacationRegistry(): Registry {
const registry = createRegistry();
registry.install(Vacation);
registry.install(Search);
return registry;
}
export interface InitialModel {
readonly model?: ModelRef;
readonly thinkingLevel?: ModelThinkingLevel;
readonly fallbackMessage?: string;
}
/** The model a new root conversation starts with: an explicit `--provider`/`--model`, or pi's default resolution. */
export async function findInitialAgentModel(
settingsManager: SettingsManager,
modelRuntime: ModelRuntime,
cli?: { readonly provider?: string; readonly model: string },
): Promise<InitialModel> {
if (cli !== undefined) {
const resolved = resolveCliModel({ cliProvider: cli.provider, cliModel: cli.model, modelRuntime });
if (resolved.error !== undefined || resolved.model === undefined) {
throw new Error(`Could not resolve model: ${resolved.error ?? cli.model}`);
}
return {
model: { provider: resolved.model.provider, modelId: resolved.model.id },
...(resolved.thinkingLevel === undefined ? {} : { thinkingLevel: resolved.thinkingLevel }),
};
}
const initial = await findInitialModel({
scopedModels: [],
isContinuing: false,
defaultProvider: settingsManager.getDefaultProvider(),
defaultModelId: settingsManager.getDefaultModel(),
defaultThinkingLevel: settingsManager.getDefaultThinkingLevel(),
modelRuntime,
});
return {
...(initial.model === undefined
? {}
: {
model: { provider: initial.model.provider, modelId: initial.model.id },
thinkingLevel: initial.thinkingLevel,
}),
...(initial.fallbackMessage === undefined ? {} : { fallbackMessage: initial.fallbackMessage }),
};
}

View file

@ -0,0 +1,20 @@
#!/usr/bin/env node
import { type OpenDurableOptions, openDurable } from "./runtime.ts";
import { runDurableTui } from "./tui.ts";
function parseArgs(argv: readonly string[]): OpenDurableOptions {
let continueSession = false;
for (const arg of argv) {
if (arg === "--continue" || arg === "-c") continueSession = true;
else throw new Error(`Unknown argument: ${arg}`);
}
return { continueSession };
}
const durable = await openDurable(parseArgs(process.argv.slice(2)));
try {
await runDurableTui(durable.view, durable.controller, durable.settings);
} finally {
await durable.close();
}

View file

@ -0,0 +1,363 @@
import type { AttachedReplicatedState } from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { clampThinkingLevel, getSupportedThinkingLevels, type ModelThinkingLevel } from "@earendil-works/pi-ai";
import {
type AgentState,
type Conversation,
type ConversationId,
type ConversationView,
type Cursor,
type EntryRecord,
Harness,
type ModelRef,
ROOT_CONVERSATION_ID,
type Submission,
type TaskGraph,
} from "@earendil-works/pi-durable";
import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
import { ModelRuntime } from "../../core/model-runtime.ts";
import { SettingsManager } from "../../core/settings-manager.ts";
import {
configureHarnessHttp,
createHarnessSettings,
createVacationRegistry,
findInitialAgentModel,
} from "./harness-setup.ts";
import { selectSession } from "./sessions.ts";
import { Vacation } from "./vacation.ts";
const context = BACKGROUND_CONTEXT;
export interface ModelSummary extends ModelRef {
readonly name: string;
readonly contextWindow: number;
}
export interface Notice {
readonly id: number;
readonly level: "info" | "warning" | "error";
readonly message: string;
}
/** A conversation the user can switch to: the main one, or a subagent's. */
export interface ConversationSummary {
readonly id: ConversationId;
readonly label: string;
/** The first user message, for a subagent its task. */
readonly title?: string;
}
/** Everything the TUI renders. Plain values; no Harness objects cross this boundary. */
export interface DurableView {
readonly session: { readonly id: string; readonly directory: string; readonly cwd: string };
/** The conversation shown and talked to. */
readonly conversation: ConversationView;
readonly conversations: readonly ConversationSummary[];
readonly models: readonly ModelSummary[];
readonly notices: readonly Notice[];
/** The live task graph while the task panel is open. */
readonly tasks?: TaskGraph;
}
export interface DurableViewSource {
current(): DurableView;
subscribe(listener: () => void): () => void;
}
/** What the TUI may ask for. */
export interface DurableController {
/** Prompt when idle; otherwise steer or queue a follow-up. */
submit(text: string, whenBusy: "steer" | "followUp"): Promise<void>;
compact(instructions: string | undefined): Promise<void>;
abort(): Promise<void>;
cycleThinking(): Promise<void>;
setModel(model: ModelRef): Promise<void>;
toggleTasks(): Promise<void>;
/** Show and talk to another conversation. */
switchConversation(id: ConversationId): Promise<void>;
}
export interface OpenDurableOptions {
readonly cwd?: string;
readonly continueSession?: boolean;
}
export interface OpenDurableResult {
readonly view: DurableViewSource;
readonly controller: DurableController;
/** pi's settings, for the TUI's theme and terminal capabilities. */
readonly settings: SettingsManager;
close(): Promise<void>;
}
/** The agent document of a view; absent while the conversation has none. */
export function agentOf(view: ConversationView): AgentState {
return (view.docs["pi.agent"] ?? {}) as AgentState;
}
/** A subagent's task: the oldest user message of its conversation. The main conversation needs no title. */
async function firstInput(harness: Harness, id: ConversationId): Promise<{ title?: string }> {
if (id === ROOT_CONVERSATION_ID) return {};
const conversation = (await harness.conversation(id, context))!;
let first: EntryRecord | undefined;
let cursor: Cursor | undefined;
do {
const page = await conversation.entries({}, 256, cursor, context);
first = page.items.findLast((entry) => entry.kind === "pi.user") ?? first;
cursor = page.next;
} while (cursor !== undefined);
return titleOf(first);
}
/** The text of a user entry, as a one-line title. */
function titleOf(entry: EntryRecord | undefined): { title?: string } {
const message = entry?.model?.[0];
if (message?.role !== "user") return {};
const text =
typeof message.content === "string"
? message.content
: message.content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join(" ");
return { title: text.replace(/\s+/g, " ").trim() };
}
export async function openDurable(options: OpenDurableOptions = {}): Promise<OpenDurableResult> {
const location = await selectSession(options.cwd ?? process.cwd(), options.continueSession ?? false);
let harness: Harness | undefined;
try {
const modelRuntime = await ModelRuntime.create();
const settingsManager = SettingsManager.create(location.cwd);
configureHarnessHttp(settingsManager);
const settings = createHarnessSettings(settingsManager);
const registry = createVacationRegistry();
const pendingReports: unknown[] = [];
let report: (error: unknown) => void = (error) => pendingReports.push(error);
harness = await Harness.open(
await openNodeSqliteStorage(location.database),
{
models: modelRuntime,
registry,
settings,
onReport: (error) => report(error),
},
context,
);
const initial = location.created ? await findInitialAgentModel(settingsManager, modelRuntime) : undefined;
const root = await harness.root(context, {
agent: {
// Only the vacation planner; the research subagent selects search itself.
extensions: [Vacation],
...(initial?.model === undefined ? {} : { model: initial.model }),
...(initial?.thinkingLevel === undefined ? {} : { thinkingLevel: initial.thinkingLevel }),
},
});
const label = (id: ConversationId): string => (id === root.id ? "main" : `subagent ${id}`);
const opened = harness;
const summaries: ConversationSummary[] = [];
let cursor: Cursor | undefined;
do {
const page = await opened.commit((tx) => tx.scanConversations({}, 256, cursor), context);
for (const { id } of page.items) summaries.push({ id, label: label(id), ...(await firstInput(opened, id)) });
cursor = page.next;
} while (cursor !== undefined);
let current: Conversation = root;
let conversation: AttachedReplicatedState<ConversationView> = await root.viewState(context);
const models = (): ModelSummary[] =>
modelRuntime.getAvailableSnapshot().map((model) => ({
provider: model.provider,
modelId: model.id,
name: model.name,
contextWindow: model.contextWindow,
}));
let state: DurableView = {
session: { id: location.id, directory: location.directory, cwd: location.cwd },
conversation: conversation.value,
conversations: summaries,
models: models(),
notices: [],
};
const listeners = new Set<() => void>();
let notifying = false;
// Commit listeners and Chord frames call this on the Session line; rendering runs afterwards, once per burst.
const update = (patch: Partial<DurableView>): void => {
state = { ...state, ...patch };
if (notifying) return;
notifying = true;
setImmediate(() => {
notifying = false;
for (const listener of listeners) listener();
});
};
let nextNotice = 1;
const notice = (level: Notice["level"], message: string): void => {
update({ notices: [...state.notices, { id: nextNotice++, level, message }].slice(-20) });
};
const fail = (error: unknown): void => notice("error", error instanceof Error ? error.message : String(error));
report = (error) => notice("warning", error instanceof Error ? error.message : String(error));
for (const error of pendingReports) report(error);
let unsubscribe = conversation.subscribe((value) => update({ conversation: value }));
// Subagents appear as their conversations are created. A commit listener only records; it calls no Session API.
const unsubscribeCommits = harness.subscribeCommits((publication) => {
let conversations = state.conversations;
for (const change of publication.changes) {
if (change.type === "conversation") {
conversations = [...conversations, { id: change.value.id, label: label(change.value.id) }];
} else if (change.type === "entry" && change.value.kind === "pi.user") {
const id = change.value.conversationId;
conversations = conversations.map((summary) =>
summary.id === id && summary.title === undefined ? { ...summary, ...titleOf(change.value) } : summary,
);
}
}
if (conversations !== state.conversations) update({ conversations });
});
let tasks: AttachedReplicatedState<TaskGraph> | undefined;
let unsubscribeTasks = (): void => {};
const closeTasks = (): void => {
unsubscribeTasks();
tasks?.dispose();
tasks = undefined;
};
let queue = Promise.resolve();
// One at a time, so toggles, switches, and key presses apply in order.
const command = (operation: () => Promise<void>): Promise<void> => {
queue = queue.then(operation).catch(fail);
return queue;
};
const watchAnswer = (submission: Submission): void => {
void submission.wait(context).then((settled) => {
if (settled.status === "unanswered" && settled.reason !== "aborted") {
notice(
"error",
`No answer: ${settled.reason}${settled.detail === undefined ? "" : ` ${JSON.stringify(settled.detail)}`}`,
);
}
}, fail);
};
const agentModel = () => {
const ref = agentOf(state.conversation).model;
const model = ref === undefined ? undefined : modelRuntime.getModel(ref.provider, ref.modelId);
if (model === undefined)
throw new Error(ref === undefined ? "No model selected" : "Current model is unavailable");
return model;
};
const controller: DurableController = {
submit: (text, whenBusy) =>
command(async () => watchAnswer(await current.submit({ type: "input", content: text, whenBusy }, context))),
compact: (instructions) =>
command(async () => {
const id = await current.compact(instructions, context);
// Report the outcome once it is known; the status line shows the compaction meanwhile.
void opened.waitForTask(id, context).then(async (receipt) => {
const outcome = receipt.state.outcome;
if (outcome.status === "completed") {
const { entryId, submissionId } = outcome.result;
// A summary written while busy is a submission: placed now, queued, or dropped as stale.
const status =
submissionId === undefined
? undefined
: (await (await opened.submission(submissionId, context))?.status(context))?.status;
notice(
"info",
entryId !== undefined || status === "done"
? "Compacted."
: status === "queued"
? "Compaction summary queued; it is placed at the next turn boundary."
: status === "unanswered"
? "Compaction summary dropped: the context changed under it."
: "Nothing to compact: the context fits in the recent window that is kept verbatim.",
);
} else if (outcome.status === "aborted") notice("info", "Compaction aborted.");
else
notice("error", `Compaction ${outcome.status}: ${outcome.error?.message ?? outcome.reason ?? ""}`);
}, fail);
}),
// Not queued: it waits until the conversation is idle.
abort: () => current.abort(context).catch(fail),
cycleThinking: () =>
command(async () => {
const model = agentModel();
if (!model.reasoning) throw new Error("Current model does not support thinking");
const levels = getSupportedThinkingLevels(model);
const level = agentOf(state.conversation).thinkingLevel ?? "off";
const next = levels[(levels.indexOf(level) + 1) % levels.length] ?? "off";
await current.configure({ thinkingLevel: next }, context);
}),
setModel: (ref) =>
command(async () => {
const model = modelRuntime.getModel(ref.provider, ref.modelId);
if (model === undefined) throw new Error(`Unknown model: ${ref.provider}/${ref.modelId}`);
const thinking: ModelThinkingLevel = agentOf(state.conversation).thinkingLevel ?? "off";
await current.configure({ model: ref, thinkingLevel: clampThinkingLevel(model, thinking) }, context);
}),
toggleTasks: () =>
command(async () => {
if (tasks !== undefined) {
closeTasks();
update({ tasks: undefined });
return;
}
const graph = await opened.taskGraph(context);
tasks = graph;
unsubscribeTasks = graph.subscribe((value) => update({ tasks: value }));
}),
switchConversation: (id) =>
command(async () => {
const next = await opened.conversation(id, context);
if (next === undefined) throw new Error(`Conversation ${id} does not exist`);
const nextState = await next.viewState(context);
unsubscribe();
conversation.dispose();
current = next;
conversation = nextState;
unsubscribe = nextState.subscribe((value) => update({ conversation: value }));
}),
};
const saved = agentOf(state.conversation).model;
if (saved === undefined) notice("warning", "No model configured; select one with /model.");
else if (modelRuntime.getModel(saved.provider, saved.modelId) === undefined) {
notice("warning", `Saved model is unavailable: ${saved.provider}/${saved.modelId}`);
}
if (initial?.fallbackMessage !== undefined) notice("info", initial.fallbackMessage);
// The task panel starts open; /tasks hides it.
await controller.toggleTasks();
// Recovered work from an interrupted turn continues now.
harness.resume();
let closing: Promise<void> | undefined;
return {
view: {
current: () => state,
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
},
controller,
settings: settingsManager,
close() {
closing ??= (async () => {
unsubscribe();
unsubscribeCommits();
conversation.dispose();
closeTasks();
try {
// Close writes no outcome: a running turn resumes with --continue.
await opened.close(context);
} finally {
await location.release();
}
})();
return closing;
},
};
} catch (error) {
await harness?.close(context).catch(() => {});
await location.release().catch(() => {});
throw error;
}
}

View file

@ -0,0 +1,56 @@
import { createHash, randomUUID } from "node:crypto";
import { mkdir, readdir, realpath } from "node:fs/promises";
import { basename, join, resolve } from "node:path";
import lockfile from "proper-lockfile";
import { getAgentDir } from "../../config.ts";
/** One session directory holding `session.sqlite`, locked by this process. */
export interface SessionLocation {
id: string;
directory: string;
database: string;
cwd: string;
created: boolean;
release(): Promise<void>;
}
/** A new session for `cwd`, or its newest one with `continueSession`. */
export async function selectSession(cwdInput: string, continueSession: boolean): Promise<SessionLocation> {
const cwd = await realpath(resolve(cwdInput));
const root = join(
getAgentDir(),
"experimental",
"vacation-sessions",
createHash("sha256").update(cwd).digest("hex").slice(0, 24),
);
await mkdir(root, { recursive: true });
let directory: string;
let created = false;
if (continueSession) {
const entries = await readdir(root, { withFileTypes: true });
const newest = entries
.filter((entry) => entry.isDirectory() && /^\d{13}-[0-9a-f-]{36}$/u.test(entry.name))
.map((entry) => entry.name)
.sort()
.at(-1);
if (!newest) throw new Error(`No vacation session exists for ${cwd}`);
directory = join(root, newest);
} else {
directory = join(root, `${String(Date.now()).padStart(13, "0")}-${randomUUID()}`);
await mkdir(directory);
created = true;
}
let release: () => Promise<void>;
try {
// A lock left by a crashed process goes stale after 10 s; wait that long before giving up.
release = await lockfile.lock(directory, {
realpath: false,
retries: { retries: 12, minTimeout: 1000, maxTimeout: 1000 },
});
} catch (error) {
throw new Error(`Session is already open in another process: ${directory}`, { cause: error });
}
return { id: basename(directory), directory, database: join(directory, "session.sqlite"), cwd, created, release };
}

View file

@ -0,0 +1,655 @@
import type { AssistantMessage, ToolResultMessage, Usage, UserMessage } from "@earendil-works/pi-ai";
import type {
ConversationId,
EntryRecord,
InboxState,
LiveState,
TaskGraph,
TaskGraphNode,
UsageState,
} from "@earendil-works/pi-durable";
import {
Box,
type Component,
Container,
type Focusable,
fuzzyFilter,
getKeybindings,
Input,
Markdown,
ProcessTerminal,
ScrollView,
type SelectItem,
SelectList,
type SelectListTheme,
Spacer,
setCapabilityOverrides,
setKeybindings,
Text,
TruncatedText,
TuiAltScreen,
VStack,
} from "@earendil-works/pi-tui";
import { getAgentDir } from "../../config.ts";
import { KeybindingsManager } from "../../core/keybindings.ts";
import type { SettingsManager } from "../../core/settings-manager.ts";
import { createAllToolRenderers } from "../../core/tools/renderers/index.ts";
import { AssistantMessageComponent } from "../../modes/interactive/components/assistant-message.ts";
import { CustomEditor } from "../../modes/interactive/components/custom-editor.ts";
import { DynamicBorder } from "../../modes/interactive/components/dynamic-border.ts";
import { formatTokens } from "../../modes/interactive/components/footer.ts";
import { keyText } from "../../modes/interactive/components/keybinding-hints.ts";
import { type StatusIndicator, WorkingStatusIndicator } from "../../modes/interactive/components/status-indicator.ts";
import { ToolExecutionComponent, type ToolRenderers } from "../../modes/interactive/components/tool-execution.ts";
import { UserMessageComponent } from "../../modes/interactive/components/user-message.ts";
import { getEditorTheme, getMarkdownTheme, initTheme, theme } from "../../modes/interactive/theme/theme.ts";
import { InteractiveThemeController } from "../../modes/interactive/theme/theme-controller.ts";
import { agentOf, type DurableController, type DurableView, type DurableViewSource } from "./runtime.ts";
const SELECT_THEME: SelectListTheme = {
selectedPrefix: (text) => theme.fg("accent", text),
selectedText: (text) => theme.fg("accent", text),
description: (text) => theme.fg("muted", text),
scrollInfo: (text) => theme.fg("dim", text),
noMatch: (text) => theme.fg("warning", text),
};
/** A filterable list in place of the editor. */
class ListSelector extends Container implements Focusable {
readonly #input = new Input();
readonly #listContainer = new Container();
readonly #items: SelectItem[];
readonly #onSelect: (value: string) => void;
readonly #onCancel: () => void;
#list: SelectList;
#focused = false;
constructor(title: string, items: SelectItem[], onSelect: (value: string) => void, onCancel: () => void) {
super();
this.#items = items;
this.#onSelect = onSelect;
this.#onCancel = onCancel;
this.#list = this.#build(items);
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
this.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
this.addChild(this.#input);
this.addChild(new Spacer(1));
this.addChild(this.#listContainer);
this.addChild(new DynamicBorder());
}
get focused(): boolean {
return this.#focused;
}
set focused(value: boolean) {
this.#focused = value;
this.#input.focused = value;
}
handleInput(data: string): void {
const keybindings = getKeybindings();
const forwarded = ["tui.select.up", "tui.select.down", "tui.select.confirm", "tui.select.cancel"] as const;
if (forwarded.some((action) => keybindings.matches(data, action))) {
this.#list.handleInput(data);
return;
}
this.#input.handleInput(data);
const query = this.#input.getValue();
const filtered =
query.length === 0 ? this.#items : fuzzyFilter(this.#items, query, (item) => `${item.label} ${item.value}`);
this.#list = this.#build(filtered);
}
#build(items: SelectItem[]): SelectList {
const list = new SelectList(items, 10, SELECT_THEME);
list.onSelect = (item) => this.#onSelect(item.value);
list.onCancel = this.#onCancel;
this.#listContainer.clear();
this.#listContainer.addChild(list);
return list;
}
}
/** The summary that replaced earlier context: collapsed to one line until expanded. */
class CompactionComponent extends Box {
readonly #summary: string;
constructor(summary: string, expanded: boolean) {
super(1, 1, (text) => theme.bg("customMessageBg", text));
this.#summary = summary;
this.setExpanded(expanded);
}
setExpanded(expanded: boolean): void {
this.clear();
this.addChild(new Text(theme.fg("customMessageLabel", theme.bold("[compaction]")), 0, 0));
this.addChild(new Spacer(1));
this.addChild(
expanded
? new Markdown(this.#summary, 0, 0, getMarkdownTheme(), {
color: (text: string) => theme.fg("customMessageText", text),
})
: new Text(
theme.fg("customMessageText", "Earlier context summarized (") +
theme.fg("dim", keyText("app.tools.expand")) +
theme.fg("customMessageText", " to expand)"),
0,
0,
),
);
}
}
interface Handlers {
submit(text: string): void;
followUp(text: string): void;
abort(): void;
exit(): void;
selectModel(): void;
cycleThinking(): void;
}
class DurableTui {
static readonly #renderers: Record<string, ToolRenderers> = createAllToolRenderers();
readonly #ui: TuiAltScreen;
readonly #chat = new Container();
readonly #tasks = new Container();
readonly #queue = new Container();
readonly #notices = new Container();
readonly #footer = new Container();
readonly #footerStats = new Text("", 1, 0);
readonly #footerHints = new Text("", 1, 0);
readonly #editorContainer = new Container();
readonly #editor: CustomEditor;
readonly #cwd: string;
/** The newest card per call ID; provider call IDs may repeat across turns. */
readonly #tools = new Map<string, ToolExecutionComponent>();
/** Every card shown, also older ones whose call ID a later turn reused. */
readonly #cards: ToolExecutionComponent[] = [];
/** Call IDs whose cards the streaming answer created; its entry takes them over. */
readonly #streamingCalls = new Set<string>();
readonly #summaries: CompactionComponent[] = [];
/** Tool output and summaries shown in full; toggled like pi. */
#expanded = false;
#renderedEntryIds: number[] = [];
#streaming: AssistantMessageComponent | undefined;
#indicator: StatusIndicator | undefined;
#statusText = "";
/** Set when the transcript was rebuilt: the next render repaints the screen and shows the end. */
#rebuilt = false;
#transcript: ScrollView;
constructor(cwd: string, handlers: Handlers) {
this.#cwd = cwd;
this.#ui = new TuiAltScreen(new ProcessTerminal(), false, getAgentDir());
const keybindings = KeybindingsManager.create();
setKeybindings(keybindings);
this.#editor = new CustomEditor(this.#ui, getEditorTheme(), keybindings, {
paddingX: 1,
embedWorkingStatus: true,
});
this.#editor.onSubmit = handlers.submit;
this.#editor.onEscape = handlers.abort;
this.#editor.onCtrlD = handlers.exit;
this.#editor.onAction("app.clear", handlers.exit);
this.#editor.onAction("app.model.select", handlers.selectModel);
this.#editor.onAction("app.thinking.cycle", handlers.cycleThinking);
this.#editor.onAction("app.tools.expand", () => {
this.#expanded = !this.#expanded;
for (const component of [...this.#cards, ...this.#summaries]) component.setExpanded(this.#expanded);
this.#ui.requestRender();
});
this.#editor.onAction("app.message.followUp", () => {
const text = this.#editor.getText().trim();
if (!text) return;
this.#editor.setText("");
handlers.followUp(text);
});
this.#editorContainer.addChild(this.#editor);
this.#footer.addChild(this.#footerStats);
this.#footer.addChild(this.#footerHints);
// One empty line between the transcript and everything below it.
const content = new Container();
content.addChild(this.#chat);
content.addChild(new Spacer(1));
const transcript = new ScrollView(content, { follow: "end", primary: true, overscroll: "chain" });
this.#transcript = transcript;
const dock = new VStack([
{ component: this.#tasks, shrink: 1, minSize: 0 },
{ component: this.#queue, shrink: 1, minSize: 0 },
{ component: this.#notices, shrink: 1, minSize: 0 },
{ component: this.#editorContainer, shrink: 1, minSize: 3 },
{ component: this.#footer, shrink: 1, minSize: 0 },
]);
for (const component of [
this.#chat,
this.#tasks,
this.#queue,
this.#notices,
this.#editorContainer,
this.#footer,
]) {
this.#ui.addChild(component);
}
this.#ui.setLayoutRoot(
new VStack([
{ component: transcript, basis: 0, grow: 1, shrink: 1, minSize: 1 },
{ component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 },
]),
);
this.#ui.setFocus(this.#editor);
}
get ui(): TuiAltScreen {
return this.#ui;
}
start(): void {
this.#ui.start();
}
stop(): void {
this.#discardTools();
this.#indicator?.dispose();
this.#ui.stop();
}
/** Finish every card: a running bash card keeps a timer until it gets a final result. */
#discardTools(): void {
for (const component of this.#cards) component.updateResult({ content: [], isError: false }, false);
this.#cards.length = 0;
this.#tools.clear();
this.#streamingCalls.clear();
}
mount(component: Component): void {
this.#editorContainer.clear();
this.#editorContainer.addChild(component);
this.#ui.setFocus(component);
this.#ui.requestRender();
}
restoreEditor(): void {
this.mount(this.#editor);
}
apply(view: DurableView): void {
const live = (view.conversation.docs["pi.live"] ?? {}) as LiveState;
this.#syncTranscript(view.conversation.entries);
const message = live.generation?.message as AssistantMessage | undefined;
// A partial without its entry was dropped, for example by a retry: render the transcript again.
if (message === undefined && this.#streaming !== undefined) this.#rebuild(view.conversation.entries);
if (message !== undefined) this.#syncStreaming(message);
for (const slot of live.tools ?? []) {
if (slot.status === "pending") continue;
const component = this.#tool(slot.name, slot.callId);
component.setArgsComplete();
if (slot.status !== "running") continue;
component.markExecutionStarted();
const child = (slot.details as { conversationId?: number } | undefined)?.conversationId;
if (slot.output === undefined && child !== undefined) {
const text = `Subagent ${child} is working. /agents switches to it.`;
component.updateResult({ content: [{ type: "text", text }], details: slot.details, isError: false }, true);
} else if (slot.output !== undefined) {
component.updateResult(
{ content: [{ type: "text", text: slot.output }], details: slot.details, isError: false },
true,
);
}
}
this.#syncTasks(view.tasks);
this.#syncQueue((view.conversation.docs["pi.inbox"] ?? { items: [] }) as InboxState);
this.#syncNotices(view);
this.#editor.borderColor = theme.getThinkingBorderColor(agentOf(view.conversation).thinkingLevel ?? "off");
this.#syncStatus(live);
this.#syncFooter(view);
if (this.#rebuilt) this.#transcript.scrollToEnd();
this.#ui.requestRender(this.#rebuilt);
this.#rebuilt = false;
}
#syncTasks(graph: TaskGraph | undefined): void {
this.#tasks.clear();
if (graph === undefined) return;
const nodes = Object.values(graph.tasks);
const lines: string[] = [theme.fg("accent", `Tasks (${nodes.length} live, /tasks to hide)`)];
// A conversation-owned task sits under the task that owns its conversation, when that task is live.
const owned = new Set(nodes.flatMap((node) => node.conversations));
const children = (node: TaskGraphNode) =>
nodes.filter(
(candidate) =>
candidate.owner === node.id ||
(candidate.owner === undefined && node.conversations.includes(candidate.conversationId)),
);
const visit = (node: TaskGraphNode, depth: number): void => {
lines.push(`${" ".repeat(depth + 1)}${describeTask(node)}`);
for (const child of children(node)) visit(child, depth + 1);
};
for (const node of nodes) {
if (node.owner === undefined && !owned.has(node.conversationId)) visit(node, 0);
}
for (const line of lines) this.#tasks.addChild(new TruncatedText(theme.fg("muted", line), 1, 0));
}
#syncQueue(inbox: InboxState): void {
this.#queue.clear();
for (const item of inbox.items) {
const text =
item.mode === "write" ? `<${String(item.entry.kind)}>` : userText(item.content as UserMessage["content"]);
this.#queue.addChild(new TruncatedText(theme.fg("muted", `[${item.mode}] ${text}`), 1, 0));
}
}
#syncNotices(view: DurableView): void {
this.#notices.clear();
for (const item of view.notices.slice(-4)) {
const color = item.level === "error" ? "error" : item.level === "warning" ? "warning" : "muted";
this.#notices.addChild(new TruncatedText(theme.fg(color, item.message), 1, 0));
}
}
#syncStatus(live: LiveState): void {
const generation = live.generation;
const compaction = live.compactions?.[0];
const runningTool = live.tools?.find((slot) => slot.status === "running");
let text = "";
if (generation?.retry !== undefined) {
text = `Retrying (attempt ${generation.attempt + 1}): ${generation.retry.error}`;
} else if (generation?.deferred !== undefined) text = "Waiting for deferred response...";
else if (compaction !== undefined) {
text = compaction.retry
? `Retrying ${compaction.reason} compaction (attempt ${compaction.attempt + 1})...`
: `Compacting (${compaction.reason})...`;
} else if (runningTool !== undefined) text = `Running ${runningTool.name}... (esc to abort)`;
else if (live.run !== undefined) text = "Working... (esc to abort)";
if (text === this.#statusText) return;
this.#statusText = text;
this.#indicator?.dispose();
this.#indicator = text
? new WorkingStatusIndicator(this.#ui, text, undefined, (part) => this.#editor.borderColor(part))
: undefined;
this.#editor.setWorkingStatusIndicator(this.#indicator);
}
#syncFooter(view: DurableView): void {
const agent = agentOf(view.conversation);
const usage = totalUsage((view.conversation.docs["pi.usage"] ?? { models: {}, tools: {} }) as UsageState);
const stats: string[] = [];
if (usage.input) stats.push(`↑${formatTokens(usage.input)}`);
if (usage.output) stats.push(`↓${formatTokens(usage.output)}`);
if (usage.cacheRead) stats.push(`R${formatTokens(usage.cacheRead)}`);
if (usage.cacheWrite) stats.push(`W${formatTokens(usage.cacheWrite)}`);
stats.push(`$${usage.cost.total.toFixed(3)}`);
const contextWindow =
view.models.find((model) => model.provider === agent.model?.provider && model.modelId === agent.model.modelId)
?.contextWindow ?? 0;
if (contextWindow > 0) {
const tokens = contextTokens(view.conversation.entries);
const percent = tokens === undefined ? undefined : (tokens / contextWindow) * 100;
const text = `${percent === undefined ? "?" : percent.toFixed(1)}%/${formatTokens(contextWindow)}`;
stats.push(percent !== undefined && percent > 90 ? theme.fg("error", text) : text);
}
this.#footerStats.setText(theme.fg("dim", `${stats.join(" ")} ${view.session.cwd}`));
const model = agent.model === undefined ? "no model" : `${agent.model.provider}/${agent.model.modelId}`;
const shown = view.conversations.find((candidate) => candidate.id === view.conversation.conversation.id);
const label = shown?.label ?? `conversation ${view.conversation.conversation.id}`;
this.#footerHints.setText(
`${theme.fg(label === "main" ? "dim" : "accent", label)}${theme.fg(
"dim",
` · ${model} · thinking:${agent.thinkingLevel ?? "off"} (${keyText("app.thinking.cycle")}) · ${keyText("app.model.select")} or /model · /agents · /compact · /tasks · ${keyText("app.message.followUp")} follow-up · ${keyText("app.clear")} exit`,
)}`,
);
}
#syncTranscript(entries: readonly EntryRecord[]): void {
// Compaction and resets replace the head of the active transcript.
if (this.#renderedEntryIds.some((id, index) => entries[index]?.id !== id)) this.#rebuild(entries);
for (const entry of entries.slice(this.#renderedEntryIds.length)) {
this.#addEntry(entry);
this.#renderedEntryIds.push(entry.id);
}
}
#rebuild(entries: readonly EntryRecord[]): void {
this.#chat.clear();
this.#discardTools();
this.#summaries.length = 0;
this.#rebuilt = true;
this.#renderedEntryIds = [];
this.#streaming = undefined;
for (const entry of entries) {
this.#addEntry(entry);
this.#renderedEntryIds.push(entry.id);
}
}
#addEntry(entry: EntryRecord): void {
const message = entry.model?.[0];
if (entry.kind === "pi.user" && message?.role === "user") {
this.#chat.addChild(new Spacer(1));
this.#chat.addChild(new UserMessageComponent(userText(message.content)));
} else if (entry.kind === "pi.assistant" && message?.role === "assistant") {
const component = this.#streaming ?? new AssistantMessageComponent();
if (this.#streaming === undefined) this.#chat.addChild(component);
this.#streaming = undefined;
component.updateContent(message, false);
// Only a tool-calling answer runs its calls; an aborted, failed, or truncated one never does.
const ran = message.stopReason === "toolUse";
for (const content of message.content) {
if (content.type !== "toolCall") continue;
const streamed = this.#streamingCalls.has(content.id);
// Only cards the stream already showed are kept for calls that never run.
if (!ran && !streamed) continue;
const card = this.#tool(content.name, content.id, content.arguments, !streamed);
card.setArgsComplete();
if (!ran) {
const text = "Not run: the answer was interrupted.";
card.updateResult({ content: [{ type: "text", text }], isError: true }, false);
}
}
this.#streamingCalls.clear();
} else if (entry.kind === "pi.tool-result" && message?.role === "toolResult") {
const result = message as ToolResultMessage;
this.#tool(result.toolName, result.toolCallId).updateResult(result);
} else if (entry.kind === "pi.compaction") {
const summary = new CompactionComponent(
message?.role === "user" ? userText(message.content) : "",
this.#expanded,
);
this.#summaries.push(summary);
this.#chat.addChild(new Spacer(1));
this.#chat.addChild(summary);
} else if (entry.kind === "pi.reset") this.#addText("[new context]");
}
#syncStreaming(message: AssistantMessage): void {
if (this.#streaming === undefined) {
this.#streaming = new AssistantMessageComponent();
this.#chat.addChild(this.#streaming);
}
this.#streaming.updateContent(message, true);
for (const content of message.content) {
if (content.type !== "toolCall") continue;
this.#tool(content.name, content.id, content.arguments, !this.#streamingCalls.has(content.id));
this.#streamingCalls.add(content.id);
}
}
#addText(text: string): void {
this.#chat.addChild(new Spacer(1));
this.#chat.addChild(new Text(theme.fg("muted", text), 1, 0));
}
/** The card of a call; `fresh` starts a new one for a call ID an earlier turn used. */
#tool(name: string, callId: string, args?: unknown, fresh = false): ToolExecutionComponent {
const existing = fresh ? undefined : this.#tools.get(callId);
if (existing !== undefined) {
if (args !== undefined) existing.updateArgs(args);
return existing;
}
const component = new ToolExecutionComponent(
name,
callId,
args ?? {},
{},
DurableTui.#renderers[name],
this.#ui,
this.#cwd,
);
component.setExpanded(this.#expanded);
this.#chat.addChild(component);
this.#cards.push(component);
this.#tools.set(callId, component);
return component;
}
}
function describeTask(node: TaskGraphNode): string {
const state = node.state;
const status =
state.status === "waiting"
? `waiting on ${state.on.join(", ")}`
: state.status === "completing"
? `completing (${state.outcome})`
: `${state.status} ${state.phase}`;
const flags = [node.background ? "background" : "", node.abortRequested ? "aborting" : ""].filter(Boolean);
const owned = node.conversations.length > 0 ? ` owns conversation ${node.conversations.join(", ")}` : "";
return `${node.kind} #${node.id}: ${status}${flags.length > 0 ? ` [${flags.join(", ")}]` : ""}${owned}`;
}
function userText(content: UserMessage["content"]): string {
if (typeof content === "string") return content;
return content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("");
}
function totalUsage(state: UsageState): Usage {
const total: Usage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
for (const usage of [...Object.values(state.models), ...Object.values(state.tools)]) {
total.input += usage.input;
total.output += usage.output;
total.cacheRead += usage.cacheRead;
total.cacheWrite += usage.cacheWrite;
total.cost.total += usage.cost.total;
}
return total;
}
/** Context size from the newest successful answer after the newest compaction; unknown before one. */
function contextTokens(entries: readonly EntryRecord[]): number | undefined {
// Kept entries follow the summary in the view but are older than it; only later answers measure the new context.
const compacted = Math.max(0, ...entries.filter((entry) => entry.kind === "pi.compaction").map((entry) => entry.id));
for (const entry of [...entries].reverse()) {
const message = entry.model?.[0];
if (entry.id < compacted || entry.kind !== "pi.assistant" || message?.role !== "assistant") continue;
if (message.stopReason === "aborted" || message.stopReason === "error") continue;
const usage = message.usage;
return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
}
return undefined;
}
export async function runDurableTui(
source: DurableViewSource,
controller: DurableController,
settings: SettingsManager,
): Promise<void> {
setCapabilityOverrides(settings.getTerminalCapabilityOverrides());
// The system theme until the controller resolves the user's theme against the terminal's colors.
initTheme();
let exit = (): void => {};
const exited = new Promise<void>((resolve) => {
exit = resolve;
});
let view!: DurableTui;
const selectModel = (): void => {
const snapshot = source.current();
const current = agentOf(snapshot.conversation).model;
const isCurrent = (model: { provider: string; modelId: string }) =>
model.provider === current?.provider && model.modelId === current.modelId;
const items: SelectItem[] = [...snapshot.models]
.sort((left, right) => Number(isCurrent(right)) - Number(isCurrent(left)))
.map((model) => ({
value: `${model.provider}/${model.modelId}`,
label: model.modelId,
description: model.provider,
}));
const selector = new ListSelector(
"Select model:",
items,
(value) => {
view.restoreEditor();
const separator = value.indexOf("/");
void controller.setModel({ provider: value.slice(0, separator), modelId: value.slice(separator + 1) });
},
() => view.restoreEditor(),
);
view.mount(selector);
};
const selectConversation = (): void => {
const snapshot = source.current();
const items: SelectItem[] = [...snapshot.conversations].reverse().map((candidate) => ({
value: String(candidate.id),
label: candidate.label,
description: `${candidate.id === snapshot.conversation.conversation.id ? "(shown) " : ""}${candidate.title ?? ""}`,
}));
const selector = new ListSelector(
"Switch to:",
items,
(value) => {
view.restoreEditor();
void controller.switchConversation(Number(value) as ConversationId);
},
() => view.restoreEditor(),
);
view.mount(selector);
};
view = new DurableTui(source.current().session.cwd, {
submit: (text) => {
const trimmed = text.trim();
if (!trimmed) return;
if (trimmed === "/model") return selectModel();
if (trimmed === "/tasks") return void controller.toggleTasks();
if (trimmed === "/agents") return selectConversation();
if (trimmed === "/compact" || trimmed.startsWith("/compact ")) {
const instructions = trimmed.slice("/compact".length).trim();
return void controller.compact(instructions || undefined);
}
void controller.submit(trimmed, "steer");
},
followUp: (text) => void controller.submit(text, "followUp"),
abort: () => void controller.abort(),
exit,
selectModel,
cycleThinking: () => void controller.cycleThinking(),
});
// pi's theme handling: the theme setting (also light/dark pairs) resolved against the terminal's reported colors.
const themes = new InteractiveThemeController(view.ui, {
getSettingsManager: () => settings,
showError: (message) => console.error(message),
onChanged: () => view.ui.requestRender(),
});
const unsubscribe = source.subscribe(() => view.apply(source.current()));
view.start();
themes.applyFromSettings();
view.apply(source.current());
await exited;
unsubscribe();
themes.dispose();
view.stop();
}

View file

@ -0,0 +1,142 @@
import { setTimeout as sleep } from "node:timers/promises";
import { Type } from "@earendil-works/pi-ai";
import {
AssistantEntry,
type ConversationId,
configure,
defineExtension,
defineTask,
defineTool,
section,
} from "@earendil-works/pi-durable";
// ─── search: slow, fake, and safe to rerun ──────────────────────────────────
/** Canned results; each topic takes a different time, so a crash can land between them. */
const SEARCHES = {
weather: {
seconds: 6,
results: ["Saturday: 24°C and sunny", "Sunday: 21°C, a short shower around 3 pm"],
},
museums: {
seconds: 10,
results: [
"Kunsthistorisches Museum: open 10-18, book a time slot",
"Belvedere: Klimt's The Kiss, quietest before 11",
"Albertina: Monet to Picasso, open until 21 on Friday",
],
},
trains: {
seconds: 30,
results: ["Railjet from Salzburg: 2h 22m, every 30 min", "Nightjet from Munich: arrives 06:20"],
},
} as const;
const search = defineTool({
name: "search",
description:
"Search travel information about a city. Topics: weather, museums, trains. Slow; call several in parallel.",
parameters: Type.Object({
topic: Type.Union([Type.Literal("weather"), Type.Literal("museums"), Type.Literal("trains")]),
city: Type.String(),
}),
// Only reads, so a call cut off by a crash simply runs again.
replay: "safe",
execute: async (args, api, context) => {
const found = SEARCHES[args.topic];
const steps = Math.ceil(found.seconds / 2);
for (let step = 1; step <= steps; step++) {
api.output(`searching ${args.topic} in ${args.city}: source ${step}/${steps}\n`);
await sleep(2000, undefined, { signal: context.abortSignal });
}
for (const result of found.results) api.output(`- ${result}\n`);
return {};
},
});
/** What the research subagent gets: only `search`. */
export const Search = defineExtension({ name: "vacation-search", tools: [search] });
// ─── research: a background subagent that reports back ──────────────────────
type ResearchState = { phase: "deliver" } | { phase: "report"; report: string };
/** Delivers the task to its subagent and posts the report to the main conversation as a new message. */
const Research = defineTask<{ task: string }, ResearchState, null>({
name: "vacation.research",
version: 1,
initial: () => ({ phase: "deliver" }),
phases: {
deliver: async (task, runtime, context) => {
// The subagent's conversation is owned by this task. No Session call may run inside a commit.
let owned: ConversationId | undefined;
await runtime.commit(async (tx) => {
owned = (await tx.scanConversations({ ownerTaskId: task.id }, 1)).items[0]?.id;
return undefined;
}, context);
const child = await runtime.conversation(owned!, context);
// A rerun after a crash gets the same submission back.
const request = { type: "input", content: task.input.task, requestId: `research:${task.id}` } as const;
const settled = await (await child!.submit(request, context)).wait(context);
await runtime.commit(async (tx) => {
let report = `[research report] The research failed: ${settled.status === "unanswered" ? settled.reason : "?"}`;
if (settled.status === "done" && settled.type === "input") {
const message = (await tx.entry(AssistantEntry, settled.answer))?.model?.[0];
const text =
message?.role === "assistant"
? message.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("")
: "";
report = `[research report] ${text}`;
}
return { status: "running", checkpoint: { phase: "report", report } };
}, context);
},
report: async (task, runtime, context) => {
const main = await runtime.conversation(runtime.conversationId, context);
const report = { type: "input", content: task.state.checkpoint.report, whenBusy: "followUp" } as const;
await main!.submit({ ...report, requestId: `research-report:${task.id}` }, context);
await runtime.commit(() => ({ status: "terminal", outcome: { status: "completed", result: null } }), context);
},
},
abort: (_task, runtime, context) =>
runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), context),
});
const research = defineTool({
name: "research",
description:
"Start a research subagent in the background. It searches while you keep talking with the user; its report arrives later as a message starting with [research report].",
parameters: Type.Object({ task: Type.String({ description: "What to research, with the city and dates" }) }),
execute: async (args, api, context) => {
await api.commit(async (tx) => {
// Background: the main conversation stays free while the research runs.
const owner = await tx.createTask(
Research,
{ task: args.task },
{ ownership: { kind: "conversation" }, background: true },
);
const child = await tx.createConversation({ ownership: { kind: "task", taskId: owner } });
await configure(tx, child.id, {
extensions: [Search],
instructions:
"You are a research subagent for a vacation planner. Search weather, museums, and trains in parallel, in one step, then report the findings in a few short bullet points.",
});
}, context);
return { content: [{ type: "text", text: "Research started in the background." }] };
},
});
/** What the main conversation gets: the prompt, `research`, and the task that delivers its report. */
export const Vacation = defineExtension({
name: "vacation",
tools: [research],
tasks: [Research],
sections: [
section(
"preamble",
() =>
"You are a friendly vacation planning assistant. Keep answers short. Hand all research to the research tool; while it runs, keep chatting with the user. When a [research report] arrives, turn it into a short plan.",
{ tag: false },
),
],
});