mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
6f1072cc08
commit
395315f484
7 changed files with 1378 additions and 0 deletions
53
packages/coding-agent/src/experimental/vacation/README.md
Normal file
53
packages/coding-agent/src/experimental/vacation/README.md
Normal 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 |
|
||||
|
|
@ -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 }),
|
||||
};
|
||||
}
|
||||
20
packages/coding-agent/src/experimental/vacation/main.ts
Normal file
20
packages/coding-agent/src/experimental/vacation/main.ts
Normal 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();
|
||||
}
|
||||
363
packages/coding-agent/src/experimental/vacation/runtime.ts
Normal file
363
packages/coding-agent/src/experimental/vacation/runtime.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
56
packages/coding-agent/src/experimental/vacation/sessions.ts
Normal file
56
packages/coding-agent/src/experimental/vacation/sessions.ts
Normal 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 };
|
||||
}
|
||||
655
packages/coding-agent/src/experimental/vacation/tui.ts
Normal file
655
packages/coding-agent/src/experimental/vacation/tui.ts
Normal 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();
|
||||
}
|
||||
142
packages/coding-agent/src/experimental/vacation/vacation.ts
Normal file
142
packages/coding-agent/src/experimental/vacation/vacation.ts
Normal 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 },
|
||||
),
|
||||
],
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue