@tshu-w/pi-agents 0.2.0 → 0.2.2
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.
- package/bin/pd.mjs +32 -35
- package/docs/HOSTS.md +50 -118
- package/docs/PROGRAM.md +32 -104
- package/docs/SPEC.md +91 -180
- package/package.json +1 -1
- package/src/agents/agents.ts +27 -26
- package/src/agents/registry.ts +33 -8
- package/src/agents/tool.ts +34 -11
- package/src/host/client.mjs +6 -1
- package/src/host/rpc-client.mjs +37 -17
- package/src/index.ts +9 -3
- package/src/output.ts +5 -1
- package/src/programs/codemode.ts +14 -0
- package/src/programs/execute.ts +49 -2
- package/src/programs/loadout.ts +18 -3
- package/src/programs/programs.ts +52 -11
- package/src/programs/scope.ts +5 -4
- package/src/programs/tool.ts +1 -1
- package/src/render-call.ts +113 -15
- package/src/roots/paths.mjs +4 -4
- package/src/ui/message.ts +24 -0
- package/src/ui/queue.ts +7 -6
- package/src/ui/viewer.ts +67 -31
- package/src/workbench.mjs +45 -33
package/src/agents/registry.ts
CHANGED
|
@@ -31,7 +31,7 @@ export interface AgentNode {
|
|
|
31
31
|
sessionFile(): string | undefined;
|
|
32
32
|
agents: Agents;
|
|
33
33
|
/** Delivers a message to this node; used only for root Agents. */
|
|
34
|
-
receive(text: string, delivery: Delivery, messageId?: string): void;
|
|
34
|
+
receive(text: string, delivery: Delivery, messageId?: string, sender?: Sender): void;
|
|
35
35
|
/** A root Agent's turn was stopped: messages from other Agents become writes until the user's next input. */
|
|
36
36
|
stopped?: boolean;
|
|
37
37
|
}
|
|
@@ -242,31 +242,56 @@ export function deliver(
|
|
|
242
242
|
const fromOwner = from !== undefined && target.ownerId === from.id;
|
|
243
243
|
const ids = recipientIds(target);
|
|
244
244
|
const text = from === undefined ? body : messageText(from, fromOwner, delivery, body, ids);
|
|
245
|
+
const by = from && sender(from, ids);
|
|
245
246
|
if (target.ownerId === undefined) {
|
|
246
247
|
const node = shared.nodes.get(target.id);
|
|
247
248
|
if (!node) throw new Error(`${label(target, ids)} is offline.`);
|
|
248
|
-
node.receive(text, delivery);
|
|
249
|
+
node.receive(text, delivery, undefined, by);
|
|
249
250
|
return { queued: false };
|
|
250
251
|
}
|
|
251
252
|
const owner = shared.nodes.get(target.ownerId);
|
|
252
253
|
if (!owner) throw new Error(`${label(target, ids)} is offline.`);
|
|
253
|
-
return owner.agents.accept(target.id, text, delivery, { fromOwner, notification: from === undefined });
|
|
254
|
+
return owner.agents.accept(target.id, text, delivery, { fromOwner, notification: from === undefined, sender: by });
|
|
254
255
|
}
|
|
255
256
|
|
|
256
257
|
export const MESSAGE_TAG = "agent-message";
|
|
258
|
+
const REPLY_NOTE = ' note="Reply with send"';
|
|
259
|
+
|
|
260
|
+
/** The sender of a message as its element names it: its name and its short ID among the recipient's visible Agents. */
|
|
261
|
+
export interface Sender {
|
|
262
|
+
id: string;
|
|
263
|
+
name?: string;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function sender(from: { id: string; name?: string }, ids: Iterable<string>): Sender {
|
|
267
|
+
return { id: shortId(from.id, ids), ...(from.name === undefined ? {} : { name: from.name }) };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The element a sender's message opens with, naming the sender as `agent-result` names its Agent. */
|
|
271
|
+
function messageOpen({ id, name }: Sender): string {
|
|
272
|
+
return `<${MESSAGE_TAG}${name === undefined ? "" : ` name="${name}"`} id="${id}"`;
|
|
273
|
+
}
|
|
257
274
|
|
|
258
275
|
/** A message in an element naming its sender; an input from a sender other than the owner asks for a reply. */
|
|
259
276
|
export function messageText(from: { id: string; name?: string }, fromOwner: boolean, delivery: Delivery, body: string, ids: Iterable<string>): string {
|
|
260
|
-
const note = !fromOwner && delivery !== "write" ?
|
|
261
|
-
return
|
|
277
|
+
const note = !fromOwner && delivery !== "write" ? REPLY_NOTE : "";
|
|
278
|
+
return `${messageOpen(sender(from, ids))}${note}>\n${body}\n</${MESSAGE_TAG}>`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The body of a message `messageText` made for `sender`, or undefined if `text` is not one. */
|
|
282
|
+
export function messageBody(text: string, sender: Sender): string | undefined {
|
|
283
|
+
const open = messageOpen(sender);
|
|
284
|
+
const close = `\n</${MESSAGE_TAG}>`;
|
|
285
|
+
const header = [`${open}>\n`, `${open}${REPLY_NOTE}>\n`].find((start) => text.startsWith(start));
|
|
286
|
+
return header && text.length >= header.length + close.length && text.endsWith(close) ? text.slice(header.length, -close.length) : undefined;
|
|
262
287
|
}
|
|
263
288
|
|
|
264
289
|
/**
|
|
265
290
|
* `messageId` marks a message from a root Agent in another process, so it is added once; `user`
|
|
266
|
-
* marks the user's input from the task viewer.
|
|
291
|
+
* marks the user's input from the task viewer, and `sender` the Agent that sent it.
|
|
267
292
|
*/
|
|
268
|
-
export function customMessage(text: string, delivery: Delivery, messageId?: string
|
|
269
|
-
return { customType: MESSAGE_TYPE, content: text, display: true, details: { delivery, ...(messageId ? { messageId } : {}), ...(user ? { user } : {}) } };
|
|
293
|
+
export function customMessage(text: string, delivery: Delivery, { messageId, user, sender }: { messageId?: string; user?: boolean; sender?: Sender } = {}) {
|
|
294
|
+
return { customType: MESSAGE_TYPE, content: text, display: true, details: { delivery, ...(messageId ? { messageId } : {}), ...(user ? { user } : {}), ...(sender ? { sender } : {}) } };
|
|
270
295
|
}
|
|
271
296
|
|
|
272
297
|
export function receivedMessageIds(ctx: ExtensionContext): string[] {
|
package/src/agents/tool.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
|
-
import { boundBlocks, boundText } from "../output.ts";
|
|
4
|
+
import { boundBlocks, boundText, type Truncation } from "../output.ts";
|
|
5
5
|
import { rejectFields } from "../params.ts";
|
|
6
|
-
import { renderTextResult, renderToolCall, startDuration, type DurationState } from "../render-call.ts";
|
|
6
|
+
import { renderResultList, renderTextResult, renderToolCall, startDuration, type DurationState } from "../render-call.ts";
|
|
7
7
|
import { agentSession } from "../roots/rpc.ts";
|
|
8
8
|
import type { RootRuntime } from "../roots/runtime.ts";
|
|
9
9
|
import {
|
|
@@ -35,12 +35,25 @@ function resultDetails({ agent, input, history }: WaitResult, result?: string) {
|
|
|
35
35
|
return { id: agent.id, name: agent.name, state: input.state, history, ...(result === undefined ? {} : { result }) };
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
type Named = { id: string; name?: string };
|
|
39
|
+
|
|
40
|
+
/** The line naming the results left out to keep within Pi's output limits. */
|
|
41
|
+
function omittedLine(omitted: Named[], ids: string[]): string {
|
|
42
|
+
return omitted.length > 0 ? `[Results omitted: ${omitted.map((agent) => label(agent, ids)).join(", ")}. Use wait with fewer targets.]` : "";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The line naming the Agents still pending, and that a steer ended the wait if one did. */
|
|
46
|
+
function pendingLine(pending: Named[], interrupted: boolean | undefined, ids: string[]): string {
|
|
47
|
+
return pending.length > 0 ? `Still pending: ${pending.map((agent) => label(agent, ids)).join(", ")}${interrupted ? `\n${WAIT_INTERRUPTED}` : ""}` : "";
|
|
48
|
+
}
|
|
49
|
+
|
|
38
50
|
/** Renders wait results; returns the text and the results the caller has now read. */
|
|
39
51
|
function renderWait(outcome: WaitOutcome, ids: string[]): { text: string; read: WaitResult[]; details: Record<string, unknown> } {
|
|
40
52
|
const { results, pending, interrupted } = outcome;
|
|
41
|
-
const
|
|
53
|
+
const pendingText = pendingLine(pending, interrupted, ids);
|
|
42
54
|
const flag = interrupted ? { interrupted } : {};
|
|
43
|
-
|
|
55
|
+
const named = pending.map(({ id, name }) => ({ id, name }));
|
|
56
|
+
if (results.length === 0) return { text: pendingText || "No results.", read: [], details: { results: [], pending: named, ...flag } };
|
|
44
57
|
const only = results[0]!;
|
|
45
58
|
if (results.length === 1 && pending.length === 0 && !only.history && only.input.state === "completed") {
|
|
46
59
|
const bounded = boundText(only.input.result ?? "", "pi-agents-wait");
|
|
@@ -57,10 +70,10 @@ function renderWait(outcome: WaitOutcome, ids: string[]): { text: string; read:
|
|
|
57
70
|
});
|
|
58
71
|
let text = bounded.text;
|
|
59
72
|
const read = results.slice(0, bounded.shown);
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
62
|
-
if (
|
|
63
|
-
return { text, read, details: { results: details, pending:
|
|
73
|
+
const omittedText = omittedLine(results.slice(bounded.shown).map((result) => result.agent), ids);
|
|
74
|
+
if (omittedText) text += `\n\n${omittedText}`;
|
|
75
|
+
if (pendingText) text += `\n\n${pendingText}`;
|
|
76
|
+
return { text, read, details: { results: details, pending: named, ...flag, ...bounded.details } };
|
|
64
77
|
}
|
|
65
78
|
|
|
66
79
|
/** Registers the `agent` tool. */
|
|
@@ -89,8 +102,18 @@ export function registerAgentTool(
|
|
|
89
102
|
startDuration(context.state as DurationState, args.action === "wait", context.executionStarted);
|
|
90
103
|
return renderToolCall("agent", args, theme, context.lastComponent);
|
|
91
104
|
},
|
|
92
|
-
// `wait` shows how long it has waited.
|
|
93
|
-
renderResult: (result, options, theme, context) => renderTextResult(result, options, theme, context)
|
|
105
|
+
// `wait` shows how long it has waited, and when collapsed, each result as a line with the first lines of its body.
|
|
106
|
+
renderResult: (result, options, theme, context) => renderTextResult(result, options, theme, context, (context.args as { action?: string }).action === "wait"
|
|
107
|
+
? () => {
|
|
108
|
+
const { results = [], pending = [], interrupted, ...truncated } = (result.details ?? {}) as { results?: ReturnType<typeof resultDetails>[]; pending?: Named[]; interrupted?: boolean } & Partial<Truncation>;
|
|
109
|
+
const only = results[0];
|
|
110
|
+
// As `renderWait` returns them, no results, or a single completed result alone, show as they are.
|
|
111
|
+
if (!only || (results.length === 1 && pending.length === 0 && !only.history && only.state === "completed")) return undefined;
|
|
112
|
+
const ids = [...results.map((entry) => entry.id), ...pending.map((agent) => agent.id)];
|
|
113
|
+
const notes = [omittedLine(results.filter((entry) => entry.result === undefined), ids), pendingLine(pending, interrupted, ids)];
|
|
114
|
+
return renderResultList(results.flatMap((entry) => entry.result === undefined ? [] : [{ line: `${label(entry, ids)} ${entry.state}`, body: entry.result }]), notes, truncated, theme);
|
|
115
|
+
}
|
|
116
|
+
: undefined),
|
|
94
117
|
description: `Create, message, and coordinate Agents. \`spawn\` creates an owned Agent and sends its first input; \`send\` sends an input or a write to a visible Agent; \`wait\` waits for owned Agents and returns unread results; \`list\` finds visible Agents; \`abort\` stops an owned Agent's current turn and queued inputs while keeping it available. When an owned Agent finishes an input while its owner is not waiting for it, the owner receives a notification naming the Agent and outcome; \`wait\` returns the result. Owned Agents under the same root share \`${maxConcurrent}\` execution slots and a limit of \`${maxOutstanding}\` inputs that have not ended. Inputs that start a new turn queue when all slots are busy; new inputs are rejected at the input limit.`,
|
|
95
118
|
promptSnippet: "Create, message, and coordinate Agents",
|
|
96
119
|
promptGuidelines: [
|
|
@@ -105,7 +128,7 @@ export function registerAgentTool(
|
|
|
105
128
|
cwd: Type.Optional(Type.String({ description: "Working directory for `spawn` (default: the caller's current working directory)." })),
|
|
106
129
|
context: Type.Optional(StringEnum(["fresh", "fork"] as const, { description: "Context for `spawn` (default: fresh). fresh starts without the caller's conversation; fork snapshots it." })),
|
|
107
130
|
model: Type.Optional(Type.String({ description: "Model for `spawn` as provider/modelId (default: the caller's model)." })),
|
|
108
|
-
thinkingLevel: Type.Optional(StringEnum(THINKING_LEVELS, { description: "Thinking level for `spawn`: off, minimal, low, medium, high, or
|
|
131
|
+
thinkingLevel: Type.Optional(StringEnum(THINKING_LEVELS, { description: "Thinking level for `spawn`: off, minimal, low, medium, high, xhigh, or max (default: the caller's level)." })),
|
|
109
132
|
target: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())], { description: "Agent ID, name, or unique ID prefix for `send`, `wait`, or `abort`. `wait` and `send` with `deliverAs='write'` also accept an array. When omitted for `wait`, selects all owned Agents with pending or unread results." })),
|
|
110
133
|
deliverAs: Type.Optional(StringEnum(["followUp", "steer", "write"] as const, { description: "Delivery for `send` (default: followUp). followUp waits until the recipient's current turn ends; steer delivers after its current tool calls, before the next model call; write delivers like steer but never starts a turn and has no result." })),
|
|
111
134
|
history: Type.Optional(Type.Integer({ minimum: 0, description: "Number of most recent read results to return again per selected Agent for `wait` (default: 0)." })),
|
package/src/host/client.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
3
|
import { open, readdir, stat, unlink } from 'node:fs/promises';
|
|
4
4
|
import net from 'node:net';
|
|
5
|
-
import { join } from 'node:path';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
8
8
|
import { FRAME, frame, json, readFrames } from './frames.mjs';
|
|
@@ -27,6 +27,11 @@ export function hostStatus(path) {
|
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** The file of the Session a host runs; Pi keeps a relative session directory relative, so it resolves against the host's cwd. */
|
|
31
|
+
export function hostSessionFile(host) {
|
|
32
|
+
return host.session?.file && resolve(host.cwd, host.session.file);
|
|
33
|
+
}
|
|
34
|
+
|
|
30
35
|
export function alive(pid) {
|
|
31
36
|
try { process.kill(pid, 0); return true; }
|
|
32
37
|
catch (error) { return error.code !== 'ESRCH'; }
|
package/src/host/rpc-client.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
5
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
|
-
import { alive, hostStatus, settledHosts, startHost } from './client.mjs';
|
|
6
|
+
import { alive, hostSessionFile, hostStatus, settledHosts, startHost } from './client.mjs';
|
|
7
7
|
import { readSessionHeader, readSessionId } from '../roots/guard.mjs';
|
|
8
8
|
import { openStream, request } from '../roots/transport.mjs';
|
|
9
9
|
import { piPackage } from '../pi.mjs';
|
|
@@ -13,8 +13,7 @@ const TIMEOUT_MS = 30000;
|
|
|
13
13
|
const NOT_READY = ['ENOENT', 'ECONNREFUSED', 'SOCKET_MODE', 'NOT_READY'];
|
|
14
14
|
const GONE = ['ENOENT', 'ECONNREFUSED', 'ECONNRESET'];
|
|
15
15
|
// Options that choose the Session; another Session opens with the rest.
|
|
16
|
-
const
|
|
17
|
-
const SESSION_SWITCHES = new Set(['-c', '--continue', '-r', '--resume']);
|
|
16
|
+
const SESSION_OPTIONS = new Set(['--session', '--session-id', '--fork', '--name', '-n', '-c', '--continue', '-r', '--resume']);
|
|
18
17
|
|
|
19
18
|
function fault(code, message) {
|
|
20
19
|
return Object.assign(new Error(message), { code });
|
|
@@ -42,20 +41,39 @@ async function connect(paths, id, handlers, live, role = 'rpc') {
|
|
|
42
41
|
}
|
|
43
42
|
}
|
|
44
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Whether Pi's `parseArgs` reads `next` as the value of `arg`. It decides by the form of `next`, so a probe of that form
|
|
46
|
+
* shows it: the probe is consumed, or parsed on its own as a message, a file, an unknown short option, or an unknown flag.
|
|
47
|
+
*/
|
|
48
|
+
function takesValue(parseArgs, arg, next) {
|
|
49
|
+
const probe = next.startsWith('---') ? '---\0' : next.startsWith('-') ? '-\0' : next.startsWith('@') ? '@\0' : '\0';
|
|
50
|
+
const parsed = parseArgs([arg, probe]);
|
|
51
|
+
if (probe === '\0') return !parsed.messages.includes(probe);
|
|
52
|
+
if (probe === '@\0') return !parsed.fileArgs.includes('\0');
|
|
53
|
+
// Only Pi's own diagnostic for the probe alone; an option may consume it and report it as an invalid value.
|
|
54
|
+
if (probe === '-\0') return !parsed.diagnostics.some(({ message }) => message === `Unknown option: ${probe}`);
|
|
55
|
+
return !parsed.unknownFlags.has('-\0');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** `args` in the groups Pi reads: an option with its value, or an argument alone; from `--` on, one group. */
|
|
59
|
+
export function argGroups(parseArgs, args) {
|
|
60
|
+
const groups = [];
|
|
61
|
+
for (let i = 0; i < args.length; i++) {
|
|
62
|
+
if (args[i] === '--') {
|
|
63
|
+
groups.push(args.slice(i));
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
groups.push(i + 1 < args.length && takesValue(parseArgs, args[i], args[i + 1]) ? [args[i], args[++i]] : [args[i]]);
|
|
67
|
+
}
|
|
68
|
+
return groups;
|
|
69
|
+
}
|
|
70
|
+
|
|
45
71
|
/** `args` without the prompts, and without the options that make Pi non-interactive. */
|
|
46
72
|
function withoutPrompts(parseArgs, args) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const positional = after.messages.length > before.messages.length || after.fileArgs.length > before.fileArgs.length;
|
|
52
|
-
before = after;
|
|
53
|
-
if (separated || positional) continue;
|
|
54
|
-
if (args[i] === '--') separated = true;
|
|
55
|
-
else if (args[i] === '--mode') i++;
|
|
56
|
-
else if (args[i] !== '-p' && args[i] !== '--print') options.push(args[i]);
|
|
57
|
-
}
|
|
58
|
-
return options;
|
|
73
|
+
return argGroups(parseArgs, args).filter(group => {
|
|
74
|
+
const parsed = parseArgs(group);
|
|
75
|
+
return !parsed.messages.length && !parsed.fileArgs.length && !['-p', '--print', '--mode'].includes(group[0]);
|
|
76
|
+
}).flat();
|
|
59
77
|
}
|
|
60
78
|
|
|
61
79
|
/** The prompts Pi sends on startup for `parsed`: its initial message, then each further message. */
|
|
@@ -90,7 +108,7 @@ export async function runHeadless({ pi, paths, args, mode, findRunning, warnIgno
|
|
|
90
108
|
|
|
91
109
|
// The host runs Pi without the prompts, which arrive as RPC commands instead.
|
|
92
110
|
const hostArgs = withoutPrompts(parseArgs, args);
|
|
93
|
-
const sessionArgs = hostArgs.filter((
|
|
111
|
+
const sessionArgs = argGroups(parseArgs, hostArgs).filter(([option]) => !SESSION_OPTIONS.has(option)).flat();
|
|
94
112
|
|
|
95
113
|
/** Starts a host for `hostArgs` in `cwd`; resolves once its Pi reports the Session. */
|
|
96
114
|
async function host(hostArgs, cwd) {
|
|
@@ -108,10 +126,12 @@ export async function runHeadless({ pi, paths, args, mode, findRunning, warnIgno
|
|
|
108
126
|
|
|
109
127
|
/** Connects to the Session in `file`, starting it in a host in `cwd` if no Pi runs it. */
|
|
110
128
|
async function open(file, cwd, handlers) {
|
|
129
|
+
// A Session Pi created in a relative session directory has a relative file.
|
|
130
|
+
file = resolve(cwd, file);
|
|
111
131
|
const deadline = performance.now() + TIMEOUT_MS;
|
|
112
132
|
for (;;) {
|
|
113
133
|
const id = readSessionId(file);
|
|
114
|
-
const holds = h => h.session && (h.session.id === id || h
|
|
134
|
+
const holds = h => h.session && (h.session.id === id || hostSessionFile(h) === file);
|
|
115
135
|
try {
|
|
116
136
|
const held = (await settledHosts(paths, holds)).find(holds);
|
|
117
137
|
if (held) return await connect(paths, held.session.id, handlers, () => alive(held.pid));
|
package/src/index.ts
CHANGED
|
@@ -15,8 +15,10 @@ import { Agents, type Limits } from "./agents/agents.ts";
|
|
|
15
15
|
import {
|
|
16
16
|
customMessage,
|
|
17
17
|
liveWork,
|
|
18
|
+
MESSAGE_TYPE,
|
|
18
19
|
messageText,
|
|
19
20
|
rememberRoots,
|
|
21
|
+
sender,
|
|
20
22
|
visibleIds,
|
|
21
23
|
nodes,
|
|
22
24
|
receivedMessageIds,
|
|
@@ -39,6 +41,7 @@ import { createReporter } from "./roots/report.ts";
|
|
|
39
41
|
import { installQueuePatches } from "./roots/queue.ts";
|
|
40
42
|
import { agentSession, installRpcPatches } from "./roots/rpc.ts";
|
|
41
43
|
import { createRootRuntime } from "./roots/runtime.ts";
|
|
44
|
+
import { renderAgentMessage } from "./ui/message.ts";
|
|
42
45
|
import { createPanel } from "./ui/panel.ts";
|
|
43
46
|
import { showQueue } from "./ui/queue.ts";
|
|
44
47
|
import { openTaskViewer } from "./ui/viewer.ts";
|
|
@@ -105,7 +108,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
105
108
|
if (message.user) return pi.sendUserMessage(message.body, { deliverAs: delivery === "steer" ? "steer" : "followUp" });
|
|
106
109
|
// The header names the sender by a short ID unique among the roots this root sees.
|
|
107
110
|
rememberRoots((await roots.roots()).map((root) => root.id));
|
|
108
|
-
if (node)
|
|
111
|
+
if (!node) return;
|
|
112
|
+
const ids = visibleIds(node.scopeId, true);
|
|
113
|
+
node.receive(messageText(message.sender!, false, delivery, message.body, ids), delivery, message.id, sender(message.sender!, ids));
|
|
109
114
|
},
|
|
110
115
|
treeIdle: () => node === undefined || treeIdle(node.rootId),
|
|
111
116
|
receivedIds: receivedMessageIds,
|
|
@@ -150,9 +155,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
150
155
|
cwd: () => ctx?.cwd ?? current.cwd,
|
|
151
156
|
busy: () => !(ctx?.isIdle() ?? true),
|
|
152
157
|
sessionFile: () => ctx?.sessionManager.getSessionFile(),
|
|
153
|
-
receive: (text, delivery, messageId) => {
|
|
158
|
+
receive: (text, delivery, messageId, from) => {
|
|
154
159
|
const as = created.stopped ? "write" : delivery;
|
|
155
|
-
pi.sendMessage(customMessage(text, as, messageId), as === "write"
|
|
160
|
+
pi.sendMessage(customMessage(text, as, { messageId, sender: from }), as === "write"
|
|
156
161
|
? { deliverAs: "steer" }
|
|
157
162
|
: { deliverAs: as, triggerTurn: true });
|
|
158
163
|
queue.update();
|
|
@@ -209,6 +214,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
209
214
|
|
|
210
215
|
registerAgentTool(pi, { limits: settings, roots, requireNode });
|
|
211
216
|
registerProgramTools(pi, { agentDir, settings, piCodemode, requireNode, programs: () => programs });
|
|
217
|
+
pi.registerMessageRenderer(MESSAGE_TYPE, renderAgentMessage);
|
|
212
218
|
|
|
213
219
|
pi.registerCommand("tasks", {
|
|
214
220
|
description: "Watch an Agent or Program; `/tasks panel` toggles the panel",
|
package/src/output.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface BoundedText {
|
|
|
18
18
|
text: string;
|
|
19
19
|
/** The kept prefix of the text, without the notice. */
|
|
20
20
|
kept: string;
|
|
21
|
+
/** The notice naming the file with the full text. */
|
|
22
|
+
notice?: string;
|
|
21
23
|
details?: Truncation;
|
|
22
24
|
}
|
|
23
25
|
|
|
@@ -30,9 +32,11 @@ export function boundText(text: string, prefix: string): BoundedText {
|
|
|
30
32
|
const summary = truncation.firstLineExceedsLimit
|
|
31
33
|
? `Line 1 is ${formatSize(Buffer.byteLength(text.split("\n")[0]!, "utf8"))}, exceeds ${formatSize(truncation.maxBytes)} limit.`
|
|
32
34
|
: `Showing lines 1-${truncation.outputLines} of ${truncation.totalLines}${truncation.truncatedBy === "bytes" ? ` (${formatSize(truncation.maxBytes)} limit)` : ""}.`;
|
|
35
|
+
const notice = `[${summary} Full output: ${fullOutputPath}]`;
|
|
33
36
|
return {
|
|
34
|
-
text: `${truncation.content}\n\n
|
|
37
|
+
text: `${truncation.content}\n\n${notice}`,
|
|
35
38
|
kept: truncation.content,
|
|
39
|
+
notice,
|
|
36
40
|
details: { truncation, fullOutputPath },
|
|
37
41
|
};
|
|
38
42
|
}
|
package/src/programs/codemode.ts
CHANGED
|
@@ -25,6 +25,20 @@ export function loadCodemode(): Promise<Codemode> {
|
|
|
25
25
|
return loaded;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** Where Pi's executor starts codemode workers from. */
|
|
29
|
+
export interface PiWorkerSource {
|
|
30
|
+
getQuickJSWasmPath(): string;
|
|
31
|
+
getCodemodeWorkerSpecifier(): string | URL | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let workerSource: Promise<PiWorkerSource> | undefined;
|
|
35
|
+
|
|
36
|
+
/** Loads Pi's `dist/config.js`, the module its executor reads them from. */
|
|
37
|
+
export function loadPiWorkerSource(): Promise<PiWorkerSource> {
|
|
38
|
+
workerSource ??= import(pathToFileURL(join(getPackageDir(), "dist", "config.js")).href);
|
|
39
|
+
return workerSource;
|
|
40
|
+
}
|
|
41
|
+
|
|
28
42
|
/** Parts of Pi's `codemode` tool that its package entry does not export. */
|
|
29
43
|
export interface PiCodemode {
|
|
30
44
|
/** Pi's `codemode` tool definition; `program` reuses its `prepareLoadout` and, since Pi 1.2.0, `reachesTools`. */
|
package/src/programs/execute.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
7
7
|
import type { CodemodeResult, CodemodeTool } from "@earendil-works/pi-codemode";
|
|
8
8
|
import type { AgentToolResult, AgentToolUpdateCallback, ExtensionToolContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import { loadCodemode, loadPiCodemode } from "./codemode.ts";
|
|
9
|
+
import { loadCodemode, loadPiCodemode, loadPiWorkerSource } from "./codemode.ts";
|
|
10
10
|
import type { DetachedCallOptions } from "./host-patches.ts";
|
|
11
11
|
|
|
12
12
|
export const PROGRAM_TOOL_NAME = "program";
|
|
@@ -104,20 +104,67 @@ interface SandboxExtras {
|
|
|
104
104
|
const sandboxExtras = new AsyncLocalStorage<SandboxExtras>();
|
|
105
105
|
let sandboxPatch: Promise<void> | undefined;
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Since Pi 1.3.0, its executor admits at most two codemode workers per process, which background
|
|
109
|
+
* Programs waiting on Agents use up. A Program's worker is admitted on its own terms instead: any
|
|
110
|
+
* number may run, and none starts while this many are still exiting after their scripts ended.
|
|
111
|
+
*/
|
|
112
|
+
const MAX_EXITING_WORKERS = 2;
|
|
113
|
+
const exitingWorkers = new Set<{ exited: boolean }>();
|
|
114
|
+
|
|
115
|
+
/** A launcher for one Program's worker, from where Pi's executor starts its own. */
|
|
116
|
+
function programWorker(codemode: Awaited<ReturnType<typeof loadCodemode>>, wasmPath: string, workerUrl: string | URL | undefined) {
|
|
117
|
+
let worker: { exited: boolean } | undefined;
|
|
118
|
+
let ended = false;
|
|
119
|
+
const options = {
|
|
120
|
+
wasm: codemode.loadQuickJSWasm(wasmPath),
|
|
121
|
+
workerUrl,
|
|
122
|
+
acquireWorker: () => {
|
|
123
|
+
// The script can end while the QuickJS module loads, before its worker starts.
|
|
124
|
+
if (ended) throw new Error("The Program ended before its worker started.");
|
|
125
|
+
if (exitingWorkers.size >= MAX_EXITING_WORKERS) {
|
|
126
|
+
throw new Error(`${exitingWorkers.size} workers of ended Programs are still shutting down. Retry after they exit.`);
|
|
127
|
+
}
|
|
128
|
+
const started = { exited: false };
|
|
129
|
+
worker = started;
|
|
130
|
+
return () => {
|
|
131
|
+
started.exited = true;
|
|
132
|
+
exitingWorkers.delete(started);
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
const sandbox = new codemode.CodemodeSandbox(options) as unknown as Record<string, unknown>;
|
|
137
|
+
return {
|
|
138
|
+
launch: sandbox.launch,
|
|
139
|
+
ended() {
|
|
140
|
+
ended = true;
|
|
141
|
+
if (worker && !worker.exited) exitingWorkers.add(worker);
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
107
146
|
async function patchSandbox(): Promise<void> {
|
|
108
|
-
const
|
|
147
|
+
const [codemode, source] = await Promise.all([loadCodemode(), loadPiWorkerSource()]);
|
|
148
|
+
const wasmPath = source.getQuickJSWasmPath();
|
|
149
|
+
const workerUrl = source.getCodemodeWorkerSpecifier();
|
|
150
|
+
const proto = codemode.CodemodeSandbox.prototype as unknown as Record<string, any>;
|
|
109
151
|
const execute = proto.execute;
|
|
110
152
|
proto.execute = function (this: Record<string, any>, code: string, options: Record<string, unknown> = {}) {
|
|
111
153
|
const current = sandboxExtras.getStore();
|
|
112
154
|
if (!current || current.used) return execute.call(this, code, options);
|
|
113
155
|
current.used = true;
|
|
114
156
|
for (const global of current.globals) this.globalsByName.set(global.name, global);
|
|
157
|
+
// Before Pi 1.2.0, the sandbox starts its worker itself, with no limit.
|
|
158
|
+
const worker = typeof this.launch === "function" ? programWorker(codemode, wasmPath, workerUrl) : undefined;
|
|
159
|
+
if (worker) this.launch = worker.launch;
|
|
115
160
|
const timeoutMs = Math.min(current.timeoutMs, (options.timeoutMs as number | undefined) ?? this.timeoutMs);
|
|
116
161
|
return execute.call(this, current.prepare(code), { ...options, timeoutMs }).then((result: CodemodeResult) => {
|
|
162
|
+
worker?.ended();
|
|
117
163
|
current.result = result;
|
|
118
164
|
current.ended();
|
|
119
165
|
return result;
|
|
120
166
|
}, (error: unknown) => {
|
|
167
|
+
worker?.ended();
|
|
121
168
|
current.ended();
|
|
122
169
|
throw error;
|
|
123
170
|
});
|
package/src/programs/loadout.ts
CHANGED
|
@@ -2,7 +2,10 @@ import { highlightCode, type ExtensionAPI, type ToolDefinition } from "@earendil
|
|
|
2
2
|
import { Text } from "@earendil-works/pi-tui";
|
|
3
3
|
import { CODEMODE_TOOL_NAME, type PiCodemode } from "./codemode.ts";
|
|
4
4
|
import { PROGRAM_TOOL_NAME } from "./execute.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { shortId } from "../agents/registry.ts";
|
|
6
|
+
import { formatDuration, formatToolCall, moreLines, renderResultList, renderTextResult, startDuration, type DurationState } from "../render-call.ts";
|
|
7
|
+
import type { Truncation } from "../output.ts";
|
|
8
|
+
import { omittedLine, runningLine, type ProgramResultDetails } from "./programs.ts";
|
|
6
9
|
|
|
7
10
|
/** The first line of the `codemode` description, which the `program` intro replaces. */
|
|
8
11
|
const CODEMODE_FIRST_LINE = "Run JavaScript code to orchestrate/compose tool calls\n";
|
|
@@ -91,9 +94,21 @@ export function programRenderers(piCodemode: PiCodemode): Renderers {
|
|
|
91
94
|
component.setText(text);
|
|
92
95
|
return component;
|
|
93
96
|
},
|
|
94
|
-
// `wait` shows how long it has waited
|
|
97
|
+
// `wait` shows how long it has waited, and when collapsed, each result as a line with the first lines of its output;
|
|
98
|
+
// the other results render like `codemode`.
|
|
95
99
|
renderResult: (result, options, theme, context) => (context.args as { action?: string }).action === "wait"
|
|
96
|
-
? renderTextResult(result, options, theme, context)
|
|
100
|
+
? renderTextResult(result, options, theme, context, () => {
|
|
101
|
+
const { results = [], running = [], interrupted, ...truncated } = (result.details ?? {}) as { results?: ProgramResultDetails[]; running?: string[]; interrupted?: boolean } & Partial<Truncation>;
|
|
102
|
+
const only = results[0];
|
|
103
|
+
// As `renderProgramWait` returns them, no results, or a single completed result alone, show as they are.
|
|
104
|
+
if (!only || (results.length === 1 && running.length === 0 && only.state === "completed")) return undefined;
|
|
105
|
+
const ids = [...results.map((entry) => entry.id), ...running];
|
|
106
|
+
const notes = [omittedLine(results.flatMap((entry) => entry.output === undefined ? [entry.id] : []), ids), runningLine(running, interrupted, ids)];
|
|
107
|
+
return renderResultList(results.flatMap(({ id, state, wallTimeSeconds, output }) => output === undefined ? [] : [{
|
|
108
|
+
line: [`Program ${shortId(id, ids)}`, state, ...(wallTimeSeconds === undefined ? [] : [formatDuration(wallTimeSeconds * 1000)])].join(" "),
|
|
109
|
+
body: output,
|
|
110
|
+
}]), notes, truncated, theme);
|
|
111
|
+
})
|
|
97
112
|
: piCodemode.renderResult(result, options, theme, context) as Text,
|
|
98
113
|
};
|
|
99
114
|
}
|
package/src/programs/programs.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
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";
|
|
5
|
+
import { boundBlocks, boundText, type Truncation } from "../output.ts";
|
|
6
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";
|
|
@@ -327,23 +327,64 @@ function resultBlock(record: ProgramRecord, ids: string[]): string {
|
|
|
327
327
|
return `<program-result id="${shortId(record.id, ids)}" status="${record.state}">\n${textOf(record.content)}\n</program-result>`;
|
|
328
328
|
}
|
|
329
329
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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: [] };
|
|
334
367
|
const images = (shown: ProgramRecord[]) => shown.flatMap((record) => (record.content ?? []).filter((item) => item.type === "image"));
|
|
335
368
|
const only = results[0]!;
|
|
336
369
|
if (results.length === 1 && running.length === 0 && only.state === "completed") {
|
|
337
370
|
const bounded = boundText(textOf(only.content), "pi-agents-program-wait");
|
|
338
|
-
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 };
|
|
339
372
|
}
|
|
340
|
-
const
|
|
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
|
+
});
|
|
341
382
|
let text = bounded.text;
|
|
342
383
|
const returned = results.slice(0, bounded.shown);
|
|
343
|
-
const
|
|
344
|
-
if (
|
|
345
|
-
if (
|
|
346
|
-
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 };
|
|
347
388
|
}
|
|
348
389
|
|
|
349
390
|
export function programListLine(record: ProgramRecord, ids: string[]): string {
|
package/src/programs/scope.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
4
|
import { CLEANUP_TIMEOUT_MS } from "./execute.ts";
|
|
5
5
|
import type { AgentOptions, ProgramAgentHost, SendOptions } from "./sandbox.ts";
|
|
6
6
|
|
|
@@ -70,12 +70,13 @@ export function programScope(
|
|
|
70
70
|
}
|
|
71
71
|
const agent = agents.ownedTarget(agentId).record;
|
|
72
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)}`;
|
|
73
|
-
const
|
|
73
|
+
const ids = visibleIds(agentId, false);
|
|
74
|
+
const text = messageText(from, true, delivery, body, ids);
|
|
74
75
|
if (delivery === "write") {
|
|
75
|
-
agents.accept(agentId, text, "write", { fromOwner: true, notification: false });
|
|
76
|
+
agents.accept(agentId, text, "write", { fromOwner: true, notification: false, sender: sender(from, ids) });
|
|
76
77
|
return undefined;
|
|
77
78
|
}
|
|
78
|
-
const { record, timedOut } = await agents.request(agentId, text, delivery, schema, timeout === undefined ? undefined : timeout * 1000);
|
|
79
|
+
const { record, timedOut } = await agents.request(agentId, text, delivery, sender(from, ids), schema, timeout === undefined ? undefined : timeout * 1000);
|
|
79
80
|
if (record.state === "completed") return schema === undefined ? record.result : record.value;
|
|
80
81
|
if (record.state === "failed") throw new Error(`Agent ${label(agent, agents.ids())} failed: ${record.result}`);
|
|
81
82
|
// An aborted input, timed out or not, shows as a cancelled call.
|
package/src/programs/tool.ts
CHANGED
|
@@ -203,7 +203,7 @@ export function registerProgramTools(
|
|
|
203
203
|
const outcome = programs.running(selected) ? await self.agents.whileSuspended(waitFor, signal, true) : await waitFor();
|
|
204
204
|
const rendered = renderProgramWait(outcome, programs.ids());
|
|
205
205
|
programs.markReturned(rendered.returned);
|
|
206
|
-
return { content: rendered.content, details: { running: outcome.running.map((record) => record.id), ...(outcome.interrupted && { interrupted: true }) } };
|
|
206
|
+
return { content: rendered.content, details: { ...rendered.details, running: outcome.running.map((record) => record.id), ...(outcome.interrupted && { interrupted: true }) } };
|
|
207
207
|
}
|
|
208
208
|
case "list": {
|
|
209
209
|
rejectFields(params, "list", ["code", "background", "timeout", "target"]);
|