@tshu-w/pi-agents 0.1.1 → 0.2.0

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/docs/PROGRAM.md CHANGED
@@ -25,7 +25,7 @@ target is an ID or a unique ID prefix.
25
25
  Agents created with `agent()` belong to the Program: it gets their results
26
26
  from `send()`, without `wait` or notifications, and only it can abort
27
27
  them. Each of these Agents and the Agents under it see only one another,
28
- and no other Agent sees them. The user sees them in `/tasks` and `/agents`,
28
+ and no other Agent sees them. The user sees them in the task panel and `/tasks`,
29
29
  and can talk to them or interrupt them in the viewer; an interrupted input
30
30
  makes its `send()` throw. When the Program ends, their unfinished inputs end
31
31
  as `aborted`, and the Agents go offline for good, from the bottom up.
@@ -45,7 +45,8 @@ A Program is `running` until it ends in one of three ways:
45
45
  - `stop` stops a running Program.
46
46
 
47
47
  When a background Program ends and its caller is not waiting for it, the
48
- caller gets a notification, as in SPEC §5.
48
+ caller gets a notification, as in SPEC §5. A steer input ends `wait` early,
49
+ as in SPEC §5.
49
50
 
50
51
  ## 3. Program API
51
52
 
@@ -54,39 +55,23 @@ can use is checked at each call. If a Program fails or is stopped, the
54
55
  error or stop reason follows its output. Its `store()` writes are kept
55
56
  only if it completes.
56
57
 
57
- A Program also creates Agents with `agent()`:
58
-
59
- ```ts
60
- agent(options?: {
61
- name?: string;
62
- cwd?: string;
63
- context?: "fresh" | "fork";
64
- model?: string;
65
- thinkingLevel?: string;
66
- }): AgentHandle
67
-
68
- interface AgentHandle {
69
- readonly id: string;
70
- readonly name: string;
71
- send(message: string, options?: {
72
- deliverAs?: "followUp" | "steer" | "write";
73
- schema?: JsonSchema;
74
- }): Promise<unknown>;
75
- abort(): Promise<void>;
76
- }
77
- ```
78
-
79
- `agent()` creates an idle Agent. Options work like `agent` `spawn`; an
80
- omitted name is generated, unique within the Program. `send()` sends a
81
- message and returns its result. `abort()` works like `agent` `abort`.
82
- It gets its tools as if the caller had spawned it.
58
+ A Program also creates idle Agents with `agent()`, typed in the Agent API
59
+ (§6). Options work like `agent` `spawn`; an omitted name is generated,
60
+ unique within the Program. `send()` sends a message and returns its result.
61
+ `abort()` works like `agent` `abort`. The Agent gets its tools as if the caller had spawned it.
83
62
 
84
63
  A result is the Agent's answer. If the input fails or is aborted, the
85
64
  result throws an Error. A write returns `undefined` once accepted.
86
65
 
87
66
  With `schema`, the Agent must call `submit_result` with a value that
88
67
  matches the schema, and the result is that value. If it does not, the
89
- input fails. `send()` throws if `schema` is used without `followUp`.
68
+ input fails.
69
+
70
+ With `timeout`, an input that has not ended within that many seconds of
71
+ `send()`, including time queued for a slot, is aborted; the Agent's queued
72
+ inputs are unaffected. The result throws a timeout Error.
73
+
74
+ `send()` throws if `schema` or `timeout` is used without `followUp`.
90
75
 
91
76
  ## 4. Lifecycle
92
77
 
@@ -94,8 +79,7 @@ When a Program ends, it cancels its unfinished tool calls and aborts its
94
79
  Agents' unfinished inputs, and waits for its Agents to go offline. It
95
80
  returns its result after cleanup. Work not stopped within the cleanup
96
81
  timeout is abandoned and may keep running; the Program's result and `stop`
97
- report it as an error.
98
- Stopping a Program does not undo completed tool calls.
82
+ report it as an error, and the Program's state is unchanged.
99
83
 
100
84
  When the caller goes offline, its running Programs stop. Their results
101
85
  remain available when the caller is loaded again.
@@ -153,11 +137,13 @@ interface SendOptions {
153
137
  deliverAs?: "followUp" | "steer" | "write";
154
138
  /** `followUp` only. The Agent must submit a value matching it. */
155
139
  schema?: JsonSchema;
140
+ /** `followUp` only. Seconds before the input is aborted. Default: none. */
141
+ timeout?: number;
156
142
  }
157
143
  interface AgentHandle {
158
144
  readonly id: string;
159
145
  readonly name: string;
160
- /** Resolves with the answer, or with the submitted value when `schema` is set; a write resolves with undefined once accepted. Throws if the input fails or is aborted. */
146
+ /** Resolves with the answer, or with the submitted value when `schema` is set; a write resolves with undefined once accepted. Throws if the input fails, is aborted, or times out. */
161
147
  send(message: string, options?: SendOptions): Promise<unknown>;
162
148
  /** Aborts the Agent's current turn and queued inputs; the Agent stays usable. */
163
149
  abort(): Promise<void>;
@@ -202,6 +188,8 @@ In these formats, `<id>` is the Program's short ID.
202
188
 
203
189
  `run` in the foreground returns the Program result, as an error if the
204
190
  Program failed or was stopped.
191
+ A Program result lists its `send()` calls with its tool calls, and names the
192
+ calls still running when the script returned, which were cancelled.
205
193
 
206
194
  `run` in the background:
207
195
 
@@ -220,6 +208,8 @@ timeout:
220
208
  Still running: <id>, ...
221
209
  ```
222
210
 
211
+ When a steer input ended the wait, the line of SPEC §6 follows.
212
+
223
213
  A single completed result, with nothing running, is returned alone. When
224
214
  there are no results or running Programs:
225
215
 
@@ -253,7 +243,7 @@ Program <id> has already ended.
253
243
  ```
254
244
 
255
245
  When some of the Program's work does not stop within the timeout, `stop`
256
- fails with:
246
+ fails with the following; only restarting Pi stops it.
257
247
 
258
248
  ```text
259
249
  Program <id> did not stop within the timeout and may still be running.
@@ -300,10 +290,17 @@ Program <id> <outcome>.
300
290
  ## 7. Human interface
301
291
 
302
292
  The task panel (SPEC §7) lists running background Programs.
293
+ `/tasks` opens a background Program in the task viewer (SPEC §7).
294
+
295
+ `/program <path> [args]` runs the script in a file as a foreground Program of
296
+ the current Agent, with the text after the path as the global `args`. Esc
297
+ stops it. Like a `!` command, it and its result join the context without
298
+ starting a turn. With `pi -p`, the result is printed, and Pi exits with
299
+ status 1 if the run fails.
303
300
 
304
301
  ## 8. Configuration
305
302
 
306
- `program` is inactive by default, like `codemode`; enable it with `"defaultTools": ["+program"]` or `--tools`.
303
+ `program` is inactive by default, like `codemode`; enable it with `"defaultTools": ["+program"]` or `--tools`. `/program` does not need it.
307
304
  MCP treats `program` as `codemode`: it activates `program` when its servers need a script tool and neither is active, unless `autoEnableCodemode` is false, and a Program waits for the MCP servers its code names.
308
305
  When both `program` and `codemode` are active, Pi warns once per Session, since they do a similar job.
309
306
  `codemode.mode` decides how the tool list is presented, as for `codemode`.
package/docs/SPEC.md CHANGED
@@ -4,7 +4,7 @@ Pi Agents lets Agents in Pi discover, message, and wait for each other.
4
4
  Programs built on these operations are specified in PROGRAM.md.
5
5
 
6
6
  Terms follow Pi's
7
- [Pico5 specification](https://github.com/earendil-works/pi/blob/main/packages/durable/docs/pico-v5.md).
7
+ [Pi Durable specification](https://github.com/earendil-works/pi/blob/main/packages/durable/docs/spec.md).
8
8
 
9
9
  ## 1. Semantic model
10
10
 
@@ -45,6 +45,11 @@ An input to an Agent with a turn in progress is either answered by the current t
45
45
  A write is added at the recipient's next tool boundary, or at once if it is
46
46
  idle. A write can go to several Agents at once.
47
47
 
48
+ When the user of a root Agent clears its queued messages, messages from other
49
+ Agents stay queued. After an Agent's turn is stopped, messages from other
50
+ Agents, notifications included, become writes until its next input from its
51
+ user or owner.
52
+
48
53
  ### 2.3 wait
49
54
 
50
55
  `wait` returns results of inputs sent to owned Agents. It can target
@@ -52,8 +57,8 @@ specific Agents and take a timeout.
52
57
 
53
58
  ### 2.4 list
54
59
 
55
- `list` returns visible Agents with their ID, name, working directory, and
56
- state.
60
+ `list` returns visible Agents with their ID, name, working directory,
61
+ state, and latest request: the last user message or input it received.
57
62
 
58
63
  ### 2.5 abort
59
64
 
@@ -71,7 +76,7 @@ stays `idle`.
71
76
  if needed. Acceptance does not mean the recipient has handled it; a message
72
77
  not yet handled is lost if the recipient exits.
73
78
 
74
- An input to an offline Agent wakes it. A write never wakes an Agent.
79
+ An input to an offline Agent wakes it; a write to one is rejected.
75
80
 
76
81
  Owned Agents run independently of their owner's turns. When their owner
77
82
  goes offline, they go offline too, from the bottom of the tree up, and their
@@ -101,7 +106,7 @@ continuing, ahead of inputs that have not started.
101
106
 
102
107
  An input is outstanding from acceptance until its turn ends or it is
103
108
  withdrawn. `maxOutstanding` (default 8) limits outstanding inputs; an input
104
- beyond it is rejected. `maxConcurrent` cannot exceed `maxOutstanding`.
109
+ beyond it is rejected. Notifications (§5) do not count.
105
110
 
106
111
  ## 5. Results and notifications
107
112
 
@@ -111,11 +116,12 @@ withdrawn. Its result is the answer or the error. Only inputs from the
111
116
  owner have results; the recipient replies to other senders with `send`.
112
117
 
113
118
  `wait` returns unread results in order and marks them read. On timeout, it
114
- returns the results so far and names the Agents still pending. It can also
115
- return read results again.
119
+ returns the results so far and names the Agents still pending. A steer input
120
+ to the waiting Agent, from any sender, ends `wait` the same way; a write does
121
+ not. It can also return read results again.
116
122
 
117
123
  When an input ends and its owner is not waiting for that Agent, the owner
118
- receives a notification, unless the owner aborted it. The notification is an input delivered as `steer`,
124
+ receives a notification, unless the owner aborted it. The notification is an input delivered as `steer` (a write after the owner's turn is stopped, §2.2),
119
125
  and it names the Agent and how the input ended. It does not include the
120
126
  result; the owner reads the result with `wait`.
121
127
 
@@ -214,6 +220,13 @@ write: { ids }
214
220
  Still pending: <name> (<id>), ...
215
221
  ```
216
222
 
223
+ When a steer input ended the wait, a line follows:
224
+
225
+ ```text
226
+ Still pending: <name> (<id>), ...
227
+ Stopped waiting for a new message. Wait again to keep waiting.
228
+ ```
229
+
217
230
  A result returned again through `history` adds `history="true"`. A single
218
231
  unread completed result, with nothing pending, is returned as the answer
219
232
  alone. When there are no results or pending inputs:
@@ -225,18 +238,19 @@ No results.
225
238
  Details:
226
239
 
227
240
  ```text
228
- { results: [{ id, name, state, history, result? }], pending: [id] }
241
+ { results: [{ id, name, state, history, result? }], pending: [id], interrupted? }
229
242
  ```
230
243
 
231
- `state` is the input outcome (§5); `history` marks a previously read result.
244
+ `state` is the input outcome (§5); `history` marks a previously read result;
245
+ `interrupted` is `true` when a steer input ended the wait.
232
246
 
233
- `list` returns one entry per Agent. With a query, entries are ordered by
234
- their most recent match. When a summary or user message matches, the entry
235
- shows the latest matching excerpt and its time:
247
+ `list` returns one entry per Agent, followed by its latest request, if any,
248
+ and its time. With a query, entries are ordered by their most recent match,
249
+ and a matching summary or user message shows its latest excerpt instead:
236
250
 
237
251
  ```text
238
252
  <name> (<id>) <state> <cwd>
239
- YYYY-MM-DD HH:MM …<matching excerpt>…
253
+ YYYY-MM-DD HH:MM <latest request or matching excerpt>
240
254
  ```
241
255
 
242
256
  When more entries remain:
@@ -304,6 +318,13 @@ Input rejected: <N> inputs have not ended. Wait for results or abort an Agent.
304
318
  <name> (<id>) is not owned by the caller.
305
319
  ```
306
320
 
321
+ When an aborted turn does not stop within the timeout (§3), `abort` fails
322
+ with the following; only restarting Pi stops it.
323
+
324
+ ```text
325
+ Agent <name> (<id>) did not stop within the timeout and may still be running.
326
+ ```
327
+
307
328
  truncation:
308
329
 
309
330
  Text beyond Pi's output limits is truncated with Pi's native notice, and
@@ -350,43 +371,59 @@ asking for a reply:
350
371
 
351
372
  A panel above the editor shows the current Agent's live work:
352
373
 
353
- Tasks (<N> live, /tasks to hide)
354
- Agent <name> (<id>) <state> <N> pending
355
- Agent <name> (<id>) <state>
356
- Program <id> running
357
- Agent <name> (<id>) <state>
374
+ Tasks (<N> live, /tasks to view)
375
+ Agent <name> (<id>) <state> <time> <cost> <N> pending
376
+ Agent <name> (<id>) <state> <time> <cost>
377
+ Program <id> running <time> <cost>
378
+ Agent <name> (<id>) <state> <time> <cost>
358
379
  +<N> more
359
380
 
360
381
  The live work is the owned Agents that are not `idle`, and the running
361
382
  background Programs with their Agents that are not `idle`. The rows list
362
383
  them as a tree in the order they started, each
363
384
  under its owner and each Program under its caller, with the owners needed to
364
- keep the tree. Rows that do not fit are counted in the last row.
385
+ keep the tree. Rows that do not fit are counted in the last row. The time is
386
+ how long the Agent's current turn or the Program has run, and the cost what the
387
+ replies in that turn have cost, or for a Program, those of all the Agents under
388
+ it. When a reply has no price, the cost is the input and output tokens instead,
389
+ like `7.3M tok`.
390
+
391
+ The panel updates as states change, and its times each second. It is shown
392
+ while it has a row.
393
+ `/tasks panel` hides or shows it.
394
+
395
+ ### Queued messages
396
+
397
+ Messages from other Agents that wait in the Session's queue, as during a turn,
398
+ are listed above the editor, below the user's queued messages, until the
399
+ Session takes them in:
365
400
 
366
- The panel updates as states change and is shown while it has a row.
367
- `/tasks` hides or shows it.
401
+ Steering: [<sender>] <body>
402
+ Write: [<sender>] <body>
403
+ Follow-up: [<sender>] <body>
368
404
 
369
- ### Agent viewer
405
+ ### Task viewer
370
406
 
371
- `/agents` lists the Agents in the current Agent's tree, including Programs'
372
- Agents, those not `idle` first and newest first within each group, with their state
373
- and latest activity, and opens the selected one in a viewer. An Agent not owned
374
- by the current Agent is named with its owners up to it, as
375
- `<name> ‹ <owner> ‹ …`, where a Program is `Program <id>`. The viewer shows the
376
- Agent's conversation and updates while it works; the current Session keeps
377
- running behind it.
407
+ `/tasks` lists the Agents in the current Agent's tree, including its Programs'
408
+ Agents, and its background Programs, live ones first and newest first, with
409
+ their state and latest activity, and opens the selected one in a viewer. An
410
+ Agent not owned by the current Agent is named with its owners, as
411
+ `<name> ‹ <owner> ‹ …`, where a Program is `Program <id>`. The viewer follows
412
+ an Agent's conversation, or a Program as it shows in the foreground, while the
413
+ current Session keeps running.
378
414
 
379
- Text entered in the viewer is the user's input to that Agent and is sent as
380
- in Pi's editor.
415
+ Text entered in an Agent's viewer is the user's input to it, sent as in Pi's
416
+ editor; a Program takes no input.
381
417
 
382
- `app.interrupt` stops the Agent's current turn, as `abort` does.
383
- `app.clear` clears the input, and closes the viewer when the input is empty.
418
+ `app.interrupt` stops an Agent's current turn, as `abort` does, or a Program,
419
+ as `stop` does. `app.clear` clears the input, and closes the viewer when the
420
+ input is empty.
384
421
 
385
422
  ### Workbench
386
423
 
387
424
  `pd agents` opens the workbench, which lists every root Session on this
388
- machine and lets the user open and message them. Interactive Sessions keep
389
- running after their terminal closes. See [WORKBENCH.md](WORKBENCH.md).
425
+ machine and lets the user open and message them. Sessions started with `pd`
426
+ keep running after their terminal closes. See [HOSTS.md](HOSTS.md).
390
427
 
391
428
  ## 8. Configuration
392
429
 
@@ -417,11 +454,11 @@ in its owner's process.
417
454
 
418
455
  A delivered message enters the recipient's current branch. An offline root
419
456
  Agent is started in a host, detached, with its saved working directory and
420
- model and the current configuration (WORKBENCH §4).
457
+ model and the current configuration (HOSTS §4).
421
458
  If a root process cannot finish cleanup within a grace period on exit, it is
422
459
  forcibly terminated.
423
460
  Before shutting down an idle Session, another extension can emit
424
461
  `busy:query` on `pi.events` with `{ busy: false }`; it is set to `true`
425
462
  while the tree has live work.
426
- One runtime uses a Session at a time. If a Session is occupied at interactive
427
- startup, its lock error stays visible until the user dismisses it, then Pi exits.
463
+ One runtime uses a Session at a time. If a Session is occupied at startup, Pi
464
+ reports it and exits.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tshu-w/pi-agents",
3
- "version": "0.1.1",
4
- "description": "Multi-agent runtime for Pi: async agents, programmatic delegation, inter-agent messaging, and a workbench for your Sessions",
3
+ "version": "0.2.0",
4
+ "description": "Multi-agent runtime for Pi: persistent async agents, dynamic workflows, and inter-agent messaging",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
7
7
  "type": "git",
@@ -19,11 +19,13 @@
19
19
  "docs"
20
20
  ],
21
21
  "keywords": [
22
- "pi-package",
23
- "pi-extension",
24
- "pi-coding-agent",
25
22
  "pi",
23
+ "pi-coding-agent",
24
+ "pi-extension",
25
+ "pi-package",
26
26
  "multi-agent",
27
+ "async-agents",
28
+ "dynamic-workflows",
27
29
  "rlm"
28
30
  ],
29
31
  "pi": {
@@ -18,6 +18,8 @@ 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,
@@ -32,6 +34,7 @@ import {
32
34
  type AgentState,
33
35
  type Delivery,
34
36
  type Entry,
37
+ type Spend,
35
38
  type TreeMetadata,
36
39
  } from "./registry.ts";
37
40
 
@@ -121,8 +124,8 @@ function lastAnswer(session: AgentSession, before: number): { outcome: Outcome;
121
124
  return { outcome: "completed", result: text };
122
125
  }
123
126
 
124
- /** Takes this extension's messages left in the agent queues after a run settles. */
125
- function takeQueued(agent: AgentSession["agent"]) {
127
+ /** Takes this extension's messages out of the agent queues. */
128
+ export function takeQueued(agent: AgentSession["agent"]) {
126
129
  // peekQueuedMessages returns one message in one-at-a-time mode; read the queues whole.
127
130
  const queues = agent as unknown as Record<"steeringQueue" | "followUpQueue", { messages: QueuedMessage[] }>;
128
131
  const steering = queues.steeringQueue.messages.slice();
@@ -135,6 +138,19 @@ function takeQueued(agent: AgentSession["agent"]) {
135
138
  return [...steering, ...followUps].filter(ours);
136
139
  }
137
140
 
141
+ /** 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. */
142
+ export function steerQueued(agent: AgentSession["agent"] | undefined): boolean {
143
+ if (!agent) return false;
144
+ const queue = (agent as unknown as { steeringQueue: { messages: QueuedMessage[] } }).steeringQueue.messages;
145
+ return queue.some((message) => !(message.role === "custom" && message.customType === MESSAGE_TYPE
146
+ && (message as { details?: { delivery?: string } }).details?.delivery === "write"));
147
+ }
148
+
149
+ // Pi does not report steers queued by extensions, so a wait checks the queue this often.
150
+ export const STEER_POLL_MS = 250;
151
+
152
+ export const WAIT_INTERRUPTED = "Stopped waiting for a new message. Wait again to keep waiting.";
153
+
138
154
  export interface Deferred {
139
155
  promise: Promise<void>;
140
156
  resolve(): void;
@@ -187,7 +203,7 @@ function ended(input: InputRecord): boolean {
187
203
 
188
204
  interface Input {
189
205
  text: string;
190
- /** The user's input from the Agent viewer. */
206
+ /** The user's input from the task viewer. */
191
207
  user?: boolean;
192
208
  record?: InputRecord;
193
209
  permit?: symbol;
@@ -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, and its inputs pending behind its current or next turn. */
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 {
@@ -393,14 +417,25 @@ export class Agents {
393
417
  /**
394
418
  * Sends an input from the owner and resolves when it ends. With a schema, the Agent must call
395
419
  * `submit_result` with a matching value, which becomes the input's `value`; `text` tells it so.
420
+ * An input not ended within `timeoutMs` is aborted alone, and `timedOut` is set.
396
421
  */
397
- async request(id: string, text: string, delivery: "followUp" | "steer", schema?: unknown): Promise<InputRecord> {
422
+ async request(id: string, text: string, delivery: "followUp" | "steer", schema?: unknown, timeoutMs?: number): Promise<{ record: InputRecord; timedOut: boolean }> {
398
423
  const agent = this.agent(id);
399
424
  const input: Input = { text, permit: this.reserve(), record: this.newRecord(agent), done: deferred() };
400
425
  if (schema !== undefined) input.schema = schema;
401
426
  this.add(agent, input, delivery);
402
- await input.done!.promise;
403
- return input.record!;
427
+ let stopping: Promise<void> | undefined;
428
+ const timer = timeoutMs === undefined ? undefined : setTimeout(() => {
429
+ stopping = this.cancel(agent, input);
430
+ stopping.catch(() => {});
431
+ }, timeoutMs);
432
+ try {
433
+ await input.done!.promise;
434
+ } finally {
435
+ clearTimeout(timer);
436
+ }
437
+ await stopping;
438
+ return { record: input.record!, timedOut: stopping !== undefined && input.record!.state === "aborted" };
404
439
  }
405
440
 
406
441
  /** Records a value from `submit_result` for the Agent's current input with a schema. */
@@ -416,6 +451,7 @@ export class Agents {
416
451
 
417
452
  accept(id: string, text: string, delivery: Delivery, options: { fromOwner: boolean; notification: boolean }): { queued: boolean } {
418
453
  const agent = this.agent(id);
454
+ if (agent.stopped && !options.fromOwner) delivery = "write";
419
455
  if (delivery === "write") {
420
456
  this.write(agent, text);
421
457
  return { queued: false };
@@ -426,7 +462,7 @@ export class Agents {
426
462
  return { queued: this.add(agent, input, delivery) };
427
463
  }
428
464
 
429
- async wait(targets: Owned[] | undefined, history: number, timeoutSeconds: number, signal?: AbortSignal): Promise<WaitOutcome> {
465
+ async wait(targets: Owned[] | undefined, history: number, timeoutSeconds: number, signal?: AbortSignal, steered?: () => boolean): Promise<WaitOutcome> {
430
466
  const selected = targets ?? [...this.agents.values()].filter((agent) =>
431
467
  this.pending(agent) || agent.record.inputs.some((input) => ended(input) && !input.read));
432
468
  const unique = [...new Set(selected)];
@@ -438,13 +474,18 @@ export class Agents {
438
474
  const suspended = blocking && this.self.ownerId !== undefined && scheduler.suspend(this.self.rootId, this.self.id);
439
475
  if (blocking) this.markWaiting(1);
440
476
  let returned = false;
477
+ let interrupted = false;
441
478
  try {
442
479
  const deadline = Date.now() + timeoutSeconds * 1000;
443
480
  while (unique.some((agent) => this.pending(agent))) {
444
481
  const remaining = deadline - Date.now();
445
482
  if (remaining <= 0) break;
483
+ if (steered?.()) {
484
+ interrupted = true;
485
+ break;
486
+ }
446
487
  signal?.throwIfAborted();
447
- await waitForChange(this.change.promise, remaining, signal);
488
+ await waitForChange(this.change.promise, steered ? Math.min(remaining, STEER_POLL_MS) : remaining, signal);
448
489
  }
449
490
  signal?.throwIfAborted();
450
491
  const results: WaitResult[] = [];
@@ -455,7 +496,7 @@ export class Agents {
455
496
  }
456
497
  }
457
498
  returned = true;
458
- return { results, pending: unique.filter((agent) => this.pending(agent)).map((agent) => agent.record) };
499
+ return { results, pending: unique.filter((agent) => this.pending(agent)).map((agent) => agent.record), ...(interrupted && { interrupted }) };
459
500
  } finally {
460
501
  if (blocking) this.markWaiting(-1);
461
502
  if (suspended) await scheduler.resume(this.self.rootId, this.self.id, this.limits.maxConcurrent, signal);
@@ -494,22 +535,38 @@ export class Agents {
494
535
  if (silent) for (const input of [...agent.queue, ...(turn?.inputs ?? [])]) input.silent = true;
495
536
  for (const input of agent.queue.splice(0)) this.end(agent, input, "aborted", "");
496
537
  this.changed();
497
- if (turn) {
498
- turn.abort.abort();
499
- const stopping = (async () => {
500
- if (turn.started && agent.session) {
501
- agent.session.abortCompaction();
502
- agent.session.abortRetry();
503
- await agent.session.abort();
504
- }
505
- await turn.done.promise;
506
- })();
507
- if (!await settlesBy(stopping, deadline)) {
508
- this.finish(agent, turn, { outcome: "aborted", result: "" });
509
- throw new Error(`Agent ${label(agent.record, this.ids())} did not stop within the timeout and may still be running.`);
538
+ if (turn) await this.stop(agent, turn, deadline);
539
+ return true;
540
+ }
541
+
542
+ /** Ends one input as aborted: withdraws it if queued, or else stops its turn, as `abort` does. */
543
+ private async cancel(agent: Owned, input: Input): Promise<void> {
544
+ const index = agent.queue.indexOf(input);
545
+ if (index !== -1) {
546
+ agent.queue.splice(index, 1);
547
+ this.end(agent, input, "aborted", "");
548
+ this.changed();
549
+ } else if (agent.turn?.inputs.includes(input)) {
550
+ await this.stop(agent, agent.turn, Date.now() + SHUTDOWN_TIMEOUT_MS);
551
+ }
552
+ }
553
+
554
+ /** Stops a turn; one that has not stopped by `deadline` is abandoned: its inputs end as aborted, and this throws. */
555
+ private async stop(agent: Owned, turn: Turn, deadline: number): Promise<void> {
556
+ agent.stopped = true;
557
+ turn.abort.abort();
558
+ const stopping = (async () => {
559
+ if (turn.started && agent.session) {
560
+ agent.session.abortCompaction();
561
+ agent.session.abortRetry();
562
+ await agent.session.abort();
510
563
  }
564
+ await turn.done.promise;
565
+ })();
566
+ if (!await settlesBy(stopping, deadline)) {
567
+ this.finish(agent, turn, { outcome: "aborted", result: "" });
568
+ throw new Error(`Agent ${label(agent.record, this.ids())} did not stop within the timeout and may still be running.`);
511
569
  }
512
- return true;
513
570
  }
514
571
 
515
572
  restore(ctx: ExtensionContext): void {
@@ -539,9 +596,7 @@ export class Agents {
539
596
  const deadline = Date.now() + SHUTDOWN_TIMEOUT_MS;
540
597
  const agents = [...this.agents.values()];
541
598
  const aborted = await Promise.allSettled(agents.map((agent) => this.abort(agent, { deadline })));
542
- const closed = await Promise.allSettled(agents.map(async (agent) => {
543
- const session = agent.session ?? await agent.loading?.catch(() => undefined);
544
- if (!session) return;
599
+ const close = async (agent: Owned, session: AgentSession) => {
545
600
  try {
546
601
  if (!await settlesBy(session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" }), deadline)) {
547
602
  throw new Error(`Agent ${label(agent.record, this.ids())} did not shut down within the timeout and may still be running.`);
@@ -550,6 +605,16 @@ export class Agents {
550
605
  session.dispose();
551
606
  agent.session = undefined;
552
607
  }
608
+ };
609
+ const closed = await Promise.allSettled(agents.map(async (agent) => {
610
+ const loading = agent.loading;
611
+ if (!agent.session && loading && !await settlesBy(loading, deadline)) {
612
+ // A Session that loads after the timeout is closed once it does.
613
+ void loading.then((late) => close(agent, late).catch(() => {}), () => {});
614
+ throw new Error(`Agent ${label(agent.record, this.ids())} did not shut down within the timeout and may still be running.`);
615
+ }
616
+ const session = agent.session ?? await loading?.catch(() => undefined);
617
+ if (session) await close(agent, session);
553
618
  }));
554
619
  const errors = [...aborted, ...closed].flatMap((result) => result.status === "rejected" ? [result.reason] : []);
555
620
  if (errors.length > 0) throw new Error(errors.map((error) => error instanceof Error ? error.message : String(error)).join("\n"));
@@ -584,6 +649,8 @@ export class Agents {
584
649
 
585
650
  /** A steer joins the current turn; other inputs queue. Returns whether the input waits for a slot. */
586
651
  private add(agent: Owned, input: Input, delivery: "followUp" | "steer"): boolean {
652
+ // While stopped, other senders' messages become writes, so this is the owner's or user's next input.
653
+ agent.stopped = false;
587
654
  if (delivery !== "steer" || !agent.turn) return this.enqueue(agent, input);
588
655
  this.join(agent, agent.turn, input);
589
656
  return false;
@@ -606,7 +673,7 @@ export class Agents {
606
673
  if (agent.turn || this.closing) return;
607
674
  const head = agent.queue.shift();
608
675
  if (!head) return;
609
- const turn: Turn = { inputs: [head], pending: [], abort: new AbortController(), started: false, slot: false, finished: false, done: deferred() };
676
+ 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
677
  agent.turn = turn;
611
678
  void this.runTurn(agent, turn);
612
679
  }
@@ -616,6 +683,7 @@ export class Agents {
616
683
  try {
617
684
  await scheduler.acquireQueued(this.self.rootId, agent.record.id, this.limits.maxConcurrent, turn.abort.signal);
618
685
  turn.slot = true;
686
+ turn.since = Date.now();
619
687
  const session = await this.load(agent);
620
688
  turn.abort.signal.throwIfAborted();
621
689
  const before = session.getSessionStats().assistantMessages;
@@ -877,6 +945,11 @@ export class Agents {
877
945
  finally { session.dispose(); }
878
946
  throw error;
879
947
  }
948
+ session.subscribe((event) => {
949
+ if (event.type !== "message_end" || event.message.role !== "assistant") return;
950
+ if (agent.turn) addSpend(agent.turn.spent, event.message.usage);
951
+ addProgramSpend(this.self.id, event.message.usage);
952
+ });
880
953
  return session;
881
954
  }
882
955
  }