@evandu/super-pi 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/super-pi-task/core.ts +748 -485
- package/agents/super-pi-task/index.ts +198 -141
- package/dist/client/assets/index-CHw4ZoY4.js +43 -0
- package/dist/client/assets/{index-CsbZzuMy.css → index-WlIFA_Dt.css} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/application/run-service.d.ts +48 -1
- package/dist/server/application/run-service.js +78 -2
- package/dist/server/capability.d.ts +89 -0
- package/dist/server/capability.js +136 -0
- package/dist/server/command-handler.js +4 -0
- package/dist/server/http-server.js +20 -0
- package/dist/server/index.js +25 -2
- package/dist/server/runtime.d.ts +1 -0
- package/dist/server/runtime.js +13 -2
- package/dist/server/store.d.ts +42 -0
- package/dist/server/store.js +144 -1
- package/dist/server/task-tool.d.ts +21 -0
- package/dist/server/task-tool.js +21 -0
- package/dist/server/types.d.ts +34 -0
- package/dist/server/workflow-engine.d.ts +129 -0
- package/dist/server/workflow-engine.js +253 -0
- package/dist/server/workflow.d.ts +287 -0
- package/dist/server/workflow.js +387 -0
- package/package.json +5 -1
- package/dist/client/assets/index-BMQsEdio.js +0 -24
|
@@ -1,485 +1,748 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The `task` and `todowrite` tools' whole behaviour, with no Pi import anywhere in this file.
|
|
3
|
-
*
|
|
4
|
-
* WHY IT IS SPLIT OUT. `index.ts` is the Pi adapter: TypeBox schemas and `registerTool` calls.
|
|
5
|
-
* Everything with a decision in it lives here instead, because `index.ts` can only be loaded by Pi -
|
|
6
|
-
* Pi hands extensions `@sinclair/typebox` as a VIRTUAL MODULE (extensions/virtual-modules.ts), so the
|
|
7
|
-
* adapter is unimportable from an ordinary Node process and therefore untestable from one. A file that
|
|
8
|
-
* can only be exercised by starting a real Pi with a real model behind it does not get tested; this one
|
|
9
|
-
* is driven end to end against a real server from `scripts/task-tool.test.mjs` and
|
|
10
|
-
* `scripts/todo-tool.test.mjs`.
|
|
11
|
-
*
|
|
12
|
-
* WHAT IS BORROWED FROM OPENCODE, and where:
|
|
13
|
-
*
|
|
14
|
-
* - `tool/task.txt` is the basis of `TASK_DESCRIPTION`, INCLUDING its "When NOT to use" list. That list
|
|
15
|
-
* is the part that matters: a delegation tool with no stated cost model gets used for a one-line
|
|
16
|
-
* search, and each such use is a whole second Pi process. The guidance is kept nearly verbatim and in
|
|
17
|
-
* its original order because it was written by someone who had watched that happen.
|
|
18
|
-
* - `tool/task.ts:64-79` `renderOutput` is `renderTaskResult`, including the `task_result` /
|
|
19
|
-
* `task_error` tag switch, so the model sees the same envelope shape it would from OpenCode.
|
|
20
|
-
* - `tool/registry.ts:265-278` `describeTask()` is `describeTask()`: the tool's own description is where
|
|
21
|
-
* the roster goes, rather than the system prompt, so the list is refreshed with the tools and cannot
|
|
22
|
-
* outlive a roster that has changed.
|
|
23
|
-
* - `tool/todowrite.txt` is the basis of `TODO_DESCRIPTION`, INCLUDING its "Use proactively when" list -
|
|
24
|
-
* see the long note above `TODO_DESCRIPTION` for why that list is the mechanism and is not paraphrased.
|
|
25
|
-
* - `tool/todo.ts` is the shape of `executeTodo`: write the list, echo it back as JSON.
|
|
26
|
-
*
|
|
27
|
-
* THE ONE DELIBERATE DEVIATION on `task`: OpenCode puts `description` to work as a session title. Super
|
|
28
|
-
* Pi's sub-agent record has no title field, and adding one is a persisted-shape change this step does not
|
|
29
|
-
* need, so `description` is carried into the `<summary>` line of the envelope instead. It is still
|
|
30
|
-
* shown to the model, still 3-5 words, and the parameter still means what its schema says.
|
|
31
|
-
*/
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* The environment contract with the server. The SAME four names are declared in
|
|
35
|
-
* `src/server/task-tool.ts`, which is the other end; the two are written out rather than imported
|
|
36
|
-
* because this file is loaded by Pi's module loader and that one by tsc, and a relative import across
|
|
37
|
-
* the two would resolve differently under each. `scripts/task-tool.test.mjs` asserts they agree.
|
|
38
|
-
*/
|
|
39
|
-
export const TASK_TOOL_NAME = "task";
|
|
40
|
-
export const TASK_ENDPOINT_ENV = "SUPER_PI_TASK_ENDPOINT";
|
|
41
|
-
export const TASK_ROSTER_ENV = "SUPER_PI_TASK_ROSTER";
|
|
42
|
-
export const TASK_CONVERSATION_ENV = "SUPER_PI_TASK_CONVERSATION";
|
|
43
|
-
export const TASK_DEPTH_ENV = "SUPER_PI_TASK_DEPTH";
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* The `todowrite` extension's half of the same environment contract, declared here for the reason the
|
|
47
|
-
* four above are. `src/server/task-tool.ts` is the other end; `scripts/todo-tool.test.mjs` asserts they
|
|
48
|
-
* agree, because a rename on one side fails at call time with a message nothing reads.
|
|
49
|
-
*/
|
|
50
|
-
export const TODO_ENDPOINT_ENV = "SUPER_PI_TODO_ENDPOINT";
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
export
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
* `
|
|
153
|
-
* a
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
//
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
- The
|
|
370
|
-
-
|
|
371
|
-
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The `task` and `todowrite` tools' whole behaviour, with no Pi import anywhere in this file.
|
|
3
|
+
*
|
|
4
|
+
* WHY IT IS SPLIT OUT. `index.ts` is the Pi adapter: TypeBox schemas and `registerTool` calls.
|
|
5
|
+
* Everything with a decision in it lives here instead, because `index.ts` can only be loaded by Pi -
|
|
6
|
+
* Pi hands extensions `@sinclair/typebox` as a VIRTUAL MODULE (extensions/virtual-modules.ts), so the
|
|
7
|
+
* adapter is unimportable from an ordinary Node process and therefore untestable from one. A file that
|
|
8
|
+
* can only be exercised by starting a real Pi with a real model behind it does not get tested; this one
|
|
9
|
+
* is driven end to end against a real server from `scripts/task-tool.test.mjs` and
|
|
10
|
+
* `scripts/todo-tool.test.mjs`.
|
|
11
|
+
*
|
|
12
|
+
* WHAT IS BORROWED FROM OPENCODE, and where:
|
|
13
|
+
*
|
|
14
|
+
* - `tool/task.txt` is the basis of `TASK_DESCRIPTION`, INCLUDING its "When NOT to use" list. That list
|
|
15
|
+
* is the part that matters: a delegation tool with no stated cost model gets used for a one-line
|
|
16
|
+
* search, and each such use is a whole second Pi process. The guidance is kept nearly verbatim and in
|
|
17
|
+
* its original order because it was written by someone who had watched that happen.
|
|
18
|
+
* - `tool/task.ts:64-79` `renderOutput` is `renderTaskResult`, including the `task_result` /
|
|
19
|
+
* `task_error` tag switch, so the model sees the same envelope shape it would from OpenCode.
|
|
20
|
+
* - `tool/registry.ts:265-278` `describeTask()` is `describeTask()`: the tool's own description is where
|
|
21
|
+
* the roster goes, rather than the system prompt, so the list is refreshed with the tools and cannot
|
|
22
|
+
* outlive a roster that has changed.
|
|
23
|
+
* - `tool/todowrite.txt` is the basis of `TODO_DESCRIPTION`, INCLUDING its "Use proactively when" list -
|
|
24
|
+
* see the long note above `TODO_DESCRIPTION` for why that list is the mechanism and is not paraphrased.
|
|
25
|
+
* - `tool/todo.ts` is the shape of `executeTodo`: write the list, echo it back as JSON.
|
|
26
|
+
*
|
|
27
|
+
* THE ONE DELIBERATE DEVIATION on `task`: OpenCode puts `description` to work as a session title. Super
|
|
28
|
+
* Pi's sub-agent record has no title field, and adding one is a persisted-shape change this step does not
|
|
29
|
+
* need, so `description` is carried into the `<summary>` line of the envelope instead. It is still
|
|
30
|
+
* shown to the model, still 3-5 words, and the parameter still means what its schema says.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The environment contract with the server. The SAME four names are declared in
|
|
35
|
+
* `src/server/task-tool.ts`, which is the other end; the two are written out rather than imported
|
|
36
|
+
* because this file is loaded by Pi's module loader and that one by tsc, and a relative import across
|
|
37
|
+
* the two would resolve differently under each. `scripts/task-tool.test.mjs` asserts they agree.
|
|
38
|
+
*/
|
|
39
|
+
export const TASK_TOOL_NAME = "task";
|
|
40
|
+
export const TASK_ENDPOINT_ENV = "SUPER_PI_TASK_ENDPOINT";
|
|
41
|
+
export const TASK_ROSTER_ENV = "SUPER_PI_TASK_ROSTER";
|
|
42
|
+
export const TASK_CONVERSATION_ENV = "SUPER_PI_TASK_CONVERSATION";
|
|
43
|
+
export const TASK_DEPTH_ENV = "SUPER_PI_TASK_DEPTH";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The `todowrite` extension's half of the same environment contract, declared here for the reason the
|
|
47
|
+
* four above are. `src/server/task-tool.ts` is the other end; `scripts/todo-tool.test.mjs` asserts they
|
|
48
|
+
* agree, because a rename on one side fails at call time with a message nothing reads.
|
|
49
|
+
*/
|
|
50
|
+
export const TODO_ENDPOINT_ENV = "SUPER_PI_TODO_ENDPOINT";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The `workflow` tool's half of the same contract, declared here for the same reason. The name the model
|
|
54
|
+
* types, the endpoint it dials, and the capability that proves the call came from a primary agent's own
|
|
55
|
+
* environment - `src/server/task-tool.ts` is the other end and `scripts/task-tool.test.mjs` asserts the
|
|
56
|
+
* copies agree.
|
|
57
|
+
*/
|
|
58
|
+
export const WORKFLOW_TOOL_NAME = "workflow";
|
|
59
|
+
export const WORKFLOW_ENDPOINT_ENV = "SUPER_PI_WORKFLOW_ENDPOINT";
|
|
60
|
+
export const TASK_CAPABILITY_ENV = "SUPER_PI_TASK_CAPABILITY";
|
|
61
|
+
|
|
62
|
+
/** The name the model calls, and the key Pi's tool allowlist matches on. OpenCode's name, kept. */
|
|
63
|
+
export const TODO_TOOL_NAME = "todowrite";
|
|
64
|
+
|
|
65
|
+
/** The parameters, OpenCode's `tool/todo.ts:6-8` minus its schema wrapper. */
|
|
66
|
+
export type TodoParams = { todos: TodoEntry[] };
|
|
67
|
+
|
|
68
|
+
/** One entry as the model writes it. `id` is OURS (see `normalizeTodos`), so it is absent here. */
|
|
69
|
+
export type TodoEntry = { content: string; status: "pending" | "in_progress" | "completed"; priority?: "high" | "medium" | "low" };
|
|
70
|
+
|
|
71
|
+
/** The three statuses Super Pi renders, as the model writes them. */
|
|
72
|
+
const TODO_STATUSES = new Set(["pending", "in_progress", "completed"]);
|
|
73
|
+
|
|
74
|
+
/** The parameters, exactly OpenCode's (`tool/task.ts:43-52`) minus the ones this step does not do. */
|
|
75
|
+
export type TaskParams = {
|
|
76
|
+
description: string;
|
|
77
|
+
prompt: string;
|
|
78
|
+
subagent_type: string;
|
|
79
|
+
task_id?: string;
|
|
80
|
+
background?: boolean;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** One dispatchable sub-agent, as the server read it from `agents/*.md` at spawn time. */
|
|
84
|
+
export type RosterEntry = { id: string; name: string; description: string };
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Reads a roster back out of an environment value.
|
|
88
|
+
*
|
|
89
|
+
* Anything unreadable is an EMPTY roster rather than a throw. An extension that throws while loading
|
|
90
|
+
* takes Pi's whole extension host down with it, and a malformed environment variable is a far smaller
|
|
91
|
+
* fault than "the primary agent cannot delegate at all".
|
|
92
|
+
*/
|
|
93
|
+
export function parseRoster(raw: string | undefined): RosterEntry[] {
|
|
94
|
+
if (!raw) return [];
|
|
95
|
+
let parsed: unknown;
|
|
96
|
+
try { parsed = JSON.parse(raw); } catch { return []; }
|
|
97
|
+
if (!Array.isArray(parsed)) return [];
|
|
98
|
+
return parsed.filter((entry): entry is RosterEntry =>
|
|
99
|
+
typeof entry === "object" && entry !== null &&
|
|
100
|
+
typeof (entry as RosterEntry).id === "string" && (entry as RosterEntry).id !== "" &&
|
|
101
|
+
typeof (entry as RosterEntry).description === "string");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The model's half of the contract, and the part that decides whether the tool is used well.
|
|
106
|
+
*
|
|
107
|
+
* The first paragraph says what the tool IS in one sentence, because a tool description is read once
|
|
108
|
+
* and acted on. The "When NOT to use" list is OpenCode's, kept in its order and for its stated reason.
|
|
109
|
+
* The usage notes are OpenCode's, with three additions and one correction, and each of those is stated
|
|
110
|
+
* below where it is made rather than left as a surprise.
|
|
111
|
+
*
|
|
112
|
+
* THE CORRECTION. OpenCode's note 1 says a dispatch "runs until it finishes", and this file used to say
|
|
113
|
+
* the same - while the schema carried a `background` parameter and the code implemented it. So a mode
|
|
114
|
+
* that existed was described as though it did not, and the model would never have used it. Note 2 below
|
|
115
|
+
* now states that the default is blocking AND that `background` exists, because "delegate several
|
|
116
|
+
* independent things at once" is exactly what this roster is shaped for and a model told it cannot.
|
|
117
|
+
*
|
|
118
|
+
* THE ADDITIONS, notes 7-9, are about COMPOSING dispatches. They were absent because there was
|
|
119
|
+
* previously nothing on the other end to compose into: the model delegated, read the result, and decided
|
|
120
|
+
* the next move itself, which it is perfectly capable of. What changed is that the server can now run a
|
|
121
|
+
* declared plan, and a model that has never been told a dispatch BLOCKS until the child finishes will
|
|
122
|
+
* fire three of them in parallel and find they all read the same pre-delegation state. Note 7 is the one
|
|
123
|
+
* that matters most: it is the difference between a chain that works and a chain where every step works
|
|
124
|
+
* from the same wrong assumptions.
|
|
125
|
+
*/
|
|
126
|
+
export const TASK_DESCRIPTION = `Launch a subagent to handle a complex, multistep task autonomously.
|
|
127
|
+
|
|
128
|
+
You must specify a subagent_type to select which subagent to use.
|
|
129
|
+
|
|
130
|
+
When NOT to use this tool:
|
|
131
|
+
- If you want to read a specific file path, use the Read or Glob tool instead, to find the match more quickly
|
|
132
|
+
- If you are searching for a specific class definition like "class Foo", use the Grep tool instead, to find the match more quickly
|
|
133
|
+
- If you are searching for code within a specific file or set of 2-3 files, use the Read tool instead, to find the match more quickly
|
|
134
|
+
- If no available subagent is a good fit for the task, use other tools directly
|
|
135
|
+
|
|
136
|
+
Usage notes:
|
|
137
|
+
1. Every dispatch starts a SEPARATE agent process that runs until it finishes, so the cost of a delegation is minutes rather than seconds. Delegate work that is genuinely substantial; do not delegate a lookup you could do in one tool call.
|
|
138
|
+
2. Once you have delegated work to a subagent, do not duplicate that work yourself. Continue with non-overlapping tasks, or wait for the result.
|
|
139
|
+
3. When the subagent is done it returns ONE message back to you, wrapped in a <task> element. That result is not visible to the user: if it matters to the user, say so in your own reply.
|
|
140
|
+
4. Each dispatch starts with a FRESH context. Your prompt is all the subagent will know, so it must contain the full task, and it must say exactly what you want back in its final message.
|
|
141
|
+
5. Tell the subagent whether you expect it to WRITE CODE or only to RESEARCH (search, file reads, web fetches), and how to verify its work if it writes. It is not aware of the user's intent.
|
|
142
|
+
6. If a subagent's description says it should be used proactively, use it without being asked.
|
|
143
|
+
7. BY DEFAULT A DISPATCH BLOCKS: the call does not return until that subagent has finished, and its answer comes back to you in this same turn. So you CAN dispatch several in one turn and use each answer before writing the next prompt - which is the normal way to chain specialists.
|
|
144
|
+
8. A LATER DISPATCH MUST CARRY THE EARLIER ONE'S ANSWER IN ITS PROMPT, because the next subagent starts with a fresh context and cannot see what the first one found. The tool does not do this for you. Write the findings you need into the next prompt yourself.
|
|
145
|
+
9. Pass background: true only for work that does not depend on anything and that you will not need before this turn ends. The result arrives later as a follow-up message; you are not told anything at call time. Do NOT sleep, poll, or ask for status.
|
|
146
|
+
10. For three or more steps where each depends on the previous one, prefer the workflow tool: it runs the chain server-side and hands each step the previous step's output without you copying it through your context.`;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The roster block, OpenCode's `describeTask()` reduced to its two jobs: the ids that are legal, and
|
|
150
|
+
* when to pick each one.
|
|
151
|
+
*
|
|
152
|
+
* `id` is printed, not `name`. The model has to type `subagent_type` exactly, and requiring it to
|
|
153
|
+
* reproduce a Chinese label as a machine token would be a transcription exercise in front of any actual
|
|
154
|
+
* work. The human-readable `name` is printed alongside because the ids in this repository are English
|
|
155
|
+
* file names while the roles are described in Chinese, and a model reading only `scout` learns less
|
|
156
|
+
* from it than from both.
|
|
157
|
+
*
|
|
158
|
+
* THE EMPTY CASE IS SPOKEN, NOT SILENT. With no sub-agents the tool still exists - Pi has already
|
|
159
|
+
* declared it - so a description that just ended after the guidance would leave the model to guess
|
|
160
|
+
* whether an empty roster is a bug or a fact about this install. It says so explicitly and tells the
|
|
161
|
+
* model to do the work itself, which is the only correct action in that state.
|
|
162
|
+
*/
|
|
163
|
+
export function rosterSection(roster: readonly RosterEntry[]): string {
|
|
164
|
+
if (!roster.length) {
|
|
165
|
+
return "\n\nAvailable subagent types: NONE. This install has no agents/*.md file with `mode: subagent`, so there is nobody to delegate to. Do not call this tool - do the work yourself with your other tools, and mention that no sub-agents are configured if the user asked for delegation.";
|
|
166
|
+
}
|
|
167
|
+
const lines = roster.map((entry) => `- ${entry.id} (${entry.name}): ${entry.description || "No description was given for this subagent."}`);
|
|
168
|
+
return `\n\nAvailable subagent types and when to use each:\n${lines.join("\n")}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The full model-facing description: the guidance, then the live roster. OpenCode's `describeTask()`. */
|
|
172
|
+
export function describeTask(roster: readonly RosterEntry[]): string {
|
|
173
|
+
return `${TASK_DESCRIPTION}${rosterSection(roster)}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The envelope the model reads, OpenCode's `renderOutput` (`tool/task.ts:64-79`).
|
|
178
|
+
*
|
|
179
|
+
* `state` is one of three words and the tag follows from it, so a model can tell a finished answer from
|
|
180
|
+
* a failure by the tag alone without parsing prose. Super Pi's own run statuses (`stopped`, `failed`)
|
|
181
|
+
* are folded into `error` here rather than passed through: `stopped` and `failed` are distinctions
|
|
182
|
+
* Super Pi's UI needs and the model has no use for, and adding a fourth `state` value would break any
|
|
183
|
+
* consumer expecting OpenCode's three.
|
|
184
|
+
*
|
|
185
|
+
* The child's text goes in verbatim. Trimming or wrapping it would mean the model receives something the
|
|
186
|
+
* sub-agent did not say, which is the one transformation that cannot be undone by reading the record.
|
|
187
|
+
*/
|
|
188
|
+
export function renderTaskResult(input: { id: string; state: "completed" | "error"; summary?: string; text: string }): string {
|
|
189
|
+
const tag = input.state === "error" ? "task_error" : "task_result";
|
|
190
|
+
return [
|
|
191
|
+
`<task id="${input.id}" state="${input.state}">`,
|
|
192
|
+
...(input.summary ? [`<summary>${input.summary}</summary>`] : []),
|
|
193
|
+
`<${tag}>`,
|
|
194
|
+
input.text,
|
|
195
|
+
`</${tag}>`,
|
|
196
|
+
"</task>",
|
|
197
|
+
].join("\n");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** What the extension learns from its own environment before it can dispatch anything. */
|
|
201
|
+
export type TaskToolConfig = {
|
|
202
|
+
endpoint: string;
|
|
203
|
+
conversationId: string;
|
|
204
|
+
depth: number;
|
|
205
|
+
roster: RosterEntry[];
|
|
206
|
+
/** The server's bearer token, when one is configured. Absent means the server runs open on loopback. */
|
|
207
|
+
token?: string;
|
|
208
|
+
/**
|
|
209
|
+
* This turn's dispatch capability, minted by the server at spawn.
|
|
210
|
+
*
|
|
211
|
+
* SENT IN THE BODY rather than a header, which is a deliberate departure from every other header here.
|
|
212
|
+
* The bearer token is a header because it authenticates the SERVER, and headers are what authentication
|
|
213
|
+
* is. This authenticates the CALLER, and the server reads it alongside `conversationId` - the pair is
|
|
214
|
+
* what identifies a turn, because a capability is only ever valid for one conversation. Presenting them
|
|
215
|
+
* apart would be a second, looser thing to get wrong.
|
|
216
|
+
*/
|
|
217
|
+
capability?: string;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
export function readTaskConfig(env: Record<string, string | undefined>): TaskToolConfig {
|
|
221
|
+
const depth = Number.parseInt(env[TASK_DEPTH_ENV] ?? "0", 10);
|
|
222
|
+
const token = env.SUPER_PI_ACCESS_TOKEN;
|
|
223
|
+
const capability = env[TASK_CAPABILITY_ENV];
|
|
224
|
+
return {
|
|
225
|
+
endpoint: env[TASK_ENDPOINT_ENV] ?? "",
|
|
226
|
+
conversationId: env[TASK_CONVERSATION_ENV] ?? "",
|
|
227
|
+
// An unreadable depth is 0, the primary agent's own value, rather than a number that would claim to
|
|
228
|
+
// be nested and trip the server's refusal for a reason that has nothing to do with this call.
|
|
229
|
+
depth: Number.isFinite(depth) ? depth : 0,
|
|
230
|
+
roster: parseRoster(env[TASK_ROSTER_ENV]),
|
|
231
|
+
...(token ? { token } : {}),
|
|
232
|
+
...(capability ? { capability } : {}),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The context slice the tool needs, so this file does not depend on Pi's `ExtensionToolContext`. */
|
|
237
|
+
export type TaskToolContext = { cwd: string };
|
|
238
|
+
|
|
239
|
+
export type TaskToolResult = { content: { type: "text"; text: string }[]; details: unknown; isError?: boolean };
|
|
240
|
+
|
|
241
|
+
/** One HTTP seam, injected so a test can drive the tool without a socket and production uses global fetch. */
|
|
242
|
+
export type TaskFetch = (url: string, init: { method: string; headers: Record<string, string>; body: string; signal?: AbortSignal }) => Promise<{ ok: boolean; status: number; text(): Promise<string> }>;
|
|
243
|
+
|
|
244
|
+
const globalFetch: TaskFetch = (url, init) => fetch(url, init as RequestInit);
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Refuses a call that cannot possibly work, in words the model can act on.
|
|
248
|
+
*
|
|
249
|
+
* The three cases are separated because the MODEL's next move differs in each: an empty roster means use
|
|
250
|
+
* your own tools, an unknown id means pick one of the listed ones, and a missing endpoint means this
|
|
251
|
+
* session was not started by Super Pi at all and no retry will change that. A single generic "dispatch
|
|
252
|
+
* failed" would leave a model that retries the same call three times.
|
|
253
|
+
*/
|
|
254
|
+
export function refuseTask(config: TaskToolConfig, params: TaskParams): string | undefined {
|
|
255
|
+
if (!config.endpoint) return `The task tool is not available in this session: ${TASK_ENDPOINT_ENV} is not set, so there is no Super Pi server to dispatch to. Do the work yourself with your other tools.`;
|
|
256
|
+
if (!config.conversationId) return `The task tool is not available in this session: ${TASK_CONVERSATION_ENV} is not set, so this run cannot be attributed to a conversation. Do the work yourself with your other tools.`;
|
|
257
|
+
if (!config.roster.length) return "This install has no dispatchable sub-agents (no agents/*.md with `mode: subagent`), so there is nobody to delegate to. Do the work yourself with your other tools.";
|
|
258
|
+
if (!params.subagent_type.trim()) return `subagent_type is required. Available: ${config.roster.map((entry) => entry.id).join(", ")}.`;
|
|
259
|
+
if (!config.roster.some((entry) => entry.id === params.subagent_type)) return `${JSON.stringify(params.subagent_type)} is not a dispatchable subagent_type. Available: ${config.roster.map((entry) => entry.id).join(", ")}. Pick one of those, or do the work yourself.`;
|
|
260
|
+
if (!params.prompt.trim()) return "prompt is required and must not be empty: the sub-agent starts with a fresh context and your prompt is everything it will know.";
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Runs one dispatch and returns the envelope the model reads.
|
|
266
|
+
*
|
|
267
|
+
* Returns rather than throws for EVERY outcome, including a malformed request. Pi treats a thrown tool
|
|
268
|
+
* error and an `isError` result almost identically, so this is not about Pi - it is that the model
|
|
269
|
+
* should see ONE shape for "the delegation did not work", with the reason inside it, instead of a stack
|
|
270
|
+
* trace for some failures and prose for others.
|
|
271
|
+
*
|
|
272
|
+
* The request carries a CONVERSATION, never a directory. The server resolves the workspace from its own
|
|
273
|
+
* stored state; a model that could name its own `cwd` would name a directory it can write, which is the
|
|
274
|
+
* entire boundary this endpoint exists to keep. That is why `context.cwd` is read here at all: only to
|
|
275
|
+
* be left out of the request.
|
|
276
|
+
*/
|
|
277
|
+
export async function executeTask(
|
|
278
|
+
params: TaskParams,
|
|
279
|
+
context: TaskToolContext,
|
|
280
|
+
config: TaskToolConfig,
|
|
281
|
+
deps: { signal?: AbortSignal; fetch?: TaskFetch } = {},
|
|
282
|
+
): Promise<TaskToolResult> {
|
|
283
|
+
const text = (body: string, isError?: boolean): TaskToolResult => ({ content: [{ type: "text", text: body }], details: undefined, ...(isError ? { isError: true } : {}) });
|
|
284
|
+
const refusal = refuseTask(config, params);
|
|
285
|
+
if (refusal) return text(renderTaskResult({ id: "refused", state: "error", text: refusal }), true);
|
|
286
|
+
|
|
287
|
+
const send = deps.fetch ?? globalFetch;
|
|
288
|
+
let response: Awaited<ReturnType<TaskFetch>>;
|
|
289
|
+
try {
|
|
290
|
+
response = await send(config.endpoint, {
|
|
291
|
+
method: "POST",
|
|
292
|
+
// The bearer token is the SAME check every other endpoint on this server applies, and the server
|
|
293
|
+
// enforces it BEFORE the loopback test, so an extension that forgot it would get a bare 401.
|
|
294
|
+
headers: { "content-type": "application/json", ...(config.token ? { authorization: `Bearer ${config.token}` } : {}) },
|
|
295
|
+
body: JSON.stringify({
|
|
296
|
+
conversationId: config.conversationId,
|
|
297
|
+
subagent_type: params.subagent_type,
|
|
298
|
+
prompt: params.prompt,
|
|
299
|
+
description: params.description,
|
|
300
|
+
depth: config.depth,
|
|
301
|
+
background: params.background === true,
|
|
302
|
+
// Always sent, even when undefined, so a body that omits the field is unambiguously a caller that
|
|
303
|
+
// did not have one - which the server treats as a refusal, not as a legacy request to let through.
|
|
304
|
+
capability: config.capability,
|
|
305
|
+
}),
|
|
306
|
+
...(deps.signal ? { signal: deps.signal } : {}),
|
|
307
|
+
});
|
|
308
|
+
} catch (error) {
|
|
309
|
+
// A failed CONNECTION is worth distinguishing from a refused dispatch: nothing ran, and no run
|
|
310
|
+
// record exists, so telling the model to look at the run would send it looking for something that
|
|
311
|
+
// was never created.
|
|
312
|
+
return text(renderTaskResult({ id: "unreachable", state: "error", text: `Could not reach the Super Pi server at ${config.endpoint}: ${error instanceof Error ? error.message : "unknown error"}` }), true);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** The server answers JSON; a body that is not JSON means a proxy or an error page, not a verdict. */
|
|
316
|
+
let payload: { run?: { id?: string; status?: string; agentId?: string }; output?: string; error?: string; outputTruncated?: boolean };
|
|
317
|
+
try { payload = JSON.parse(await response.text()) as typeof payload; }
|
|
318
|
+
catch { return text(renderTaskResult({ id: "unreadable", state: "error", text: `The dispatch endpoint answered ${response.status} with something that is not JSON, so its result cannot be read.` }), true); }
|
|
319
|
+
|
|
320
|
+
// No run id means nothing ran: either the server refused before spawning, or the body was unreadable.
|
|
321
|
+
// "refused" says so, where "unknown" would imply a run exists somewhere whose output is merely absent.
|
|
322
|
+
const id = payload.run?.id ?? "refused";
|
|
323
|
+
if (params.background === true && payload.run?.status === "running") {
|
|
324
|
+
return text(`<task id="${id}" state="running">\n<summary>${params.description.trim() || params.subagent_type}</summary>\n<task_result>The task is working in the background. You will be notified automatically when it finishes. Do not sleep, poll for progress, ask the task for status, or duplicate this task's work. Continue with non-overlapping work.</task_result>\n</task>`);
|
|
325
|
+
}
|
|
326
|
+
const completed = payload.run?.status === "completed";
|
|
327
|
+
const body = completed
|
|
328
|
+
? payload.output ?? ""
|
|
329
|
+
: payload.error ?? `The sub-agent run finished as ${JSON.stringify(payload.run?.status ?? "unknown")} without saying why.`;
|
|
330
|
+
return text(
|
|
331
|
+
renderTaskResult({
|
|
332
|
+
id,
|
|
333
|
+
state: completed ? "completed" : "error",
|
|
334
|
+
summary: params.description.trim() ? `${params.description.trim()} (${params.subagent_type})` : `${params.subagent_type}`,
|
|
335
|
+
text: payload.outputTruncated && completed ? `${body}\n\n[This answer was truncated at 50 KB by the server; the full run is recorded in Super Pi.]` : body,
|
|
336
|
+
}),
|
|
337
|
+
!completed,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---------------------------------------------------------------------------------------------
|
|
342
|
+
// The `todowrite` tool: a task list the model maintains itself, and a person watches from the sidebar.
|
|
343
|
+
//
|
|
344
|
+
// THIS IS A PORT OF OPENCODE'S `tool/todo.ts` + `tool/todowrite.txt`, and the two files are 46 lines and
|
|
345
|
+
// 44 lines respectively - almost all of the first one Effect plumbing, and the whole of the second one
|
|
346
|
+
// prose the model reads. Only the prose ports intact, and that is the point of this file.
|
|
347
|
+
//
|
|
348
|
+
// THE PROACTIVE TRIGGER IS THE DESCRIPTION, and nothing else. This is the part of OpenCode's design that
|
|
349
|
+
// is easy to get wrong by looking for a mechanism that isn't there: OpenCode does NOT inject the todo
|
|
350
|
+
// list into any system prompt, does NOT have a scheduler, and does NOT nudge. `session/todo.ts` is a
|
|
351
|
+
// per-session table that a tool call writes, and the ONLY thing that makes a model call it on its own is
|
|
352
|
+
// the "Use proactively when" list in `todowrite.txt` - specifically the two entries that fire on work
|
|
353
|
+
// happening rather than on a question being asked:
|
|
354
|
+
//
|
|
355
|
+
// - "You start a task - mark it `in_progress` (only one at a time) before working"
|
|
356
|
+
// - "You finish a task - mark it `completed` and add any follow-ups discovered during the work"
|
|
357
|
+
//
|
|
358
|
+
// Those two lines ARE the design. Everything else here - the endpoint, the store, the sidebar - is
|
|
359
|
+
// plumbing that makes those two lines possible. So they are carried over in OpenCode's own order and
|
|
360
|
+
// wording, and the test asserts the sentences are PRESENT rather than paraphrased, because a reworded
|
|
361
|
+
// trigger is a trigger that fires less often and no test would notice except this one.
|
|
362
|
+
//
|
|
363
|
+
// WHAT CHANGES FROM OPENCODE, and why each change is forced rather than chosen:
|
|
364
|
+
//
|
|
365
|
+
// - `cancelled` is GONE from the States section. Super Pi's list is replaced whole on every call
|
|
366
|
+
// (`normalizeTodos` in src/server/task-tool.ts), so "no longer needed" is expressed by not sending the
|
|
367
|
+
// item. Keeping a fourth status would mean a state the model can set that nothing ever takes off the
|
|
368
|
+
// screen, which is a row that says it was cancelled forever.
|
|
369
|
+
// - The permission gate is gone. OpenCode asks `ctx.ask({ permission: "todowrite", patterns: ["*"] })`
|
|
370
|
+
// before every write (`tool/todo.ts:24-29`) and denies the tool to sub-agents that lack the permission
|
|
371
|
+
// (`agent/subagent-permissions.ts:19-24`). Super Pi's server has one permission story and it is
|
|
372
|
+
// bearer-token plus loopback, not a per-tool prompt, and a tool whose every call raises a modal is a
|
|
373
|
+
// tool a model stops calling - which would break the proactive trigger this file exists to preserve.
|
|
374
|
+
// The endpoint's loopback-and-token guards are what stands in that place; they are noted in
|
|
375
|
+
// `refuseTodo` so the model knows the rule rather than guessing at it.
|
|
376
|
+
// - The list is per-install, not per-session (`AppState.todos` is one array). Stated in the report rather
|
|
377
|
+
// than hidden: switching conversations shows the previous conversation's list until the model rewrites
|
|
378
|
+
// it. Making it per-conversation is a persisted-shape change and is out of this task's scope.
|
|
379
|
+
//
|
|
380
|
+
// THE OUTPUT IS THE SERVER'S LIST, NOT THE MODEL'S. OpenCode echoes its own input
|
|
381
|
+
// (`tool/todo.ts:38`). This tool echoes what the server actually STORED, which differs whenever the
|
|
382
|
+
// server dropped an empty item, truncated a long one or filled in a missing id - and echoing the stored
|
|
383
|
+
// copy is what stops the model from believing a row was saved when it was not.
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The model-facing description. OpenCode's `todowrite.txt`, ported.
|
|
387
|
+
*
|
|
388
|
+
* Kept in English for the same reason `TASK_DESCRIPTION` above is: it is read by a model, and the rest
|
|
389
|
+
* of this extension's model-facing text is English, so a Chinese paragraph here would be the only Chinese
|
|
390
|
+
* instruction in the tool set. The paragraphs a person reads (the sidebar) are the Chinese ones.
|
|
391
|
+
*
|
|
392
|
+
* The one ADDED paragraph is the third one. OpenCode never tells the model that anyone is watching - its
|
|
393
|
+
* description is written entirely in the imperative voice of a task list. Super Pi's list is on screen in
|
|
394
|
+
* a panel a user is looking at while the agent works, and that is the reason to maintain it in real time
|
|
395
|
+
* rather than at the end, so the description says so. This is the piece Super Pi has that OpenCode does
|
|
396
|
+
* not, and it is stated to the model as a fact about the world rather than as a request.
|
|
397
|
+
*/
|
|
398
|
+
export const TODO_DESCRIPTION = `Create and maintain a structured task list for the current coding session. Tracks progress, organizes multi-step work, and surfaces status to the user.
|
|
399
|
+
|
|
400
|
+
## When to use
|
|
401
|
+
Use proactively when:
|
|
402
|
+
- The task requires 3+ distinct steps or actions (not just 3 tool calls for a single conceptual step)
|
|
403
|
+
- The work is non-trivial and benefits from planning
|
|
404
|
+
- The user provides multiple tasks (numbered or comma-separated) or explicitly asks for a todo list
|
|
405
|
+
- New instructions arrive - capture them as todos
|
|
406
|
+
- You start a task - mark it \`in_progress\` (only one at a time) before working
|
|
407
|
+
- You finish a task - mark it \`completed\` and add any follow-ups discovered during the work
|
|
408
|
+
|
|
409
|
+
## When NOT to use
|
|
410
|
+
Skip when:
|
|
411
|
+
- The work is a single, straightforward task (or <3 trivial steps)
|
|
412
|
+
- The request is purely informational or conversational
|
|
413
|
+
- Tracking adds no organizational value
|
|
414
|
+
|
|
415
|
+
## States
|
|
416
|
+
- \`pending\` - not started
|
|
417
|
+
- \`in_progress\` - actively working (exactly ONE at a time)
|
|
418
|
+
- \`completed\` - finished successfully
|
|
419
|
+
|
|
420
|
+
To drop an item that is no longer needed, leave it out of the next call entirely: this tool replaces the whole list, so an item you do not send is an item that disappears.
|
|
421
|
+
|
|
422
|
+
## Rules
|
|
423
|
+
- Update status in real time; don't batch completions
|
|
424
|
+
- Mark \`completed\` only after the required work is actually done, including any required verification. Never based on intent.
|
|
425
|
+
- Keep exactly one \`in_progress\` while work remains
|
|
426
|
+
- If blocked or partial, keep it \`in_progress\` and add a follow-up todo describing the blocker
|
|
427
|
+
- Preserve user-provided commands verbatim (flags, args, order)
|
|
428
|
+
- Items should be specific and actionable; break large work into smaller steps
|
|
429
|
+
|
|
430
|
+
## The user is watching this list
|
|
431
|
+
This list is rendered live in a panel beside the conversation, so a stale row is visible to the person you are working for while you are still working. That is why the triggers above are about PROGRESS and not about questions: update the list when a task starts, when it finishes, and when you discover a new step - not when the user asks where you are.
|
|
432
|
+
|
|
433
|
+
## Examples
|
|
434
|
+
|
|
435
|
+
Use it:
|
|
436
|
+
- "Add a dark mode toggle and run the tests" -> multi-step feature + explicit verification
|
|
437
|
+
- "Rename getCwd -> getCurrentWorkingDirectory across the repo" -> grep reveals 15 occurrences in 8 files
|
|
438
|
+
- "Implement registration, catalog, cart, checkout" -> multiple complex features
|
|
439
|
+
|
|
440
|
+
Skip it:
|
|
441
|
+
- "How do I print Hello World in Python?" -> informational
|
|
442
|
+
- "Add a comment to calculateTotal" -> single edit
|
|
443
|
+
- "Run npm install and tell me what happened" -> one command
|
|
444
|
+
|
|
445
|
+
When in doubt, use it.`;
|
|
446
|
+
|
|
447
|
+
export type TodoToolConfig = { endpoint: string; conversationId: string; token?: string };
|
|
448
|
+
|
|
449
|
+
/** What the extension learns from its own environment before it can write anything. */
|
|
450
|
+
export function readTodoConfig(env: Record<string, string | undefined>): TodoToolConfig {
|
|
451
|
+
const token = env.SUPER_PI_ACCESS_TOKEN;
|
|
452
|
+
return { endpoint: env[TODO_ENDPOINT_ENV] ?? "", conversationId: env[TASK_CONVERSATION_ENV] ?? "", ...(token ? { token } : {}) };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Refuses a call that cannot work, in words the model can act on.
|
|
457
|
+
*
|
|
458
|
+
* The server is authoritative over what the list may contain (`normalizeTodos`), so this file does NOT
|
|
459
|
+
* re-check `content` or `status` and deliberately does not reject a list it dislikes: a list with one bad
|
|
460
|
+
* entry is better stored than refused, because a refusal loses the twenty good ones with it. What is
|
|
461
|
+
* checked here is only the things that make the call impossible - the same three-case split `refuseTask`
|
|
462
|
+
* uses, for the same reason.
|
|
463
|
+
*/
|
|
464
|
+
export function refuseTodo(config: TodoToolConfig, params: TodoParams): string | undefined {
|
|
465
|
+
if (!config.endpoint) return `The todowrite tool is not available in this session: ${TODO_ENDPOINT_ENV} is not set, so there is no Super Pi server to write the list to. Keep working and describe your plan in prose instead.`;
|
|
466
|
+
if (!config.conversationId) return `The todowrite tool is not available in this session: ${TASK_CONVERSATION_ENV} is not set. Keep working and describe your plan in prose instead.`;
|
|
467
|
+
if (!Array.isArray(params.todos)) return "todowrite requires a `todos` array. Send the WHOLE list every time - this tool replaces the list rather than adding to it.";
|
|
468
|
+
return undefined;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** One HTTP seam, injected so a test can drive the tool without a socket and production uses global fetch. */
|
|
472
|
+
export type TodoFetch = (url: string, init: { method: string; headers: Record<string, string>; body: string; signal?: AbortSignal }) => Promise<{ ok: boolean; status: number; text(): Promise<string> }>;
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Runs one write and returns what the model reads.
|
|
476
|
+
*
|
|
477
|
+
* Returns rather than throws for every outcome, for the reason `executeTask` does: the model should see
|
|
478
|
+
* ONE shape for "the list was not written", with the reason inside it.
|
|
479
|
+
*
|
|
480
|
+
* THE RETURNED LIST IS THE SERVER'S, and this is the one place the port departs from OpenCode's behaviour
|
|
481
|
+
* on purpose. The server is the only party that has validated, truncated and id-stamped what was stored,
|
|
482
|
+
* so echoing its copy is what tells the model the truth; echoing the input instead (OpenCode,
|
|
483
|
+
* `tool/todo.ts:38`) would let it believe a 40,000-character row survived when it was cut to 400. The
|
|
484
|
+
* dropped count rides along in the sentence for the same reason - a silent truncation reads as success.
|
|
485
|
+
*/
|
|
486
|
+
export async function executeTodo(
|
|
487
|
+
params: TodoParams,
|
|
488
|
+
config: TodoToolConfig,
|
|
489
|
+
deps: { signal?: AbortSignal; fetch?: TodoFetch } = {},
|
|
490
|
+
): Promise<TaskToolResult> {
|
|
491
|
+
const text = (body: string, isError?: boolean): TaskToolResult => ({ content: [{ type: "text", text: body }], details: undefined, ...(isError ? { isError: true } : {}) });
|
|
492
|
+
const refusal = refuseTodo(config, params);
|
|
493
|
+
if (refusal) return text(refusal, true);
|
|
494
|
+
|
|
495
|
+
const send = deps.fetch ?? globalFetch;
|
|
496
|
+
let response: Awaited<ReturnType<TodoFetch>>;
|
|
497
|
+
try {
|
|
498
|
+
response = await send(config.endpoint, {
|
|
499
|
+
method: "POST",
|
|
500
|
+
headers: { "content-type": "application/json", ...(config.token ? { authorization: `Bearer ${config.token}` } : {}) },
|
|
501
|
+
// The CONVERSATION is named so the server can attribute the write, never a directory: the same
|
|
502
|
+
// boundary `executeTask` keeps, and for the same reason - a model that could name its own cwd could
|
|
503
|
+
// write outside the workspace this app exists to keep it inside.
|
|
504
|
+
body: JSON.stringify({ conversationId: config.conversationId, todos: params.todos }),
|
|
505
|
+
...(deps.signal ? { signal: deps.signal } : {}),
|
|
506
|
+
});
|
|
507
|
+
} catch (error) {
|
|
508
|
+
return text(`Could not reach the Super Pi server at ${config.endpoint}: ${error instanceof Error ? error.message : "unknown error"}. The todo list was NOT saved - carry your plan in prose this turn and try again.`, true);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
let payload: { todos?: TodoEntry[]; dropped?: number; error?: string };
|
|
512
|
+
try { payload = JSON.parse(await response.text()) as typeof payload; }
|
|
513
|
+
catch { return text(`The todo endpoint answered ${response.status} with something that is not JSON, so the list cannot be confirmed as saved. Treat it as unsaved and retry.`, true); }
|
|
514
|
+
if (!response.ok || !Array.isArray(payload.todos)) {
|
|
515
|
+
return text(payload.error ?? `The todo endpoint answered ${response.status} without saving the list. Treat it as unsaved and retry.`, true);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Only statuses this file knows are echoed, and only because the server's validator already enforces
|
|
519
|
+
// them - this is a type narrowing for the renderer below, not a second validation pass.
|
|
520
|
+
const stored = payload.todos.filter((todo): todo is TodoEntry => Boolean(todo) && typeof todo.content === "string" && TODO_STATUSES.has(todo.status));
|
|
521
|
+
const open = stored.filter((todo) => todo.status !== "completed").length;
|
|
522
|
+
const summary = !stored.length ? "0 个待办任务" : !open ? `全部完成:${stored.length} 个待办任务` : `${open} 个待办未完成,共 ${stored.length} 个`;
|
|
523
|
+
const dropped = typeof payload.dropped === "number" && payload.dropped > 0
|
|
524
|
+
? `\n\n[${payload.dropped} of the entries you sent were dropped or truncated by the server - they are NOT in the saved list. The saved list is the JSON below.]`
|
|
525
|
+
: "";
|
|
526
|
+
return text(`${summary}${dropped}\n\n${JSON.stringify(stored, null, 2)}`);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ---------------------------------------------------------------------------------------------
|
|
530
|
+
// The `workflow` tool: hand an ordered plan of sub-agent steps to the server and let it walk them.
|
|
531
|
+
//
|
|
532
|
+
// WHY IT EXISTS BESIDE `task`. `task` delegates ONE piece of work and hands the answer back to the model,
|
|
533
|
+
// which then decides the next move. That works, and a model is entirely capable of chaining three `task`
|
|
534
|
+
// calls - but every link in that chain has to pass through the model's context as prose, and a model that
|
|
535
|
+
// drops one link produces a chain where every step ran from the same wrong assumptions, with nothing in
|
|
536
|
+
// the transcript to show where it broke. This tool moves the loop to the server: the plan is written down
|
|
537
|
+
// whole before the first step runs, each step is handed the previous step's output automatically, and the
|
|
538
|
+
// timeline is on screen the whole way. See `src/server/workflow.ts` for the rules and
|
|
539
|
+
// `docs/AGENT_LOOP_DESIGN.md` for what this deliberately does not do.
|
|
540
|
+
//
|
|
541
|
+
// WHAT IT IS NOT, stated in the description because a model will otherwise try: it is not a graph, it
|
|
542
|
+
// cannot branch or loop back, and it does not run steps in parallel. A plan it cannot express is a plan to
|
|
543
|
+
// dispatch with `task` directly - and that fallback is the reason this tool can be small. A richer
|
|
544
|
+
// orchestration language would not remove that fallback, it would only add a second way to be wrong.
|
|
545
|
+
|
|
546
|
+
/** What the model declares. The shape the server's validator accepts, minus everything it will refuse. */
|
|
547
|
+
export type WorkflowParams = {
|
|
548
|
+
summary?: string;
|
|
549
|
+
onFailure?: string;
|
|
550
|
+
steps?: { agentId?: string; label?: string; prompt?: string }[];
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* The description, written to push the decision BACK toward `task`.
|
|
555
|
+
*
|
|
556
|
+
* THE TONE IS THE DESIGN. A model that reads "orchestrate pipelines" will reach for this tool on a
|
|
557
|
+
* two-step task and then discover it cannot express what it wanted. Every note here exists to make the
|
|
558
|
+
* smaller tool the default and this one the deliberate choice: `task` is named as the answer for most
|
|
559
|
+
* work, and the conditions under which a plan is genuinely better are given concretely rather than
|
|
560
|
+
* described abstractly.
|
|
561
|
+
*
|
|
562
|
+
* The failure policy is named as a THREE-way choice with its cost, because the model is choosing a
|
|
563
|
+
* behaviour it will not see again until the end: `stop` is the default and is what a plan that failed has
|
|
564
|
+
* honestly earned, `skip` hands the next subagent a hole, and `retry` costs another whole subagent run.
|
|
565
|
+
*/
|
|
566
|
+
export const WORKFLOW_DESCRIPTION = `Run an ordered plan of sub-agents, where each step is automatically handed the previous step's output.
|
|
567
|
+
|
|
568
|
+
WHEN TO USE THIS, AND WHEN NOT:
|
|
569
|
+
- Two steps, or a chain you can hold in your head: use the task tool, twice. You see each answer before you write the next prompt, which is more control than this gives you.
|
|
570
|
+
- Three or more steps where each DEPENDS on the previous one: use this. The server runs the chain, hands each step the last step's output, and shows the user a timeline while it happens. Doing that by hand means copying every answer through your context, and a plan this shape is exactly where a dropped link stays invisible until the end.
|
|
571
|
+
- Steps that are INDEPENDENT of each other: do not use this. Use the task tool with background: true, several times - that is what parallel is for.
|
|
572
|
+
- Anything with branching, conditional steps, or a loop back to an earlier step: you cannot express that here. Use the task tool and decide the next step yourself.
|
|
573
|
+
|
|
574
|
+
Usage notes:
|
|
575
|
+
1. This call BLOCKS until the whole plan finishes, and every step is a separate sub-agent process that takes minutes. Three steps is often five to ten minutes.
|
|
576
|
+
2. Steps run IN ORDER, one at a time. Each is a fresh subagent with a fresh context.
|
|
577
|
+
3. You do NOT need to copy an earlier step's output into a later step's prompt. By default the server prepends every earlier step's agent, label, status and output to each step, so a later step can act on what came before. Write each prompt as the instruction for ITS OWN step.
|
|
578
|
+
4. To control that instead, put {{previous}} in a prompt for just the previous step's output, or {{history}} for all of them. Using either one REPLACES the automatic block rather than adding to it.
|
|
579
|
+
5. A step that COMPLETES has succeeded, whatever it says. If a verifier step comes back saying the work failed, the plan still ran to the end - read its output, then decide whether to run another round yourself. The server does not judge results for you.
|
|
580
|
+
6. onFailure controls what happens when a step FAILS TO RUN: stop (the default) ends the plan and reports which step failed; skip goes past it and tells the next step it was skipped; retry:N tries it again up to N more times.
|
|
581
|
+
7. label is optional and is only ever shown to the user. Say what the step is FOR in three or four words.
|
|
582
|
+
8. agentId must be one of the dispatchable sub-agent types listed below.`;
|
|
583
|
+
|
|
584
|
+
/** What the extension knows about the workflow endpoint before it can call anything. */
|
|
585
|
+
export type WorkflowToolConfig = {
|
|
586
|
+
endpoint: string;
|
|
587
|
+
conversationId: string;
|
|
588
|
+
token?: string;
|
|
589
|
+
/** This turn's dispatch capability. See `TaskToolConfig.capability` for why it travels in the body. */
|
|
590
|
+
capability?: string;
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
/** What the extension learns from its own environment before it can run a plan. */
|
|
594
|
+
export function readWorkflowConfig(env: Record<string, string | undefined>): WorkflowToolConfig {
|
|
595
|
+
const token = env.SUPER_PI_ACCESS_TOKEN;
|
|
596
|
+
const capability = env[TASK_CAPABILITY_ENV];
|
|
597
|
+
return {
|
|
598
|
+
endpoint: env[WORKFLOW_ENDPOINT_ENV] ?? "",
|
|
599
|
+
conversationId: env[TASK_CONVERSATION_ENV] ?? "",
|
|
600
|
+
...(token ? { token } : {}),
|
|
601
|
+
...(capability ? { capability } : {}),
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Refuses a call that cannot work, in words the model can act on.
|
|
607
|
+
*
|
|
608
|
+
* SHALLOWER THAN `refuseTask` DELIBERATELY. Only the two conditions that make the call impossible are
|
|
609
|
+
* checked here - no endpoint, no conversation. The plan's shape is the SERVER's business, because the
|
|
610
|
+
* server is what has the roster: this file cannot know whether "scout" is a legal agentId on this install,
|
|
611
|
+
* and a check against a list hardcoded here would be a second list free to fall out of date with
|
|
612
|
+
* `agents/*.md`. So a bad plan comes back as a server refusal carrying the reason - the same shape the
|
|
613
|
+
* model would have got anyway, and the server's message, which names the ids that actually exist.
|
|
614
|
+
*/
|
|
615
|
+
export function refuseWorkflow(config: WorkflowToolConfig, params: WorkflowParams): string | undefined {
|
|
616
|
+
if (!config.endpoint) return `The workflow tool is not available in this session: ${WORKFLOW_ENDPOINT_ENV} is not set, so there is no Super Pi server to run the plan. Use the task tool, or do the steps yourself.`;
|
|
617
|
+
if (!config.conversationId) return `The workflow tool is not available in this session: ${TASK_CONVERSATION_ENV} is not set. Use the task tool, or do the steps yourself.`;
|
|
618
|
+
if (!Array.isArray(params.steps) || !params.steps.length) return "workflow requires a non-empty `steps` array. For a single sub-agent, use the task tool instead.";
|
|
619
|
+
return undefined;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** One step as the server reports it back, which is the shape the envelope renders. */
|
|
623
|
+
export type WorkflowStepEntry = {
|
|
624
|
+
n?: number;
|
|
625
|
+
agentId?: string;
|
|
626
|
+
label?: string;
|
|
627
|
+
status?: string;
|
|
628
|
+
attempts?: number;
|
|
629
|
+
output?: string;
|
|
630
|
+
outputTruncated?: boolean;
|
|
631
|
+
error?: string;
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Renders the plan the model gets back, in the same `<task>` family as a single dispatch.
|
|
636
|
+
*
|
|
637
|
+
* THE SAME THREE STATES, and for the reason `renderTaskResult` folds Super Pi's stopped/failed into
|
|
638
|
+
* `error`: the envelope's state is a thing the model branches on, and a fourth value would break a
|
|
639
|
+
* consumer expecting the OpenCode three. What IS different is that a plan carries per-step detail the
|
|
640
|
+
* model needs - which step failed, how many tries it took, what each one said - and flattening all of
|
|
641
|
+
* that into one blob would throw away the part that makes a plan worth reading over three task calls.
|
|
642
|
+
*
|
|
643
|
+
* Each step is rendered with its own `<step>` element, and its attempts as a COUNT rather than as text: a
|
|
644
|
+
* retried step's earlier attempts are already in the run records and on the timeline, and re-sending a
|
|
645
|
+
* superseded answer here would have the model act on a failure it has already moved past.
|
|
646
|
+
*/
|
|
647
|
+
export function renderWorkflowResult(input: { id: string; state: "completed" | "error"; summary: string; steps: WorkflowStepEntry[]; text?: string }): string {
|
|
648
|
+
const tag = input.state === "error" ? "task_error" : "task_result";
|
|
649
|
+
const body: string[] = [];
|
|
650
|
+
for (const step of input.steps) {
|
|
651
|
+
const where = step.label ? `第 ${step.n} 步 · ${step.label}(${step.agentId})` : `第 ${step.n} 步 · ${step.agentId}`;
|
|
652
|
+
const tries = (step.attempts ?? 0) > 1 ? `,尝试了 ${step.attempts} 次` : "";
|
|
653
|
+
const lines = [`<step n="${step.n ?? "?"}" agent="${step.agentId ?? "?"}" status="${step.status ?? "unknown"}">`];
|
|
654
|
+
lines.push(`<summary>${where}:${step.status ?? "unknown"}${tries}</summary>`);
|
|
655
|
+
if (step.output) lines.push(step.outputTruncated ? `${step.output}\n[这一步的输出被服务端截断,完整内容在运行记录里]` : step.output);
|
|
656
|
+
if (step.error) lines.push(`(这一步没能完成:${step.error})`);
|
|
657
|
+
lines.push("</step>");
|
|
658
|
+
body.push(lines.join("\n"));
|
|
659
|
+
}
|
|
660
|
+
return [
|
|
661
|
+
`<task id="${input.id}" state="${input.state}">`,
|
|
662
|
+
`<summary>${input.summary}</summary>`,
|
|
663
|
+
`<${tag}>`,
|
|
664
|
+
...body,
|
|
665
|
+
...(input.text ? ["", input.text] : []),
|
|
666
|
+
`</${tag}>`,
|
|
667
|
+
"</task>",
|
|
668
|
+
].join("\n");
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** One HTTP seam, injected for tests exactly as `TaskFetch` is. */
|
|
672
|
+
export type WorkflowFetch = TaskFetch;
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Runs one plan and returns the envelope the model reads.
|
|
676
|
+
*
|
|
677
|
+
* RETURNS RATHER THAN THROWS for every outcome, for the reason `executeTask` does: Pi treats a thrown tool
|
|
678
|
+
* error and an isError result almost identically, so this is not about Pi - it is that the model should
|
|
679
|
+
* see ONE shape for "the delegation did not work", with the reason inside it.
|
|
680
|
+
*
|
|
681
|
+
* A PLAN THAT FAILED IS NOT AN ERROR IN THE TRANSPORT SENSE. The steps ran; some of them did not succeed.
|
|
682
|
+
* That is a result the model reasons about, so it comes back as state="error" with every step's output
|
|
683
|
+
* intact rather than as a refusal that would hide what did happen. The distinction matters most in the one
|
|
684
|
+
* case a model must handle well: a plan whose verifier came back negative still tells it everything it
|
|
685
|
+
* needs to decide whether to run another round.
|
|
686
|
+
*/
|
|
687
|
+
export async function executeWorkflow(
|
|
688
|
+
params: WorkflowParams,
|
|
689
|
+
config: WorkflowToolConfig,
|
|
690
|
+
deps: { signal?: AbortSignal; fetch?: WorkflowFetch } = {},
|
|
691
|
+
): Promise<TaskToolResult> {
|
|
692
|
+
const text = (body: string, isError?: boolean): TaskToolResult => ({ content: [{ type: "text", text: body }], details: undefined, ...(isError ? { isError: true } : {}) });
|
|
693
|
+
const refusal = refuseWorkflow(config, params);
|
|
694
|
+
if (refusal) return text(renderWorkflowResult({ id: "refused", state: "error", summary: "workflow", steps: [], text: refusal }), true);
|
|
695
|
+
|
|
696
|
+
const send = deps.fetch ?? globalFetch;
|
|
697
|
+
let response: Awaited<ReturnType<WorkflowFetch>>;
|
|
698
|
+
try {
|
|
699
|
+
response = await send(config.endpoint, {
|
|
700
|
+
method: "POST",
|
|
701
|
+
headers: { "content-type": "application/json", ...(config.token ? { authorization: `Bearer ${config.token}` } : {}) },
|
|
702
|
+
// The CONVERSATION, never a directory - the same boundary `executeTask` keeps, for the same reason.
|
|
703
|
+
body: JSON.stringify({
|
|
704
|
+
conversationId: config.conversationId,
|
|
705
|
+
summary: params.summary,
|
|
706
|
+
onFailure: params.onFailure,
|
|
707
|
+
steps: params.steps,
|
|
708
|
+
capability: config.capability,
|
|
709
|
+
}),
|
|
710
|
+
...(deps.signal ? { signal: deps.signal } : {}),
|
|
711
|
+
});
|
|
712
|
+
} catch (error) {
|
|
713
|
+
return text(renderWorkflowResult({
|
|
714
|
+
id: "unreachable", state: "error", summary: params.summary?.trim() || "workflow", steps: [],
|
|
715
|
+
text: `Could not reach the Super Pi server at ${config.endpoint}: ${error instanceof Error ? error.message : "unknown error"}. Nothing ran, so there is no plan to look for.`,
|
|
716
|
+
}), true);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
let payload: { workflow?: { id?: string; summary?: string; status?: string; steps?: WorkflowStepEntry[] }; error?: string };
|
|
720
|
+
try { payload = JSON.parse(await response.text()) as typeof payload; }
|
|
721
|
+
catch { return text(renderWorkflowResult({ id: "unreadable", state: "error", summary: "workflow", steps: [], text: `The workflow endpoint answered ${response.status} with something that is not JSON, so its result cannot be read.` }), true); }
|
|
722
|
+
|
|
723
|
+
// No workflow id means nothing ran: the server refused before writing a plan, so there is no timeline
|
|
724
|
+
// to point at, and saying so beats implying a run exists whose output is merely absent.
|
|
725
|
+
if (!payload.workflow?.id) {
|
|
726
|
+
return text(renderWorkflowResult({
|
|
727
|
+
id: "refused", state: "error", summary: params.summary?.trim() || "workflow", steps: [],
|
|
728
|
+
text: payload.error ?? `The workflow endpoint answered ${response.status} without starting a plan.`,
|
|
729
|
+
}), true);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const workflow = payload.workflow;
|
|
733
|
+
const steps = Array.isArray(workflow.steps) ? workflow.steps : [];
|
|
734
|
+
const completed = workflow.status === "completed";
|
|
735
|
+
const finished = steps.filter((step) => step.status === "completed" || step.status === "skipped").length;
|
|
736
|
+
return text(
|
|
737
|
+
renderWorkflowResult({
|
|
738
|
+
id: workflow.id,
|
|
739
|
+
state: completed ? "completed" : "error",
|
|
740
|
+
summary: workflow.summary || params.summary?.trim() || "workflow",
|
|
741
|
+
steps,
|
|
742
|
+
...(completed ? {} : {
|
|
743
|
+
text: `The plan finished as ${JSON.stringify(workflow.status ?? "unknown")}: ${finished} of ${steps.length} steps completed. Nothing was lost - read each step above, then decide what happens next. If you need another round, dispatch it with the task tool.`,
|
|
744
|
+
}),
|
|
745
|
+
}),
|
|
746
|
+
!completed,
|
|
747
|
+
);
|
|
748
|
+
}
|