@tshu-w/pi-agents 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { deferred, settlesBy, waitForChange, type Deferred } from "../agents/agents.ts";
3
+ import { deferred, settlesBy, STEER_POLL_MS, WAIT_INTERRUPTED, waitForChange, type Deferred } from "../agents/agents.ts";
4
4
  import { shortId, trackTreeWork } from "../agents/registry.ts";
5
- import { boundBlocks, boundText } from "../output.ts";
6
- import type { ProgramFiles, ProgramOutcome, ProgramRunResult } from "./execute.ts";
5
+ import { boundBlocks, boundText, type Truncation } from "../output.ts";
6
+ import { CLEANUP_TIMEOUT_MS, PROGRAM_TOOL_NAME, type ProgramFiles, type ProgramOutcome, type ProgramRunResult } from "./execute.ts";
7
7
 
8
8
  const PROGRAM_ENTRY = "pi-agents-program";
9
- const CLEANUP_TIMEOUT_MS = 15_000;
10
9
  /** Ends the result of a Program some of whose work did not stop. */
11
10
  export const CLEANUP_ERROR = "Cleanup error: some of the Program's work did not stop within the timeout and may still be running.";
12
11
 
@@ -22,13 +21,25 @@ export interface ProgramRecord {
22
21
  isError?: boolean;
23
22
  /** Files the Program's tool calls used, for compaction. */
24
23
  files?: ProgramFiles;
24
+ /** The tool calls its result lists, which the task viewer shows after a restart. */
25
+ calls?: unknown[];
25
26
  /** Whether `wait` has returned the result. */
26
27
  returned: boolean;
27
28
  notified: boolean;
28
29
  }
29
30
 
31
+ /** What the task viewer shows of a Program, as its tool call shows in the foreground. */
32
+ export interface ProgramView {
33
+ record: ProgramRecord;
34
+ code?: string;
35
+ /** The latest partial result while it runs, then its result. */
36
+ result?: ProgramRunResult["result"];
37
+ }
38
+
30
39
  interface Running {
31
40
  record: ProgramRecord;
41
+ code?: string;
42
+ result?: ProgramRunResult["result"];
32
43
  controller?: AbortController;
33
44
  done?: Promise<void>;
34
45
  /** Stops counting the Program as the tree's work. */
@@ -41,6 +52,8 @@ interface Running {
41
52
  export interface ProgramWaitOutcome {
42
53
  results: ProgramRecord[];
43
54
  running: ProgramRecord[];
55
+ /** A steer ended the wait. */
56
+ interrupted?: boolean;
44
57
  }
45
58
 
46
59
  function ended(record: ProgramRecord): boolean {
@@ -58,6 +71,7 @@ export class Programs {
58
71
  private programs = new Map<string, Running>();
59
72
  private change: Deferred = deferred();
60
73
  private closing = false;
74
+ private viewers = new Set<() => void>();
61
75
 
62
76
  constructor(
63
77
  private rootId: string,
@@ -70,11 +84,27 @@ export class Programs {
70
84
  ) {}
71
85
 
72
86
  restore(ctx: ExtensionContext): void {
87
+ // A Program's code is in the `run` call that started it, whose result names the Program.
88
+ const codes = new Map<string, string>();
89
+ const callIds = new Map<string, string>();
73
90
  for (const entry of ctx.sessionManager.getEntries()) {
91
+ if (entry.type === "message" && entry.message.role === "assistant") {
92
+ for (const part of entry.message.content) {
93
+ const code = part.type === "toolCall" && part.name === PROGRAM_TOOL_NAME ? (part.arguments as { code?: unknown }).code : undefined;
94
+ if (typeof code === "string") codes.set((part as { id: string }).id, code);
95
+ }
96
+ } else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === PROGRAM_TOOL_NAME) {
97
+ const id = (entry.message.details as { id?: unknown } | undefined)?.id;
98
+ if (typeof id === "string") callIds.set(id, entry.message.toolCallId);
99
+ }
74
100
  if (entry.type !== "custom" || entry.customType !== PROGRAM_ENTRY) continue;
75
101
  const record = entry.data as ProgramRecord;
76
102
  this.programs.set(record.id, { record, waiters: 0 });
77
103
  }
104
+ for (const program of this.programs.values()) {
105
+ const callId = callIds.get(program.record.id);
106
+ program.code = callId === undefined ? undefined : codes.get(callId);
107
+ }
78
108
  for (const program of this.programs.values()) {
79
109
  const { record } = program;
80
110
  if (ended(record)) continue;
@@ -86,19 +116,25 @@ export class Programs {
86
116
  }
87
117
  }
88
118
 
89
- /** Starts a background Program, which `run` executes with its ID, and returns its record. */
90
- start(run: (id: string, signal: AbortSignal, abort: () => void) => Promise<ProgramRunResult>): ProgramRecord {
119
+ /** Starts a background Program, which `run` executes with its ID and reports its partial results to `update`, and returns its record. */
120
+ start(code: string, run: (id: string, signal: AbortSignal, abort: () => void, update: (result: unknown) => void) => Promise<ProgramRunResult>): ProgramRecord {
91
121
  const record: ProgramRecord = { id: randomUUID(), state: "running", startedAt: Date.now(), returned: false, notified: false };
92
122
  const controller = new AbortController();
93
- const program: Running = { record, controller, waiters: 0 };
123
+ const program: Running = { record, code, controller, waiters: 0 };
124
+ const update = (result: unknown) => {
125
+ if (ended(record)) return;
126
+ program.result = result as Running["result"];
127
+ this.viewed();
128
+ };
94
129
  this.programs.set(record.id, program);
95
130
  // The tree stays busy until the Program ends and its notification is delivered.
96
131
  const release = program.release = trackTreeWork(this.rootId);
97
132
  this.persist(record);
98
133
  this.hooks.changed?.();
99
- program.done = run(record.id, controller.signal, () => controller.abort(new Error("Program stopped"))).then(
134
+ program.done = run(record.id, controller.signal, () => controller.abort(new Error("Program stopped")), update).then(
100
135
  ({ outcome, result, files, abandoned }) => {
101
136
  if (abandoned) program.abandoned = true;
137
+ if (!ended(record)) program.result = result;
102
138
  this.end(program, outcome, result.content, result.isError === true, files);
103
139
  },
104
140
  (error: unknown) => this.end(program, "failed", [{ type: "text", text: `Script failed\nOutput:\nScript error:\n${error instanceof Error ? error.message : String(error)}` }], true),
@@ -131,6 +167,20 @@ export class Programs {
131
167
  return [...this.programs.values()].map((program) => program.record).sort((a, b) => b.startedAt - a.startedAt);
132
168
  }
133
169
 
170
+ view(id: string): ProgramView | undefined {
171
+ const program = this.programs.get(id);
172
+ if (!program) return undefined;
173
+ const { record, code } = program;
174
+ const restored = record.content && { content: record.content, details: record.calls && { calls: record.calls }, isError: record.isError };
175
+ return { record, code, result: program.result ?? restored as ProgramView["result"] };
176
+ }
177
+
178
+ /** Calls `listener` when a Program's view changes; returns a function that stops it. */
179
+ onView(listener: () => void): () => void {
180
+ this.viewers.add(listener);
181
+ return () => this.viewers.delete(listener);
182
+ }
183
+
134
184
  /**
135
185
  * Returns whether the Program was running; it has ended when this resolves. A Program that has
136
186
  * not stopped within the timeout is abandoned and ends as stopped; this throws when some of its
@@ -138,6 +188,8 @@ export class Programs {
138
188
  */
139
189
  async stop(program: Running, reason = "Program stopped"): Promise<boolean> {
140
190
  if (ended(program.record)) return false;
191
+ // As with `abort`, the caller that stops a Program is not notified.
192
+ program.record.notified = true;
141
193
  program.controller?.abort(new Error(reason));
142
194
  if (!await settlesBy(program.done!, Date.now() + CLEANUP_TIMEOUT_MS)) {
143
195
  program.abandoned = true;
@@ -157,22 +209,28 @@ export class Programs {
157
209
  return programs.some(({ record }) => !ended(record));
158
210
  }
159
211
 
160
- async wait(unique: Running[], timeoutSeconds: number, signal?: AbortSignal): Promise<ProgramWaitOutcome> {
212
+ async wait(unique: Running[], timeoutSeconds: number, signal?: AbortSignal, steered?: () => boolean): Promise<ProgramWaitOutcome> {
161
213
  for (const program of unique) program.waiters += 1;
162
214
  let returned = false;
215
+ let interrupted = false;
163
216
  try {
164
217
  const deadline = Date.now() + timeoutSeconds * 1000;
165
218
  while (unique.some(({ record }) => !ended(record))) {
166
219
  const remaining = deadline - Date.now();
167
220
  if (remaining <= 0) break;
221
+ if (steered?.()) {
222
+ interrupted = true;
223
+ break;
224
+ }
168
225
  signal?.throwIfAborted();
169
- await waitForChange(this.change.promise, remaining, signal);
226
+ await waitForChange(this.change.promise, steered ? Math.min(remaining, STEER_POLL_MS) : remaining, signal);
170
227
  }
171
228
  signal?.throwIfAborted();
172
229
  returned = true;
173
230
  return {
174
231
  results: unique.filter(({ record }) => ended(record)).map(({ record }) => record),
175
232
  running: unique.filter(({ record }) => !ended(record)).map(({ record }) => record),
233
+ ...(interrupted && { interrupted }),
176
234
  };
177
235
  } finally {
178
236
  for (const program of unique) {
@@ -212,6 +270,10 @@ export class Programs {
212
270
  record.state = outcome;
213
271
  record.content = content;
214
272
  record.isError = isError;
273
+ // Keeps the calls the result's details list, from the result or the last partial one.
274
+ program.result = { content, details: program.result?.details, isError };
275
+ const calls = (program.result.details as { calls?: unknown[] } | undefined)?.calls;
276
+ if (calls?.length) record.calls = calls;
215
277
  if (files && (files.read.length > 0 || files.written.length > 0 || files.edited.length > 0)) record.files = files;
216
278
  program.controller = undefined;
217
279
  this.persist(record);
@@ -220,6 +282,11 @@ export class Programs {
220
282
  this.change = deferred();
221
283
  previous.resolve();
222
284
  this.hooks.changed?.();
285
+ this.viewed();
286
+ }
287
+
288
+ private viewed(): void {
289
+ for (const listener of this.viewers) listener();
223
290
  }
224
291
 
225
292
  private notify(record: ProgramRecord): void {
@@ -260,23 +327,64 @@ function resultBlock(record: ProgramRecord, ids: string[]): string {
260
327
  return `<program-result id="${shortId(record.id, ids)}" status="${record.state}">\n${textOf(record.content)}\n</program-result>`;
261
328
  }
262
329
 
263
- /** Renders wait results; returns the content and the results the caller has now received. */
264
- export function renderProgramWait({ results, running }: ProgramWaitOutcome, ids: string[]): { content: Item[]; returned: ProgramRecord[]; isError?: boolean } {
265
- const runningLine = running.length > 0 ? `Still running: ${running.map((record) => shortId(record.id, ids)).join(", ")}` : "";
266
- if (results.length === 0) return { content: [{ type: "text", text: runningLine || "No results." }], returned: [] };
330
+ // The header codemode puts before a Program's output; a Program that ended before its run did has no wall time.
331
+ const SCRIPT_HEADER = /^Script (?:completed|failed)\n(?:Wall time ([\d.]+) seconds\n)?Output:\n/;
332
+
333
+ export interface ProgramResultDetails {
334
+ id: string;
335
+ state: ProgramState;
336
+ wallTimeSeconds?: number;
337
+ /** The output the result shows, without the header. */
338
+ output?: string;
339
+ }
340
+
341
+ /** A result's details, with the output in the first `available` characters of its text, if shown. */
342
+ function resultDetails(record: ProgramRecord, available?: number): ProgramResultDetails {
343
+ const text = textOf(record.content);
344
+ const header = SCRIPT_HEADER.exec(text);
345
+ return {
346
+ id: record.id,
347
+ state: record.state,
348
+ ...(header?.[1] !== undefined && { wallTimeSeconds: Number(header[1]) }),
349
+ ...(available !== undefined && { output: text.slice(0, available).slice(header?.[0].length ?? 0) }),
350
+ };
351
+ }
352
+
353
+ /** Renders wait results; returns the content, its details, and the results the caller has now received. */
354
+ /** The line naming the results left out to keep within Pi's output limits. */
355
+ export function omittedLine(omitted: string[], ids: string[]): string {
356
+ return omitted.length > 0 ? `[Results omitted: ${omitted.map((id) => shortId(id, ids)).join(", ")}. Use wait with fewer targets.]` : "";
357
+ }
358
+
359
+ /** The line naming the Programs still running, and that a steer ended the wait if one did. */
360
+ export function runningLine(running: string[], interrupted: boolean | undefined, ids: string[]): string {
361
+ return running.length > 0 ? `Still running: ${running.map((id) => shortId(id, ids)).join(", ")}${interrupted ? `\n${WAIT_INTERRUPTED}` : ""}` : "";
362
+ }
363
+
364
+ export function renderProgramWait({ results, running, interrupted }: ProgramWaitOutcome, ids: string[]): { content: Item[]; details: { results: ProgramResultDetails[] } & Partial<Truncation>; returned: ProgramRecord[]; isError?: boolean } {
365
+ const runningText = runningLine(running.map((record) => record.id), interrupted, ids);
366
+ if (results.length === 0) return { content: [{ type: "text", text: runningText || "No results." }], details: { results: [] }, returned: [] };
267
367
  const images = (shown: ProgramRecord[]) => shown.flatMap((record) => (record.content ?? []).filter((item) => item.type === "image"));
268
368
  const only = results[0]!;
269
369
  if (results.length === 1 && running.length === 0 && only.state === "completed") {
270
370
  const bounded = boundText(textOf(only.content), "pi-agents-program-wait");
271
- return { content: [{ type: "text", text: bounded.text }, ...images(results)], returned: results };
371
+ return { content: [{ type: "text", text: bounded.text }, ...images(results)], details: { results: [resultDetails(only)], ...bounded.details }, returned: results };
272
372
  }
273
- const bounded = boundBlocks(results.map((record) => resultBlock(record, ids)), "program-result", "pi-agents-program-wait");
373
+ const blocks = results.map((record) => resultBlock(record, ids));
374
+ const bounded = boundBlocks(blocks, "program-result", "pi-agents-program-wait");
375
+ let offset = 0;
376
+ const details = results.map((record, index) => {
377
+ const block = blocks[index]!;
378
+ const available = Math.max(0, bounded.kept.length - offset - block.indexOf("\n") - 1);
379
+ offset += block.length + 2;
380
+ return resultDetails(record, index < bounded.shown ? available : undefined);
381
+ });
274
382
  let text = bounded.text;
275
383
  const returned = results.slice(0, bounded.shown);
276
- const omitted = results.slice(bounded.shown);
277
- if (omitted.length > 0) text += `\n\n[Results omitted: ${omitted.map((record) => shortId(record.id, ids)).join(", ")}. Use wait with fewer targets.]`;
278
- if (runningLine) text += `\n\n${runningLine}`;
279
- return { content: [{ type: "text", text }, ...images(returned)], returned };
384
+ const omittedText = omittedLine(results.slice(bounded.shown).map((record) => record.id), ids);
385
+ if (omittedText) text += `\n\n${omittedText}`;
386
+ if (runningText) text += `\n\n${runningText}`;
387
+ return { content: [{ type: "text", text }, ...images(returned)], details: { results: details, ...bounded.details }, returned };
280
388
  }
281
389
 
282
390
  export function programListLine(record: ProgramRecord, ids: string[]): string {
@@ -0,0 +1,11 @@
1
+ /** The message a `/program` run adds to the Session: `Ran …`, then the Program's result. */
2
+ export const PROGRAM_MESSAGE = 'pi-agents-program-run';
3
+
4
+ const SCRIPT_HEADER = /^Script (completed|failed)\nWall time [\d.]+ seconds\nOutput:\n$/;
5
+
6
+ /** What `pi -p` prints for a Program result: its text items without codemode's header. */
7
+ export function runOutput(content) {
8
+ const [first, ...rest] = content;
9
+ const output = first?.type === 'text' && SCRIPT_HEADER.test(first.text) ? rest : content;
10
+ return output.flatMap(item => item.type === 'text' ? [item.text] : []).join('\n');
11
+ }
@@ -1,5 +1,6 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import type { CodemodeTool } from "@earendil-works/pi-codemode";
3
+ import type { CallTracker } from "./execute.ts";
3
4
 
4
5
  export interface AgentOptions {
5
6
  name?: string;
@@ -12,6 +13,7 @@ export interface AgentOptions {
12
13
  export interface SendOptions {
13
14
  deliverAs?: "followUp" | "steer" | "write";
14
15
  schema?: unknown;
16
+ timeout?: number;
15
17
  }
16
18
 
17
19
  /** What `agent()` and its handles do on the host. */
@@ -53,9 +55,10 @@ export function withAgentPrefix(code: string): string {
53
55
  return `${agentPrefix()}${code}`;
54
56
  }
55
57
 
56
- /** Globals behind `agent()`. A handle's calls wait for its Agent to be created. */
57
- export function agentGlobals(host: ProgramAgentHost): CodemodeTool[] {
58
+ /** Globals behind `agent()`. A handle's calls wait for its Agent to be created; `track` records each `send()`. */
59
+ export function agentGlobals(host: ProgramAgentHost, track: CallTracker): CodemodeTool[] {
58
60
  const created = new Map<string, Promise<void>>();
61
+ const names = new Map<string, string>();
59
62
  const ready = (id: string) => created.get(id) ?? Promise.reject(new Error(`Unknown Agent ${id}`));
60
63
  return [
61
64
  {
@@ -63,6 +66,7 @@ export function agentGlobals(host: ProgramAgentHost): CodemodeTool[] {
63
66
  spread: true,
64
67
  execute: (args) => {
65
68
  const [id, name, options] = args as [string, string, AgentOptions];
69
+ names.set(id, name);
66
70
  const creation = host.create(id, name, options ?? {});
67
71
  created.set(id, creation);
68
72
  return creation;
@@ -73,8 +77,17 @@ export function agentGlobals(host: ProgramAgentHost): CodemodeTool[] {
73
77
  spread: true,
74
78
  execute: async (args) => {
75
79
  const [id, message, options] = args as [string, string, SendOptions | undefined];
76
- await ready(id);
77
- return host.send(id, message, options ?? {});
80
+ // Recorded from the start, so a send whose Agent fails to be created shows too.
81
+ const end = track(`agent ${names.get(id) ?? id}`, JSON.stringify(message) ?? "");
82
+ try {
83
+ await ready(id);
84
+ const value = await host.send(id, message, options ?? {});
85
+ end("ok");
86
+ return value;
87
+ } catch (error) {
88
+ end((error as { cancelled?: boolean }).cancelled ? "cancelled" : "error", error instanceof Error ? error.message : String(error));
89
+ throw error;
90
+ }
78
91
  },
79
92
  },
80
93
  {
@@ -1,9 +1,9 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Agents, settlesBy, SUBMIT_RESULT_TOOL_NAME, THINKING_LEVELS, type Limits } from "../agents/agents.ts";
3
- import { label, messageText, nodes, treeChanged, visibleIds, type AgentNode } from "../agents/registry.ts";
3
+ import { label, messageText, nodes, sender, treeChanged, visibleIds, type AgentNode } from "../agents/registry.ts";
4
+ import { CLEANUP_TIMEOUT_MS } from "./execute.ts";
4
5
  import type { AgentOptions, ProgramAgentHost, SendOptions } from "./sandbox.ts";
5
6
 
6
- const CLEANUP_TIMEOUT_MS = 15_000;
7
7
  const DELIVERIES = ["followUp", "steer", "write"];
8
8
 
9
9
  export interface ProgramScope {
@@ -32,6 +32,7 @@ export function programScope(
32
32
  program: true,
33
33
  background: options.background,
34
34
  createdAt: new Date().toISOString(),
35
+ spent: { cost: 0, tokens: 0, unpriced: false },
35
36
  agents,
36
37
  cwd: () => ctx.cwd,
37
38
  busy: () => true,
@@ -54,7 +55,7 @@ export function programScope(
54
55
  },
55
56
  send: async (agentId, message, sendOptions: SendOptions) => {
56
57
  const delivery = sendOptions.deliverAs ?? "followUp";
57
- const { schema } = sendOptions;
58
+ const { schema, timeout } = sendOptions;
58
59
  if (typeof message !== "string" || !message.trim()) throw new Error("send() requires a non-empty message");
59
60
  if (!DELIVERIES.includes(delivery)) throw new Error('send() deliverAs must be "followUp", "steer", or "write"');
60
61
  if (schema !== undefined) {
@@ -63,16 +64,23 @@ export function programScope(
63
64
  throw new Error("send() schema must be a JSON Schema object or boolean");
64
65
  }
65
66
  }
67
+ if (timeout !== undefined) {
68
+ if (delivery !== "followUp") throw new Error('send() accepts timeout only with deliverAs "followUp"');
69
+ if (typeof timeout !== "number" || !(timeout > 0) || !Number.isFinite(timeout)) throw new Error("send() timeout must be a positive number of seconds");
70
+ }
66
71
  const agent = agents.ownedTarget(agentId).record;
67
72
  const body = schema === undefined ? message : `${message}\n\nWhen done, call \`${SUBMIT_RESULT_TOOL_NAME}\` with a \`value\` that matches this JSON Schema:\n${JSON.stringify(schema)}`;
68
- const text = messageText(from, true, delivery, body, visibleIds(agentId, false));
73
+ const ids = visibleIds(agentId, false);
74
+ const text = messageText(from, true, delivery, body, ids);
69
75
  if (delivery === "write") {
70
- agents.accept(agentId, text, "write", { fromOwner: true, notification: false });
76
+ agents.accept(agentId, text, "write", { fromOwner: true, notification: false, sender: sender(from, ids) });
71
77
  return undefined;
72
78
  }
73
- const record = await agents.request(agentId, text, delivery, schema);
79
+ const { record, timedOut } = await agents.request(agentId, text, delivery, sender(from, ids), schema, timeout === undefined ? undefined : timeout * 1000);
74
80
  if (record.state === "completed") return schema === undefined ? record.result : record.value;
75
- throw new Error(record.state === "failed" ? `Agent ${label(agent, agents.ids())} failed: ${record.result}` : `Agent ${label(agent, agents.ids())} was aborted.`);
81
+ if (record.state === "failed") throw new Error(`Agent ${label(agent, agents.ids())} failed: ${record.result}`);
82
+ // An aborted input, timed out or not, shows as a cancelled call.
83
+ throw Object.assign(new Error(timedOut ? `Agent ${label(agent, agents.ids())} timed out after ${timeout} seconds.` : `Agent ${label(agent, agents.ids())} was aborted.`), { cancelled: true });
76
84
  },
77
85
  abort: async (agentId) => {
78
86
  await agents.abort(agents.ownedTarget(agentId));