diff --git a/packages/coding-agent/src/experimental/vacation/README.md b/packages/coding-agent/src/experimental/vacation/README.md new file mode 100644 index 000000000..82ec2c03c --- /dev/null +++ b/packages/coding-agent/src/experimental/vacation/README.md @@ -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///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 | diff --git a/packages/coding-agent/src/experimental/vacation/harness-setup.ts b/packages/coding-agent/src/experimental/vacation/harness-setup.ts new file mode 100644 index 000000000..bffa15ee5 --- /dev/null +++ b/packages/coding-agent/src/experimental/vacation/harness-setup.ts @@ -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 { + 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 }), + }; +} diff --git a/packages/coding-agent/src/experimental/vacation/main.ts b/packages/coding-agent/src/experimental/vacation/main.ts new file mode 100644 index 000000000..d3d843d5f --- /dev/null +++ b/packages/coding-agent/src/experimental/vacation/main.ts @@ -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(); +} diff --git a/packages/coding-agent/src/experimental/vacation/runtime.ts b/packages/coding-agent/src/experimental/vacation/runtime.ts new file mode 100644 index 000000000..0c9b8d268 --- /dev/null +++ b/packages/coding-agent/src/experimental/vacation/runtime.ts @@ -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; + compact(instructions: string | undefined): Promise; + abort(): Promise; + cycleThinking(): Promise; + setModel(model: ModelRef): Promise; + toggleTasks(): Promise; + /** Show and talk to another conversation. */ + switchConversation(id: ConversationId): Promise; +} + +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; +} + +/** 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 { + 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 = 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): 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 | 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): Promise => { + 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 | 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; + } +} diff --git a/packages/coding-agent/src/experimental/vacation/sessions.ts b/packages/coding-agent/src/experimental/vacation/sessions.ts new file mode 100644 index 000000000..f5dabe2a9 --- /dev/null +++ b/packages/coding-agent/src/experimental/vacation/sessions.ts @@ -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; +} + +/** A new session for `cwd`, or its newest one with `continueSession`. */ +export async function selectSession(cwdInput: string, continueSession: boolean): Promise { + 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; + 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 }; +} diff --git a/packages/coding-agent/src/experimental/vacation/tui.ts b/packages/coding-agent/src/experimental/vacation/tui.ts new file mode 100644 index 000000000..397b276cb --- /dev/null +++ b/packages/coding-agent/src/experimental/vacation/tui.ts @@ -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 = 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(); + /** 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(); + 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 { + 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((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(); +} diff --git a/packages/coding-agent/src/experimental/vacation/vacation.ts b/packages/coding-agent/src/experimental/vacation/vacation.ts new file mode 100644 index 000000000..d6fc87513 --- /dev/null +++ b/packages/coding-agent/src/experimental/vacation/vacation.ts @@ -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 }, + ), + ], +});