@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/README.md +27 -20
- package/bin/daemon.mjs +32 -14
- package/bin/host.mjs +9 -1
- package/bin/pd.mjs +80 -32
- package/docs/{WORKBENCH.md → HOSTS.md} +48 -36
- package/docs/PROGRAM.md +31 -34
- package/docs/SPEC.md +76 -39
- package/package.json +7 -5
- package/src/agents/agents.ts +103 -30
- package/src/agents/registry.ts +30 -3
- package/src/agents/search.ts +21 -4
- package/src/agents/tool.ts +10 -5
- package/src/daemon/client.mjs +4 -1
- package/src/host/client.mjs +24 -4
- package/src/host/extension.ts +38 -14
- package/src/host/rpc-client.mjs +360 -0
- package/src/index.ts +36 -14
- 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/programs.ts +78 -11
- package/src/programs/run-output.mjs +11 -0
- package/src/programs/sandbox.ts +17 -4
- package/src/programs/scope.ts +11 -4
- package/src/programs/tool.ts +172 -16
- package/src/roots/guard.mjs +8 -2
- 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/panel.ts +39 -7
- package/src/ui/queue.ts +46 -0
- package/src/ui/viewer.ts +119 -11
- package/src/workbench.mjs +48 -20
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
|
|
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
|
-
|
|
60
|
-
|
|
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.
|
|
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
|
|
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
|
-
[
|
|
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,
|
|
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
|
|
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.
|
|
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.
|
|
115
|
-
|
|
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
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
367
|
-
|
|
401
|
+
Steering: [<sender>] <body>
|
|
402
|
+
Write: [<sender>] <body>
|
|
403
|
+
Follow-up: [<sender>] <body>
|
|
368
404
|
|
|
369
|
-
###
|
|
405
|
+
### Task viewer
|
|
370
406
|
|
|
371
|
-
`/
|
|
372
|
-
Agents,
|
|
373
|
-
and latest activity, and opens the selected one in a viewer. An
|
|
374
|
-
by the current Agent is named with its owners
|
|
375
|
-
`<name> ‹ <owner> ‹ …`, where a Program is `Program <id>`. The viewer
|
|
376
|
-
Agent's conversation
|
|
377
|
-
|
|
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
|
|
380
|
-
|
|
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
|
|
383
|
-
`app.clear` clears the input, and closes the viewer when the
|
|
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.
|
|
389
|
-
running after their terminal closes. See [
|
|
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 (
|
|
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
|
|
427
|
-
|
|
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.
|
|
4
|
-
"description": "Multi-agent runtime for Pi: async agents,
|
|
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": {
|
package/src/agents/agents.ts
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
|
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
|
-
|
|
403
|
-
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
|
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
|
}
|