@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.
- package/README.md +27 -20
- package/bin/daemon.mjs +32 -14
- package/bin/host.mjs +9 -1
- package/bin/pd.mjs +87 -42
- package/docs/HOSTS.md +111 -0
- package/docs/PROGRAM.md +39 -114
- package/docs/SPEC.md +109 -161
- package/package.json +7 -5
- package/src/agents/agents.ts +129 -55
- package/src/agents/registry.ts +62 -10
- package/src/agents/search.ts +21 -4
- package/src/agents/tool.ts +41 -13
- package/src/daemon/client.mjs +4 -1
- package/src/host/client.mjs +30 -5
- package/src/host/extension.ts +38 -14
- package/src/host/rpc-client.mjs +380 -0
- package/src/index.ts +44 -16
- package/src/output.ts +5 -1
- package/src/programs/codemode.ts +2 -2
- package/src/programs/execute.ts +150 -34
- package/src/programs/host-patches.ts +56 -27
- package/src/programs/loadout.ts +18 -3
- package/src/programs/programs.ts +128 -20
- package/src/programs/run-output.mjs +11 -0
- package/src/programs/sandbox.ts +17 -4
- package/src/programs/scope.ts +15 -7
- package/src/programs/tool.ts +172 -16
- package/src/render-call.ts +113 -15
- package/src/roots/guard.mjs +8 -2
- package/src/roots/paths.mjs +4 -4
- package/src/roots/queue.ts +52 -0
- package/src/roots/report.ts +12 -6
- package/src/roots/rpc.ts +618 -0
- package/src/roots/runtime.ts +17 -2
- package/src/roots/transport.mjs +41 -23
- package/src/ui/message.ts +24 -0
- package/src/ui/panel.ts +39 -7
- package/src/ui/queue.ts +47 -0
- package/src/ui/viewer.ts +182 -38
- package/src/workbench.mjs +91 -51
- package/docs/WORKBENCH.md +0 -167
package/src/agents/agents.ts
CHANGED
|
@@ -18,12 +18,15 @@ import { fileURLToPath } from "node:url";
|
|
|
18
18
|
import {
|
|
19
19
|
customMessage,
|
|
20
20
|
deliver,
|
|
21
|
+
addProgramSpend,
|
|
22
|
+
addSpend,
|
|
21
23
|
label,
|
|
22
24
|
messageText,
|
|
23
25
|
MESSAGE_TYPE,
|
|
24
26
|
nodes,
|
|
25
27
|
resolveTarget,
|
|
26
28
|
scheduler,
|
|
29
|
+
sender,
|
|
27
30
|
shortId,
|
|
28
31
|
TREE_ENTRY,
|
|
29
32
|
trackTreeWork,
|
|
@@ -31,15 +34,18 @@ import {
|
|
|
31
34
|
visibleIds,
|
|
32
35
|
type AgentState,
|
|
33
36
|
type Delivery,
|
|
37
|
+
type Sender,
|
|
34
38
|
type Entry,
|
|
39
|
+
type Spend,
|
|
35
40
|
type TreeMetadata,
|
|
36
41
|
} from "./registry.ts";
|
|
42
|
+
import { defaultSessionDir } from "../roots/paths.mjs";
|
|
37
43
|
|
|
38
44
|
const AGENT_ENTRY = "pi-agents-agent";
|
|
39
45
|
const SHUTDOWN_TIMEOUT_MS = 10_000;
|
|
40
46
|
const EXTENSION_PATH = fileURLToPath(new URL("../index.ts", import.meta.url));
|
|
41
47
|
export const SUBMIT_RESULT_TOOL_NAME = "submit_result";
|
|
42
|
-
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
|
|
48
|
+
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
43
49
|
|
|
44
50
|
export type Outcome = "completed" | "failed" | "aborted";
|
|
45
51
|
type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
|
|
@@ -72,16 +78,11 @@ export interface AgentRecord {
|
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
|
|
75
|
-
function defaultSessionDirectory(cwd: string, agentDir: string): string {
|
|
76
|
-
const safePath = `--${path.resolve(cwd).replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
|
77
|
-
return path.join(path.resolve(agentDir), "sessions", safePath);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
81
|
function childSessionDirectory(cwd: string, currentCwd: string, currentSessionDir: string, agentDir: string): string {
|
|
81
|
-
const targetDefault =
|
|
82
|
+
const targetDefault = defaultSessionDir(cwd, agentDir);
|
|
82
83
|
if (!currentSessionDir) return path.join(targetDefault, "subagents");
|
|
83
84
|
const current = path.resolve(currentSessionDir);
|
|
84
|
-
const currentDefault =
|
|
85
|
+
const currentDefault = defaultSessionDir(currentCwd, agentDir);
|
|
85
86
|
if (current === currentDefault ||
|
|
86
87
|
(path.basename(current) === "subagents" && path.dirname(current) === currentDefault)) {
|
|
87
88
|
return path.join(targetDefault, "subagents");
|
|
@@ -121,8 +122,8 @@ function lastAnswer(session: AgentSession, before: number): { outcome: Outcome;
|
|
|
121
122
|
return { outcome: "completed", result: text };
|
|
122
123
|
}
|
|
123
124
|
|
|
124
|
-
/** Takes this extension's messages
|
|
125
|
-
function takeQueued(agent: AgentSession["agent"]) {
|
|
125
|
+
/** Takes this extension's messages out of the agent queues. */
|
|
126
|
+
export function takeQueued(agent: AgentSession["agent"]) {
|
|
126
127
|
// peekQueuedMessages returns one message in one-at-a-time mode; read the queues whole.
|
|
127
128
|
const queues = agent as unknown as Record<"steeringQueue" | "followUpQueue", { messages: QueuedMessage[] }>;
|
|
128
129
|
const steering = queues.steeringQueue.messages.slice();
|
|
@@ -135,6 +136,19 @@ function takeQueued(agent: AgentSession["agent"]) {
|
|
|
135
136
|
return [...steering, ...followUps].filter(ours);
|
|
136
137
|
}
|
|
137
138
|
|
|
139
|
+
/** Whether a steer, which this turn must take in before its next model call, waits in the agent's queue; a write does not count. */
|
|
140
|
+
export function steerQueued(agent: AgentSession["agent"] | undefined): boolean {
|
|
141
|
+
if (!agent) return false;
|
|
142
|
+
const queue = (agent as unknown as { steeringQueue: { messages: QueuedMessage[] } }).steeringQueue.messages;
|
|
143
|
+
return queue.some((message) => !(message.role === "custom" && message.customType === MESSAGE_TYPE
|
|
144
|
+
&& (message as { details?: { delivery?: string } }).details?.delivery === "write"));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Pi does not report steers queued by extensions, so a wait checks the queue this often.
|
|
148
|
+
export const STEER_POLL_MS = 250;
|
|
149
|
+
|
|
150
|
+
export const WAIT_INTERRUPTED = "Stopped waiting for a new message. Wait again to keep waiting.";
|
|
151
|
+
|
|
138
152
|
export interface Deferred {
|
|
139
153
|
promise: Promise<void>;
|
|
140
154
|
resolve(): void;
|
|
@@ -187,8 +201,10 @@ function ended(input: InputRecord): boolean {
|
|
|
187
201
|
|
|
188
202
|
interface Input {
|
|
189
203
|
text: string;
|
|
190
|
-
/** The user's input from the
|
|
204
|
+
/** The user's input from the task viewer. */
|
|
191
205
|
user?: boolean;
|
|
206
|
+
/** The Agent that sent it. */
|
|
207
|
+
sender?: Sender;
|
|
192
208
|
record?: InputRecord;
|
|
193
209
|
permit?: symbol;
|
|
194
210
|
schema?: unknown;
|
|
@@ -198,7 +214,7 @@ interface Input {
|
|
|
198
214
|
silent?: boolean;
|
|
199
215
|
}
|
|
200
216
|
|
|
201
|
-
type Message = Pick<Input, "text" | "user">;
|
|
217
|
+
type Message = Pick<Input, "text" | "user" | "sender">;
|
|
202
218
|
|
|
203
219
|
interface Turn {
|
|
204
220
|
inputs: Input[];
|
|
@@ -208,6 +224,10 @@ interface Turn {
|
|
|
208
224
|
started: boolean;
|
|
209
225
|
/** Whether the turn holds a scheduler slot. */
|
|
210
226
|
slot: boolean;
|
|
227
|
+
/** When the turn got its slot, in milliseconds. */
|
|
228
|
+
since?: number;
|
|
229
|
+
/** What the turn's replies have cost. */
|
|
230
|
+
spent: Spend;
|
|
211
231
|
finished: boolean;
|
|
212
232
|
done: Deferred;
|
|
213
233
|
}
|
|
@@ -226,6 +246,8 @@ interface Owned {
|
|
|
226
246
|
loading?: Promise<AgentSession>;
|
|
227
247
|
prepared?: Prepared;
|
|
228
248
|
waiters: number;
|
|
249
|
+
/** Its turn was stopped: messages from other Agents become writes until its owner's or user's next input. */
|
|
250
|
+
stopped?: boolean;
|
|
229
251
|
}
|
|
230
252
|
|
|
231
253
|
export interface SpawnRequest {
|
|
@@ -246,6 +268,8 @@ export interface WaitResult {
|
|
|
246
268
|
export interface WaitOutcome {
|
|
247
269
|
results: WaitResult[];
|
|
248
270
|
pending: AgentRecord[];
|
|
271
|
+
/** A steer ended the wait. */
|
|
272
|
+
interrupted?: boolean;
|
|
249
273
|
}
|
|
250
274
|
|
|
251
275
|
/** The Agents owned by one loaded Agent. */
|
|
@@ -287,11 +311,11 @@ export class Agents {
|
|
|
287
311
|
return [...this.agents.values()].some((agent) => agent.turn !== undefined || agent.queue.length > 0);
|
|
288
312
|
}
|
|
289
313
|
|
|
290
|
-
/** An owned Agent's state,
|
|
291
|
-
counts(id: string): { state: AgentState; pending: number } | undefined {
|
|
314
|
+
/** An owned Agent's state, its inputs pending behind its current or next turn, when its turn got a slot, and what the turn has cost. */
|
|
315
|
+
counts(id: string): { state: AgentState; pending: number; since?: number; spent?: Spend } | undefined {
|
|
292
316
|
const agent = this.agents.get(id);
|
|
293
317
|
if (!agent) return undefined;
|
|
294
|
-
return { state: this.state(agent), pending: agent.queue.length };
|
|
318
|
+
return { state: this.state(agent), pending: agent.queue.length, since: agent.turn?.since, spent: agent.turn?.spent };
|
|
295
319
|
}
|
|
296
320
|
|
|
297
321
|
private state(agent: Owned): AgentState {
|
|
@@ -362,8 +386,9 @@ export class Agents {
|
|
|
362
386
|
scheduler.releaseOutstanding(this.self.rootId, permit);
|
|
363
387
|
throw error;
|
|
364
388
|
}
|
|
365
|
-
const
|
|
366
|
-
const
|
|
389
|
+
const self = { id: this.self.id, name: this.self.name() };
|
|
390
|
+
const text = messageText(self, true, "followUp", request.message, this.ids());
|
|
391
|
+
const queued = this.enqueue(agent, { text, sender: sender(self, this.ids()), permit, record: this.newRecord(agent) });
|
|
367
392
|
return { record: agent.record, queued };
|
|
368
393
|
}
|
|
369
394
|
|
|
@@ -391,16 +416,27 @@ export class Agents {
|
|
|
391
416
|
}
|
|
392
417
|
|
|
393
418
|
/**
|
|
394
|
-
* Sends an input from the owner and resolves when it ends. With a schema, the Agent must call
|
|
419
|
+
* Sends an input from the owner, `from` as its text names it, and resolves when it ends. With a schema, the Agent must call
|
|
395
420
|
* `submit_result` with a matching value, which becomes the input's `value`; `text` tells it so.
|
|
421
|
+
* An input not ended within `timeoutMs` is aborted alone, and `timedOut` is set.
|
|
396
422
|
*/
|
|
397
|
-
async request(id: string, text: string, delivery: "followUp" | "steer", schema?: unknown): Promise<InputRecord> {
|
|
423
|
+
async request(id: string, text: string, delivery: "followUp" | "steer", from: Sender, schema?: unknown, timeoutMs?: number): Promise<{ record: InputRecord; timedOut: boolean }> {
|
|
398
424
|
const agent = this.agent(id);
|
|
399
|
-
const input: Input = { text, permit: this.reserve(), record: this.newRecord(agent), done: deferred() };
|
|
425
|
+
const input: Input = { text, sender: from, permit: this.reserve(), record: this.newRecord(agent), done: deferred() };
|
|
400
426
|
if (schema !== undefined) input.schema = schema;
|
|
401
427
|
this.add(agent, input, delivery);
|
|
402
|
-
|
|
403
|
-
|
|
428
|
+
let stopping: Promise<void> | undefined;
|
|
429
|
+
const timer = timeoutMs === undefined ? undefined : setTimeout(() => {
|
|
430
|
+
stopping = this.cancel(agent, input);
|
|
431
|
+
stopping.catch(() => {});
|
|
432
|
+
}, timeoutMs);
|
|
433
|
+
try {
|
|
434
|
+
await input.done!.promise;
|
|
435
|
+
} finally {
|
|
436
|
+
clearTimeout(timer);
|
|
437
|
+
}
|
|
438
|
+
await stopping;
|
|
439
|
+
return { record: input.record!, timedOut: stopping !== undefined && input.record!.state === "aborted" };
|
|
404
440
|
}
|
|
405
441
|
|
|
406
442
|
/** Records a value from `submit_result` for the Agent's current input with a schema. */
|
|
@@ -414,19 +450,20 @@ export class Agents {
|
|
|
414
450
|
input.submitted = { value: args.value };
|
|
415
451
|
}
|
|
416
452
|
|
|
417
|
-
accept(id: string, text: string, delivery: Delivery, options: { fromOwner: boolean; notification: boolean }): { queued: boolean } {
|
|
453
|
+
accept(id: string, text: string, delivery: Delivery, options: { fromOwner: boolean; notification: boolean; sender?: Sender }): { queued: boolean } {
|
|
418
454
|
const agent = this.agent(id);
|
|
455
|
+
if (agent.stopped && !options.fromOwner) delivery = "write";
|
|
419
456
|
if (delivery === "write") {
|
|
420
|
-
this.write(agent, text);
|
|
457
|
+
this.write(agent, text, options.sender);
|
|
421
458
|
return { queued: false };
|
|
422
459
|
}
|
|
423
460
|
// Notifications keep their owner informed even at the input limit.
|
|
424
461
|
const permit = options.notification ? undefined : this.reserve();
|
|
425
|
-
const input: Input = { text, permit, record: options.fromOwner && !options.notification ? this.newRecord(agent) : undefined };
|
|
462
|
+
const input: Input = { text, sender: options.sender, permit, record: options.fromOwner && !options.notification ? this.newRecord(agent) : undefined };
|
|
426
463
|
return { queued: this.add(agent, input, delivery) };
|
|
427
464
|
}
|
|
428
465
|
|
|
429
|
-
async wait(targets: Owned[] | undefined, history: number, timeoutSeconds: number, signal?: AbortSignal): Promise<WaitOutcome> {
|
|
466
|
+
async wait(targets: Owned[] | undefined, history: number, timeoutSeconds: number, signal?: AbortSignal, steered?: () => boolean): Promise<WaitOutcome> {
|
|
430
467
|
const selected = targets ?? [...this.agents.values()].filter((agent) =>
|
|
431
468
|
this.pending(agent) || agent.record.inputs.some((input) => ended(input) && !input.read));
|
|
432
469
|
const unique = [...new Set(selected)];
|
|
@@ -438,13 +475,18 @@ export class Agents {
|
|
|
438
475
|
const suspended = blocking && this.self.ownerId !== undefined && scheduler.suspend(this.self.rootId, this.self.id);
|
|
439
476
|
if (blocking) this.markWaiting(1);
|
|
440
477
|
let returned = false;
|
|
478
|
+
let interrupted = false;
|
|
441
479
|
try {
|
|
442
480
|
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
443
481
|
while (unique.some((agent) => this.pending(agent))) {
|
|
444
482
|
const remaining = deadline - Date.now();
|
|
445
483
|
if (remaining <= 0) break;
|
|
484
|
+
if (steered?.()) {
|
|
485
|
+
interrupted = true;
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
446
488
|
signal?.throwIfAborted();
|
|
447
|
-
await waitForChange(this.change.promise, remaining, signal);
|
|
489
|
+
await waitForChange(this.change.promise, steered ? Math.min(remaining, STEER_POLL_MS) : remaining, signal);
|
|
448
490
|
}
|
|
449
491
|
signal?.throwIfAborted();
|
|
450
492
|
const results: WaitResult[] = [];
|
|
@@ -455,7 +497,7 @@ export class Agents {
|
|
|
455
497
|
}
|
|
456
498
|
}
|
|
457
499
|
returned = true;
|
|
458
|
-
return { results, pending: unique.filter((agent) => this.pending(agent)).map((agent) => agent.record) };
|
|
500
|
+
return { results, pending: unique.filter((agent) => this.pending(agent)).map((agent) => agent.record), ...(interrupted && { interrupted }) };
|
|
459
501
|
} finally {
|
|
460
502
|
if (blocking) this.markWaiting(-1);
|
|
461
503
|
if (suspended) await scheduler.resume(this.self.rootId, this.self.id, this.limits.maxConcurrent, signal);
|
|
@@ -494,22 +536,38 @@ export class Agents {
|
|
|
494
536
|
if (silent) for (const input of [...agent.queue, ...(turn?.inputs ?? [])]) input.silent = true;
|
|
495
537
|
for (const input of agent.queue.splice(0)) this.end(agent, input, "aborted", "");
|
|
496
538
|
this.changed();
|
|
497
|
-
if (turn)
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
539
|
+
if (turn) await this.stop(agent, turn, deadline);
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Ends one input as aborted: withdraws it if queued, or else stops its turn, as `abort` does. */
|
|
544
|
+
private async cancel(agent: Owned, input: Input): Promise<void> {
|
|
545
|
+
const index = agent.queue.indexOf(input);
|
|
546
|
+
if (index !== -1) {
|
|
547
|
+
agent.queue.splice(index, 1);
|
|
548
|
+
this.end(agent, input, "aborted", "");
|
|
549
|
+
this.changed();
|
|
550
|
+
} else if (agent.turn?.inputs.includes(input)) {
|
|
551
|
+
await this.stop(agent, agent.turn, Date.now() + SHUTDOWN_TIMEOUT_MS);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Stops a turn; one that has not stopped by `deadline` is abandoned: its inputs end as aborted, and this throws. */
|
|
556
|
+
private async stop(agent: Owned, turn: Turn, deadline: number): Promise<void> {
|
|
557
|
+
agent.stopped = true;
|
|
558
|
+
turn.abort.abort();
|
|
559
|
+
const stopping = (async () => {
|
|
560
|
+
if (turn.started && agent.session) {
|
|
561
|
+
agent.session.abortCompaction();
|
|
562
|
+
agent.session.abortRetry();
|
|
563
|
+
await agent.session.abort();
|
|
510
564
|
}
|
|
565
|
+
await turn.done.promise;
|
|
566
|
+
})();
|
|
567
|
+
if (!await settlesBy(stopping, deadline)) {
|
|
568
|
+
this.finish(agent, turn, { outcome: "aborted", result: "" });
|
|
569
|
+
throw new Error(`Agent ${label(agent.record, this.ids())} did not stop within the timeout and may still be running.`);
|
|
511
570
|
}
|
|
512
|
-
return true;
|
|
513
571
|
}
|
|
514
572
|
|
|
515
573
|
restore(ctx: ExtensionContext): void {
|
|
@@ -539,9 +597,7 @@ export class Agents {
|
|
|
539
597
|
const deadline = Date.now() + SHUTDOWN_TIMEOUT_MS;
|
|
540
598
|
const agents = [...this.agents.values()];
|
|
541
599
|
const aborted = await Promise.allSettled(agents.map((agent) => this.abort(agent, { deadline })));
|
|
542
|
-
const
|
|
543
|
-
const session = agent.session ?? await agent.loading?.catch(() => undefined);
|
|
544
|
-
if (!session) return;
|
|
600
|
+
const close = async (agent: Owned, session: AgentSession) => {
|
|
545
601
|
try {
|
|
546
602
|
if (!await settlesBy(session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" }), deadline)) {
|
|
547
603
|
throw new Error(`Agent ${label(agent.record, this.ids())} did not shut down within the timeout and may still be running.`);
|
|
@@ -550,13 +606,23 @@ export class Agents {
|
|
|
550
606
|
session.dispose();
|
|
551
607
|
agent.session = undefined;
|
|
552
608
|
}
|
|
609
|
+
};
|
|
610
|
+
const closed = await Promise.allSettled(agents.map(async (agent) => {
|
|
611
|
+
const loading = agent.loading;
|
|
612
|
+
if (!agent.session && loading && !await settlesBy(loading, deadline)) {
|
|
613
|
+
// A Session that loads after the timeout is closed once it does.
|
|
614
|
+
void loading.then((late) => close(agent, late).catch(() => {}), () => {});
|
|
615
|
+
throw new Error(`Agent ${label(agent.record, this.ids())} did not shut down within the timeout and may still be running.`);
|
|
616
|
+
}
|
|
617
|
+
const session = agent.session ?? await loading?.catch(() => undefined);
|
|
618
|
+
if (session) await close(agent, session);
|
|
553
619
|
}));
|
|
554
620
|
const errors = [...aborted, ...closed].flatMap((result) => result.status === "rejected" ? [result.reason] : []);
|
|
555
621
|
if (errors.length > 0) throw new Error(errors.map((error) => error instanceof Error ? error.message : String(error)).join("\n"));
|
|
556
622
|
}
|
|
557
623
|
|
|
558
|
-
private write(agent: Owned, text: string): void {
|
|
559
|
-
const message = customMessage(text, "write");
|
|
624
|
+
private write(agent: Owned, text: string, from?: Sender): void {
|
|
625
|
+
const message = customMessage(text, "write", { sender: from });
|
|
560
626
|
const session = agent.session;
|
|
561
627
|
if (session?.isStreaming) {
|
|
562
628
|
session.agent.steer({ role: "custom", ...message, timestamp: Date.now() });
|
|
@@ -584,6 +650,8 @@ export class Agents {
|
|
|
584
650
|
|
|
585
651
|
/** A steer joins the current turn; other inputs queue. Returns whether the input waits for a slot. */
|
|
586
652
|
private add(agent: Owned, input: Input, delivery: "followUp" | "steer"): boolean {
|
|
653
|
+
// While stopped, other senders' messages become writes, so this is the owner's or user's next input.
|
|
654
|
+
agent.stopped = false;
|
|
587
655
|
if (delivery !== "steer" || !agent.turn) return this.enqueue(agent, input);
|
|
588
656
|
this.join(agent, agent.turn, input);
|
|
589
657
|
return false;
|
|
@@ -596,9 +664,9 @@ export class Agents {
|
|
|
596
664
|
this.changed();
|
|
597
665
|
if (!turn.started) return;
|
|
598
666
|
if (agent.session?.isStreaming) {
|
|
599
|
-
agent.session.agent.steer({ role: "custom", ...customMessage(input.text, "steer",
|
|
667
|
+
agent.session.agent.steer({ role: "custom", ...customMessage(input.text, "steer", { user: input.user, sender: input.sender }), timestamp: Date.now() });
|
|
600
668
|
} else {
|
|
601
|
-
turn.pending.push({ text: input.text, user: input.user });
|
|
669
|
+
turn.pending.push({ text: input.text, user: input.user, sender: input.sender });
|
|
602
670
|
}
|
|
603
671
|
}
|
|
604
672
|
|
|
@@ -606,7 +674,7 @@ export class Agents {
|
|
|
606
674
|
if (agent.turn || this.closing) return;
|
|
607
675
|
const head = agent.queue.shift();
|
|
608
676
|
if (!head) return;
|
|
609
|
-
const turn: Turn = { inputs: [head], pending: [], abort: new AbortController(), started: false, slot: false, finished: false, done: deferred() };
|
|
677
|
+
const turn: Turn = { inputs: [head], pending: [], abort: new AbortController(), started: false, slot: false, spent: { cost: 0, tokens: 0, unpriced: false }, finished: false, done: deferred() };
|
|
610
678
|
agent.turn = turn;
|
|
611
679
|
void this.runTurn(agent, turn);
|
|
612
680
|
}
|
|
@@ -616,6 +684,7 @@ export class Agents {
|
|
|
616
684
|
try {
|
|
617
685
|
await scheduler.acquireQueued(this.self.rootId, agent.record.id, this.limits.maxConcurrent, turn.abort.signal);
|
|
618
686
|
turn.slot = true;
|
|
687
|
+
turn.since = Date.now();
|
|
619
688
|
const session = await this.load(agent);
|
|
620
689
|
turn.abort.signal.throwIfAborted();
|
|
621
690
|
const before = session.getSessionStats().assistantMessages;
|
|
@@ -623,11 +692,11 @@ export class Agents {
|
|
|
623
692
|
for (const input of turn.inputs) if (input.record) input.record.state = "running";
|
|
624
693
|
this.persist(agent);
|
|
625
694
|
this.changed();
|
|
626
|
-
let batch: Message[] = turn.inputs.map(({ text, user }) => ({ text, user }));
|
|
695
|
+
let batch: Message[] = turn.inputs.map(({ text, user, sender }) => ({ text, user, sender }));
|
|
627
696
|
while (batch.length > 0 && !turn.abort.signal.aborted) {
|
|
628
|
-
for (const { text,
|
|
629
|
-
const
|
|
630
|
-
await session.sendCustomMessage(customMessage(
|
|
697
|
+
for (const { text, ...rest } of batch.slice(0, -1)) await session.sendCustomMessage(customMessage(text, "steer", rest), { triggerTurn: false });
|
|
698
|
+
const { text, ...rest } = batch.at(-1)!;
|
|
699
|
+
await session.sendCustomMessage(customMessage(text, "steer", rest), { triggerTurn: true });
|
|
631
700
|
await session.waitForIdle();
|
|
632
701
|
const leftover = takeQueued(session.agent);
|
|
633
702
|
for (const message of leftover) {
|
|
@@ -635,7 +704,7 @@ export class Agents {
|
|
|
635
704
|
}
|
|
636
705
|
batch = [
|
|
637
706
|
...turn.pending.splice(0),
|
|
638
|
-
...leftover.flatMap(({ content, details }) => details.delivery === "write" ? [] : [{ text: content, user: details.user }]),
|
|
707
|
+
...leftover.flatMap(({ content, details }) => details.delivery === "write" ? [] : [{ text: content, user: details.user, sender: details.sender }]),
|
|
639
708
|
];
|
|
640
709
|
}
|
|
641
710
|
const last = lastAnswer(session, before);
|
|
@@ -877,6 +946,11 @@ export class Agents {
|
|
|
877
946
|
finally { session.dispose(); }
|
|
878
947
|
throw error;
|
|
879
948
|
}
|
|
949
|
+
session.subscribe((event) => {
|
|
950
|
+
if (event.type !== "message_end" || event.message.role !== "assistant") return;
|
|
951
|
+
if (agent.turn) addSpend(agent.turn.spent, event.message.usage);
|
|
952
|
+
addProgramSpend(this.self.id, event.message.usage);
|
|
953
|
+
});
|
|
880
954
|
return session;
|
|
881
955
|
}
|
|
882
956
|
}
|
package/src/agents/registry.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** The Agents loaded in this process, shared across trees: who sees whom, how targets resolve, and how messages are addressed. */
|
|
2
|
+
import type { Usage } from "@earendil-works/pi-ai";
|
|
2
3
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
import type { Agents } from "./agents.ts";
|
|
4
5
|
import { TreeScheduler } from "./scheduler.ts";
|
|
@@ -16,19 +17,23 @@ export interface AgentNode {
|
|
|
16
17
|
/** The node whose tree this node sees: its root, or the Program its Agents belong to. */
|
|
17
18
|
scopeId: string;
|
|
18
19
|
ownerId?: string;
|
|
19
|
-
/** A Program that owns Agents; no Agent sees it, but the user sees it in the task panel and the
|
|
20
|
+
/** A Program that owns Agents; no Agent sees it, but the user sees it in the task panel and the task viewer. */
|
|
20
21
|
program?: boolean;
|
|
21
22
|
/** Whether a Program runs in the background. */
|
|
22
23
|
background?: boolean;
|
|
23
24
|
/** When a Program started. */
|
|
24
25
|
createdAt?: string;
|
|
26
|
+
/** What the replies of a Program's Agents and the Agents under them have cost since it started. */
|
|
27
|
+
spent?: Spend;
|
|
25
28
|
name(): string | undefined;
|
|
26
29
|
cwd(): string;
|
|
27
30
|
busy(): boolean;
|
|
28
31
|
sessionFile(): string | undefined;
|
|
29
32
|
agents: Agents;
|
|
30
33
|
/** Delivers a message to this node; used only for root Agents. */
|
|
31
|
-
receive(text: string, delivery: Delivery, messageId?: string): void;
|
|
34
|
+
receive(text: string, delivery: Delivery, messageId?: string, sender?: Sender): void;
|
|
35
|
+
/** A root Agent's turn was stopped: messages from other Agents become writes until the user's next input. */
|
|
36
|
+
stopped?: boolean;
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
export interface Entry {
|
|
@@ -86,6 +91,26 @@ export function trackTreeWork(rootId: string): () => void {
|
|
|
86
91
|
};
|
|
87
92
|
}
|
|
88
93
|
|
|
94
|
+
/** What replies cost: dollars, input and output tokens, and whether a reply had no price. */
|
|
95
|
+
export interface Spend {
|
|
96
|
+
cost: number;
|
|
97
|
+
tokens: number;
|
|
98
|
+
unpriced: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function addSpend(spend: Spend, usage: Usage): void {
|
|
102
|
+
spend.cost += usage.cost.total;
|
|
103
|
+
spend.tokens += usage.input + usage.output;
|
|
104
|
+
if (usage.cost.total === 0 && usage.input + usage.output + usage.cacheRead + usage.cacheWrite > 0) spend.unpriced = true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Adds a reply of an Agent owned by `ownerId` to the Programs above it. */
|
|
108
|
+
export function addProgramSpend(ownerId: string, usage: Usage): void {
|
|
109
|
+
for (let node = shared.nodes.get(ownerId); node; node = node.ownerId === undefined ? undefined : shared.nodes.get(node.ownerId)) {
|
|
110
|
+
if (node.spent) addSpend(node.spent, usage);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
89
114
|
/** The Programs a node runs, oldest first. */
|
|
90
115
|
export function programsOf(node: AgentNode): AgentNode[] {
|
|
91
116
|
return [...shared.nodes.values()]
|
|
@@ -169,7 +194,9 @@ export function treeMetadata(ctx: ExtensionContext): TreeMetadata | undefined {
|
|
|
169
194
|
export function treeEntries(scopeId: string): Entry[] {
|
|
170
195
|
const root = shared.nodes.get(scopeId);
|
|
171
196
|
if (!root) return [];
|
|
172
|
-
|
|
197
|
+
// A Program's Agent heads its own scope; it stays owned, so messages to it go through its owner.
|
|
198
|
+
const owned = root.ownerId === undefined ? undefined : shared.nodes.get(root.ownerId)?.agents.owned().find((agent) => agent.id === root.id);
|
|
199
|
+
const entries: Entry[] = root.program ? [] : [owned ? { ...owned, ownerId: root.ownerId } : {
|
|
173
200
|
id: root.id,
|
|
174
201
|
name: root.name(),
|
|
175
202
|
cwd: root.cwd(),
|
|
@@ -215,31 +242,56 @@ export function deliver(
|
|
|
215
242
|
const fromOwner = from !== undefined && target.ownerId === from.id;
|
|
216
243
|
const ids = recipientIds(target);
|
|
217
244
|
const text = from === undefined ? body : messageText(from, fromOwner, delivery, body, ids);
|
|
245
|
+
const by = from && sender(from, ids);
|
|
218
246
|
if (target.ownerId === undefined) {
|
|
219
247
|
const node = shared.nodes.get(target.id);
|
|
220
248
|
if (!node) throw new Error(`${label(target, ids)} is offline.`);
|
|
221
|
-
node.receive(text, delivery);
|
|
249
|
+
node.receive(text, delivery, undefined, by);
|
|
222
250
|
return { queued: false };
|
|
223
251
|
}
|
|
224
252
|
const owner = shared.nodes.get(target.ownerId);
|
|
225
253
|
if (!owner) throw new Error(`${label(target, ids)} is offline.`);
|
|
226
|
-
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 });
|
|
227
255
|
}
|
|
228
256
|
|
|
229
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
|
+
}
|
|
230
274
|
|
|
231
275
|
/** A message in an element naming its sender; an input from a sender other than the owner asks for a reply. */
|
|
232
276
|
export function messageText(from: { id: string; name?: string }, fromOwner: boolean, delivery: Delivery, body: string, ids: Iterable<string>): string {
|
|
233
|
-
const note = !fromOwner && delivery !== "write" ?
|
|
234
|
-
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;
|
|
235
287
|
}
|
|
236
288
|
|
|
237
289
|
/**
|
|
238
290
|
* `messageId` marks a message from a root Agent in another process, so it is added once; `user`
|
|
239
|
-
* marks the user's input from the Agent
|
|
291
|
+
* marks the user's input from the task viewer, and `sender` the Agent that sent it.
|
|
240
292
|
*/
|
|
241
|
-
export function customMessage(text: string, delivery: Delivery, messageId?: string
|
|
242
|
-
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 } : {}) } };
|
|
243
295
|
}
|
|
244
296
|
|
|
245
297
|
export function receivedMessageIds(ctx: ExtensionContext): string[] {
|
package/src/agents/search.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { entryLine, MESSAGE_TAG, MESSAGE_TYPE, type Entry } from "./registry.ts"
|
|
|
4
4
|
interface SearchText {
|
|
5
5
|
time: number;
|
|
6
6
|
text: string;
|
|
7
|
+
/** A user message or an input, not a summary, write, or notification. */
|
|
8
|
+
request?: boolean;
|
|
7
9
|
}
|
|
8
10
|
|
|
9
11
|
/** Parsed texts by Session file, reused while the file is unchanged. */
|
|
@@ -32,9 +34,10 @@ function sessionTexts(sessionFile: string | undefined): SearchText[] {
|
|
|
32
34
|
const text = typeof content === "string"
|
|
33
35
|
? content
|
|
34
36
|
: Array.isArray(content) ? content.flatMap((part: any) => part?.type === "text" ? [part.text] : []).join("\n") : "";
|
|
35
|
-
if (text) texts.push({ time, text });
|
|
37
|
+
if (text) texts.push({ time, text, request: true });
|
|
36
38
|
} else if (entry.type === "custom_message" && entry.customType === MESSAGE_TYPE && typeof entry.content === "string") {
|
|
37
|
-
|
|
39
|
+
const request = entry.details?.delivery !== "write" && (entry.content.startsWith(`<${MESSAGE_TAG} `) || entry.details?.user === true);
|
|
40
|
+
texts.push({ time, text: entry.content, request });
|
|
38
41
|
}
|
|
39
42
|
}
|
|
40
43
|
cache.set(sessionFile, { stamp, texts });
|
|
@@ -85,9 +88,23 @@ export function searchEntries(entries: Entry[], query: string | undefined): List
|
|
|
85
88
|
return items.sort((left, right) => right.time - left.time).map(({ time: _time, ...item }) => item);
|
|
86
89
|
}
|
|
87
90
|
|
|
91
|
+
/** An entry with its latest matching excerpt, else its latest request, and its time. */
|
|
88
92
|
export function listLine(item: ListItem, ids: Iterable<string>): string {
|
|
89
93
|
const line = entryLine(item.entry, ids);
|
|
90
|
-
|
|
94
|
+
const detail = item.match ?? latestRequest(item.entry.sessionFile);
|
|
95
|
+
return detail ? `${line}\n ${formatTime(detail.time)} ${detail.excerpt}` : line;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function latestRequest(sessionFile: string | undefined): { time: number; excerpt: string } | undefined {
|
|
99
|
+
const text = sessionTexts(sessionFile).findLast((text) => text.request);
|
|
100
|
+
if (!text) return undefined;
|
|
101
|
+
const flat = messageBody(text.text).replace(/\s+/g, " ").trim();
|
|
102
|
+
return { time: text.time, excerpt: flat.length > 120 ? `${flat.slice(0, 120)}…` : flat };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A message's body without its `agent-message` element. */
|
|
106
|
+
function messageBody(content: string): string {
|
|
107
|
+
return content.startsWith(`<${MESSAGE_TAG} `) ? content.slice(content.indexOf("\n") + 1, content.lastIndexOf("\n")) : content;
|
|
91
108
|
}
|
|
92
109
|
|
|
93
110
|
/**
|
|
@@ -103,7 +120,7 @@ export function latestActivity(sessionFile: string | undefined): string | undefi
|
|
|
103
120
|
let entry: Record<string, any>;
|
|
104
121
|
try { entry = JSON.parse(line); } catch { continue; }
|
|
105
122
|
if (input === undefined && entry.type === "custom_message" && entry.customType === MESSAGE_TYPE && typeof entry.content === "string") {
|
|
106
|
-
input =
|
|
123
|
+
input = messageBody(entry.content);
|
|
107
124
|
}
|
|
108
125
|
if (input !== undefined && entry.type === "message" && entry.message?.role === "assistant" && Array.isArray(entry.message.content)) {
|
|
109
126
|
activity = replyActivity(entry.message.content) ?? activity;
|