@evandu/super-pi 0.1.1 → 0.1.3
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-CsbZzuMy.css → index-BhFeQ1E-.css} +1 -1
- package/dist/client/assets/index-D_VwFuME.js +43 -0
- package/dist/client/index.html +2 -2
- package/dist/server/agents.d.ts +20 -1
- package/dist/server/agents.js +92 -18
- package/dist/server/application/catalog-service.d.ts +22 -2
- package/dist/server/application/catalog-service.js +61 -9
- package/dist/server/application/conversation-service.d.ts +3 -2
- package/dist/server/application/conversation-service.js +5 -1
- package/dist/server/application/run-service.d.ts +49 -1
- package/dist/server/application/run-service.js +81 -2
- package/dist/server/application/workspace-service.d.ts +3 -2
- package/dist/server/application/workspace-service.js +5 -1
- package/dist/server/capability.d.ts +89 -0
- package/dist/server/capability.js +136 -0
- package/dist/server/command-handler.js +12 -0
- package/dist/server/http-server.js +20 -0
- package/dist/server/index.js +47 -9
- package/dist/server/runtime.d.ts +6 -2
- package/dist/server/runtime.js +19 -4
- package/dist/server/server-context.d.ts +1 -0
- package/dist/server/store.d.ts +48 -0
- package/dist/server/store.js +160 -1
- package/dist/server/subagent-runs.d.ts +2 -0
- package/dist/server/subagent-runs.js +4 -0
- package/dist/server/subagents.d.ts +3 -2
- package/dist/server/subagents.js +5 -1
- package/dist/server/task-tool.d.ts +21 -0
- package/dist/server/task-tool.js +21 -0
- package/dist/server/types.d.ts +45 -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 +8 -2
- package/dist/client/assets/index-BMQsEdio.js +0 -24
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { attemptFromRun, composeStepPrompt, newWorkflowRunId, retriesFor, } from "./workflow.js";
|
|
2
|
+
const now = () => new Date().toISOString();
|
|
3
|
+
export class WorkflowEngine {
|
|
4
|
+
recorder;
|
|
5
|
+
hooks;
|
|
6
|
+
/**
|
|
7
|
+
* One controller per in-flight plan.
|
|
8
|
+
*
|
|
9
|
+
* NEEDED SEPARATELY FROM THE RECORDER'S, and the difference is what stops a plan between steps. The
|
|
10
|
+
* recorder's map only holds runs that are executing right now, so a plan halted by a signal while no
|
|
11
|
+
* child is out would have nothing to abort and would happily start the next one. This map holds the
|
|
12
|
+
* PLAN, which exists for its whole length, so `stop` reaches it in every phase.
|
|
13
|
+
*/
|
|
14
|
+
controllers = new Map();
|
|
15
|
+
constructor(recorder, hooks) {
|
|
16
|
+
this.recorder = recorder;
|
|
17
|
+
this.hooks = hooks;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Stops ONE plan, and says whether there was anything to stop.
|
|
21
|
+
*
|
|
22
|
+
* `false` for an unknown or already-finished id is deliberate and matches `SubagentRunRecorder.stop`:
|
|
23
|
+
* the click that produces it raced the plan's own terminal event, and an error reply would put a
|
|
24
|
+
* failure banner on a timeline that says it finished normally.
|
|
25
|
+
*
|
|
26
|
+
* The abort reaches the child too, because every step is dispatched under this controller's signal and
|
|
27
|
+
* `dispatch` turns it into the same abort-then-killTree the recorder uses. Stopping a plan really does
|
|
28
|
+
* stop the process it is on.
|
|
29
|
+
*/
|
|
30
|
+
stop(workflowId) {
|
|
31
|
+
const controller = this.controllers.get(workflowId);
|
|
32
|
+
if (!controller)
|
|
33
|
+
return false;
|
|
34
|
+
controller.abort();
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
/** Stops every plan in flight for one conversation, and returns how many were signalled. */
|
|
38
|
+
stopConversation(conversationId) {
|
|
39
|
+
let stopped = 0;
|
|
40
|
+
for (const [id, controller] of this.controllers) {
|
|
41
|
+
if (this.conversationOf(id) === conversationId) {
|
|
42
|
+
controller.abort();
|
|
43
|
+
stopped += 1;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return stopped;
|
|
47
|
+
}
|
|
48
|
+
/** Aborts every plan in flight. Called on shutdown, so no child outlives the server that owns it. */
|
|
49
|
+
abortAll() {
|
|
50
|
+
for (const controller of this.controllers.values())
|
|
51
|
+
controller.abort();
|
|
52
|
+
}
|
|
53
|
+
/** The conversation a plan belongs to, which only this map knows while the plan is running. */
|
|
54
|
+
conversationOf(workflowId) {
|
|
55
|
+
return this.owners.get(workflowId);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Which conversation each in-flight plan belongs to.
|
|
59
|
+
*
|
|
60
|
+
* Held beside the controllers rather than read back from the store, because the store is the right
|
|
61
|
+
* place for what a person may read later and the wrong place for a synchronous lookup in a stop
|
|
62
|
+
* handler - it clones the entire state, and this runs on a click.
|
|
63
|
+
*/
|
|
64
|
+
owners = new Map();
|
|
65
|
+
/**
|
|
66
|
+
* Executes one plan to a terminal state, and returns the record as it ended.
|
|
67
|
+
*
|
|
68
|
+
* ALWAYS RESOLVES, like `dispatch` does and for the same reason: by the time the first await runs, the
|
|
69
|
+
* whole plan is already on disk, so the honest account of a plan that cannot proceed is a record that
|
|
70
|
+
* says why, rather than a rejection that leaves it claiming to be running until the next restart.
|
|
71
|
+
*/
|
|
72
|
+
async run(input) {
|
|
73
|
+
const id = newWorkflowRunId();
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
const onCallerAbort = () => controller.abort();
|
|
76
|
+
input.signal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
77
|
+
this.controllers.set(id, controller);
|
|
78
|
+
this.owners.set(id, input.conversationId);
|
|
79
|
+
// THE WHOLE PLAN, IN FULL, BEFORE STEP ONE RUNS. See `Store.beginWorkflow`: this is the write that
|
|
80
|
+
// makes the record a claim the loop makes good rather than a log that fills in after the fact.
|
|
81
|
+
const plan = {
|
|
82
|
+
id,
|
|
83
|
+
conversationId: input.conversationId,
|
|
84
|
+
summary: input.plan.summary,
|
|
85
|
+
onFailure: input.plan.onFailure,
|
|
86
|
+
status: "running",
|
|
87
|
+
steps: input.plan.steps.map((step, index) => ({
|
|
88
|
+
index,
|
|
89
|
+
agentId: step.agentId,
|
|
90
|
+
...(step.label ? { label: step.label } : {}),
|
|
91
|
+
prompt: step.prompt,
|
|
92
|
+
attempts: [],
|
|
93
|
+
status: "pending",
|
|
94
|
+
})),
|
|
95
|
+
currentIndex: 0,
|
|
96
|
+
startedAt: now(),
|
|
97
|
+
};
|
|
98
|
+
try {
|
|
99
|
+
// The INSERT, distinct from every later `commit`: this is the one write that makes the plan exist,
|
|
100
|
+
// and it happens before the loop rather than inside it. Separate from `commit` rather than folded
|
|
101
|
+
// into it because "insert if absent" is a different store operation with a different failure mode -
|
|
102
|
+
// a `commit` that upserted would let a plan start executing without ever having been written down.
|
|
103
|
+
const created = await this.hooks.store.beginWorkflow(plan);
|
|
104
|
+
this.hooks.broadcast({ type: "workflow.event", workflow: created });
|
|
105
|
+
for (let index = 0; index < plan.steps.length; index += 1) {
|
|
106
|
+
// Checked BEFORE each step, not only after one: a plan stopped between two children must not
|
|
107
|
+
// start the second one, and the signal that stopped it may have arrived while no child was out.
|
|
108
|
+
if (controller.signal.aborted)
|
|
109
|
+
break;
|
|
110
|
+
const halted = await this.runStep(plan, plan.steps[index], input, controller.signal);
|
|
111
|
+
if (halted)
|
|
112
|
+
break;
|
|
113
|
+
plan.currentIndex = index + 1;
|
|
114
|
+
await this.commit(plan);
|
|
115
|
+
}
|
|
116
|
+
// The terminal status is DERIVED, not set optimistically as the loop goes. A plan that finished all
|
|
117
|
+
// its steps including deliberate skips is `completed` - skips are the plan's own declared intent,
|
|
118
|
+
// not a shortfall. A plan that ran out of steps because one failed under a `stop` policy is `failed`.
|
|
119
|
+
//
|
|
120
|
+
// BEING HALTED OUTRANKS ALL OF IT, and it is keyed on a STEP that ended `stopped` rather than only
|
|
121
|
+
// on this engine's own signal - because a plan can be halted by a button that never touches the
|
|
122
|
+
// engine at all. Pressing stop on a step's OWN run card aborts the child through the recorder, which
|
|
123
|
+
// settles that run as `stopped`; the engine's controller is still perfectly un-aborted at that point.
|
|
124
|
+
// Keying only on the signal reported that plan as `failed`, which is a verdict nothing established:
|
|
125
|
+
// the work was not broken, a person stopped it. So the test is whether anything in the plan ended
|
|
126
|
+
// `stopped`, whoever did the stopping.
|
|
127
|
+
if (controller.signal.aborted || plan.steps.some((step) => step.status === "stopped")) {
|
|
128
|
+
this.haltRemaining(plan, plan.currentIndex);
|
|
129
|
+
plan.status = "stopped";
|
|
130
|
+
}
|
|
131
|
+
else if (plan.steps.every((step) => step.status === "completed" || step.status === "skipped")) {
|
|
132
|
+
plan.status = "completed";
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
this.haltRemaining(plan, plan.currentIndex);
|
|
136
|
+
plan.status = "failed";
|
|
137
|
+
}
|
|
138
|
+
plan.finishedAt = now();
|
|
139
|
+
await this.commit(plan);
|
|
140
|
+
return { workflow: structuredClone(plan) };
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
input.signal?.removeEventListener("abort", onCallerAbort);
|
|
144
|
+
this.controllers.delete(id);
|
|
145
|
+
this.owners.delete(id);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Runs one step until it succeeds, is skipped, or stops the plan. Returns whether the PLAN should halt.
|
|
150
|
+
*
|
|
151
|
+
* The retry is a loop rather than a recursive call so the bound in the policy is the loop's own
|
|
152
|
+
* condition and cannot be exceeded by a bug in the call structure - the one property a retry policy has
|
|
153
|
+
* to have absolutely.
|
|
154
|
+
*/
|
|
155
|
+
async runStep(plan, step, input, signal) {
|
|
156
|
+
const retries = retriesFor(plan.onFailure);
|
|
157
|
+
let attempt = 0;
|
|
158
|
+
while (attempt <= retries) {
|
|
159
|
+
if (signal.aborted) {
|
|
160
|
+
step.status = "stopped";
|
|
161
|
+
await this.commit(plan);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
attempt += 1;
|
|
165
|
+
step.status = "running";
|
|
166
|
+
plan.currentIndex = step.index;
|
|
167
|
+
// Recomposed EVERY attempt, not once before the loop. A retry exists because the first answer was
|
|
168
|
+
// not acceptable, so handing the second attempt the same inputs would make the policy a no-op that
|
|
169
|
+
// burns the same sub-agent's answer twice and reports it as progress.
|
|
170
|
+
const composed = composeStepPrompt(step.prompt, plan.steps.slice(0, step.index));
|
|
171
|
+
await this.commit(plan);
|
|
172
|
+
const run = await this.recorder.dispatch({
|
|
173
|
+
conversationId: input.conversationId,
|
|
174
|
+
cwd: input.cwd,
|
|
175
|
+
agentId: step.agentId,
|
|
176
|
+
task: composed.prompt,
|
|
177
|
+
dispatchedBy: "model",
|
|
178
|
+
// The two fields that were carried in every run record from the start and read by nothing until
|
|
179
|
+
// now. A step's run therefore names the plan it belongs to and the label the model gave it, which
|
|
180
|
+
// is what lets the timeline link a card back to its step without any new field on `SubagentRun`.
|
|
181
|
+
loopId: plan.id,
|
|
182
|
+
role: step.label,
|
|
183
|
+
model: input.model,
|
|
184
|
+
denyTools: input.denyTools,
|
|
185
|
+
signal,
|
|
186
|
+
});
|
|
187
|
+
// Appended, never assigned over: the attempt that failed is exactly what makes the retry legible,
|
|
188
|
+
// and it is the record a person reads when a verifier failed twice.
|
|
189
|
+
step.attempts.push(attemptFromRun(run, attempt));
|
|
190
|
+
if (run.status === "completed") {
|
|
191
|
+
step.status = "completed";
|
|
192
|
+
await this.commit(plan);
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
if (run.status === "stopped") {
|
|
196
|
+
step.status = "stopped";
|
|
197
|
+
await this.commit(plan);
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
// Failed. The policy decides whether this step is tried again, walked past, or ends the plan.
|
|
201
|
+
if (attempt <= retries) {
|
|
202
|
+
await this.commit(plan);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (plan.onFailure === "skip") {
|
|
206
|
+
// Marked `skipped` rather than `failed`, because the two mean different things to what comes
|
|
207
|
+
// after: a skipped step contributed no output and says so in the next step's handoff block, while
|
|
208
|
+
// a failed one contributed output that failed. Both are visible; conflating them would lose which.
|
|
209
|
+
step.status = "skipped";
|
|
210
|
+
await this.commit(plan);
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
step.status = "failed";
|
|
214
|
+
await this.commit(plan);
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
// Unreachable: the loop returns on every path that sets a terminal step status, and the bound is
|
|
218
|
+
// checked on entry. Present so a future edit that adds a path fails to compile rather than silently
|
|
219
|
+
// leaving a step `running` with no plan status to match it.
|
|
220
|
+
step.status = "failed";
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Marks every step from `from` onward as stopped, because none of them will run.
|
|
225
|
+
*
|
|
226
|
+
* LEAVING THEM `pending` WOULD BE A LIE IN THE SPECIFIC DIRECTION THAT MATTERS: a browser renders
|
|
227
|
+
* "未开始" for a pending step, so a plan that died after step one of three would show three steps with
|
|
228
|
+
* two still waiting, and a person would reasonably expect the rest to happen. They will not.
|
|
229
|
+
*/
|
|
230
|
+
haltRemaining(plan, from) {
|
|
231
|
+
for (const step of plan.steps.slice(from))
|
|
232
|
+
if (step.status === "pending" || step.status === "running")
|
|
233
|
+
step.status = "stopped";
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Persists a plan, THEN announces it.
|
|
237
|
+
*
|
|
238
|
+
* PERSIST FIRST, ANNOUNCE SECOND, and the order is load-bearing here for exactly the reason it is in
|
|
239
|
+
* `SubagentRunRecorder.settle`: a browser that is told a step finished while `state.json` still says it
|
|
240
|
+
* is running will show a timeline a reload immediately contradicts, and the startup reconciliation that
|
|
241
|
+
* would fix it runs on a SERVER restart, not a page refresh.
|
|
242
|
+
*
|
|
243
|
+
* Unlike the recorder, this AWAITS both halves rather than writing fire-and-forget, and that is not an
|
|
244
|
+
* inconsistency. The recorder cannot await: it publishes on every text delta, and a drain loop that
|
|
245
|
+
* flushed to disk per token would throttle the child it is streaming. This publishes twice per step -
|
|
246
|
+
* at most sixteen times for a whole plan - so awaiting costs nothing measurable and buys the strong
|
|
247
|
+
* version of the guarantee.
|
|
248
|
+
*/
|
|
249
|
+
async commit(plan) {
|
|
250
|
+
const persisted = await this.hooks.store.setWorkflow(plan);
|
|
251
|
+
this.hooks.broadcast({ type: "workflow.event", workflow: persisted });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import type { SubagentRosterEntry } from "./task-tool.js";
|
|
2
|
+
import type { SubagentRun } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The workflow PLAN: an ordered list of sub-agent steps the server will execute on the primary agent's
|
|
5
|
+
* behalf, carrying each step's output into the next one.
|
|
6
|
+
*
|
|
7
|
+
* WHY THIS FILE EXISTS, and why it holds no I/O at all.
|
|
8
|
+
*
|
|
9
|
+
* The dispatch pipe was already built and already proved: `task-tool.ts` is the policy half of the mouth,
|
|
10
|
+
* `subagent-runs.ts` records and publishes a run, `subagents.ts` spawns and drains a child. What was
|
|
11
|
+
* missing was that a MODEL, given a request that needs three different specialists in a row, had to
|
|
12
|
+
* remember the order itself and hand-copy each result into the next prompt by hand. That is the
|
|
13
|
+
* orchestration gap - and the reason the seam here is pure is the same reason `task-tool.ts` is: every
|
|
14
|
+
* rule in this file can then be proved without spawning a process, which is the only layer where a
|
|
15
|
+
* plan's shape, its refusals and its truncation budgets are cheap to test exhaustively.
|
|
16
|
+
*
|
|
17
|
+
* WHAT IS BORROWED FROM OPENCODE, and what is not: nothing. That is a deliberate departure from the
|
|
18
|
+
* sibling files, which each cite upstream lines they ported. OpenCode has no multi-sub-agent pipeline
|
|
19
|
+
* primitive to port - it composes `task` calls the same way this project did, by model convention, which
|
|
20
|
+
* is the gap. What this file reuses instead is local: the roster filter from `task-tool.ts` (the same one
|
|
21
|
+
* that decides who may be dispatched), and the `SubagentRunRecorder.dispatch` seam, which always resolves
|
|
22
|
+
* with a settled record and therefore needs no error parsing here.
|
|
23
|
+
*
|
|
24
|
+
* WHAT THIS FILE DELIBERATELY DOES NOT MODEL, because `docs/AGENT_LOOP_DESIGN.md` proposed all of it and
|
|
25
|
+
* each one is a second orchestration authority rather than a capability:
|
|
26
|
+
*
|
|
27
|
+
* - BRANCHES AND EDGES. A declared graph makes the model learn a language to express sequencing it can
|
|
28
|
+
* already express by dispatching twice. It also cannot be expressive enough, and a model that finds it
|
|
29
|
+
* too rigid bypasses it, leaving two ways to orchestrate that behave differently.
|
|
30
|
+
* - LOOP-BACK. `verifier.md` already ends its report with `通过`/`失败`, so the model can read a failed
|
|
31
|
+
* step and decide whether to run another round. That judgement is the thing worth keeping on the side
|
|
32
|
+
* that can read the output; encoding it as an edge would move it to a component that never sees the text.
|
|
33
|
+
* - A SEPARATE ARTIFACT STORE. `WorkflowAttempt` below IS the versioned artefact: one attempt's output is
|
|
34
|
+
* written once and never edited, and the next attempt appends beside it rather than replacing it.
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* The id an orchestrated plan answers to.
|
|
38
|
+
*
|
|
39
|
+
* Prefixed and uuid-shaped for the same reason run ids are: the string is a lookup key in `state.json`
|
|
40
|
+
* and appears in `data-*` attributes in the browser, and a shape a person can recognise in a log is worth
|
|
41
|
+
* more than a bare uuid. `engine.ts` validates caller-supplied ids against this prefix the way
|
|
42
|
+
* `subagents.ts:358` validates run ids, because the same string becomes a reconciliation key at startup.
|
|
43
|
+
*/
|
|
44
|
+
export declare const newWorkflowRunId: () => string;
|
|
45
|
+
/** Matches the shape `newWorkflowRunId` produces, for validating a caller-supplied id. */
|
|
46
|
+
export declare const WORKFLOW_ID_PATTERN: RegExp;
|
|
47
|
+
/**
|
|
48
|
+
* How many steps one plan may hold.
|
|
49
|
+
*
|
|
50
|
+
* A bound, because every step is a real Pi process that runs for minutes and every step's output is
|
|
51
|
+
* carried forward: the cost of a plan grows with both its length and the size of what it accumulates.
|
|
52
|
+
* Eight steps is more than the pipelines this install's four sub-agents are built for (`scout` to
|
|
53
|
+
* `patcher` to `verifier`, maybe twice), so the bound is one a real request would not reach rather than
|
|
54
|
+
* one it would hit by accident.
|
|
55
|
+
*/
|
|
56
|
+
export declare const WORKFLOW_MAX_STEPS = 8;
|
|
57
|
+
/**
|
|
58
|
+
* What happens to a step that failed.
|
|
59
|
+
*
|
|
60
|
+
* `stop` is the DEFAULT and it is the honest one. `docs/AGENT_LOOP_DESIGN.md` named the failure this
|
|
61
|
+
* guards against in a sentence worth keeping: "A failed step never silently advances with missing input."
|
|
62
|
+
* Continuing past a failed step means the next sub-agent is handed a hole it cannot see, and the plan
|
|
63
|
+
* reports success. So a caller that says nothing about failure gets the behaviour that cannot lie.
|
|
64
|
+
*/
|
|
65
|
+
export type WorkflowFailurePolicy = "stop" | "skip" | "retry:1" | "retry:2" | "retry:3";
|
|
66
|
+
/** Every accepted policy, in the order the refusal message lists them for a model that guesses wrong. */
|
|
67
|
+
export declare const WORKFLOW_FAILURE_POLICIES: readonly WorkflowFailurePolicy[];
|
|
68
|
+
/**
|
|
69
|
+
* Reads a failure policy, or `undefined` for anything that is not one.
|
|
70
|
+
*
|
|
71
|
+
* `retry` alone normalizes to `retry:1`: a model that reaches for retry almost always means "try it
|
|
72
|
+
* again once", and refusing over a missing count would cost a retry of the plan itself. Everything else
|
|
73
|
+
* is refused rather than coerced, because guessing `stop` for a model that asked for `parallel` would
|
|
74
|
+
* silently run a different plan than the one it wrote.
|
|
75
|
+
*/
|
|
76
|
+
export declare function parseFailurePolicy(raw: unknown): WorkflowFailurePolicy | undefined;
|
|
77
|
+
/** How many times a policy re-runs a failing step. `stop` and `skip` both run it zero further times. */
|
|
78
|
+
export declare function retriesFor(policy: WorkflowFailurePolicy): number;
|
|
79
|
+
/** Where a whole plan ended. `running` is the only state from which it advances on its own. */
|
|
80
|
+
export type WorkflowStatus = "running" | "completed" | "failed" | "stopped";
|
|
81
|
+
/** Where one step ended. `skipped` means the policy chose to walk past it after a failure. */
|
|
82
|
+
export type WorkflowStepStatus = "pending" | "running" | "completed" | "failed" | "skipped" | "stopped";
|
|
83
|
+
/**
|
|
84
|
+
* One try at one step.
|
|
85
|
+
*
|
|
86
|
+
* THE ARTEFACT, AND THE REASON THERE IS NO ARTIFACT STORE. A failed `verifier` that is retried has two
|
|
87
|
+
* different answers - the first one found two failing assertions, the second found none - and the first
|
|
88
|
+
* is exactly what makes the second legible. So an attempt is written once and never edited, and a retry
|
|
89
|
+
* appends beside the attempt it replaced instead of overwriting it. `n` is the attempt number within its
|
|
90
|
+
* step, not a global counter, so one step's history reads 1, 2, 3 without knowing anything about its neighbours.
|
|
91
|
+
*/
|
|
92
|
+
export type WorkflowAttempt = {
|
|
93
|
+
/** 1-based within the step. Attempt 1 is the first try, not a zero-th. */
|
|
94
|
+
n: number;
|
|
95
|
+
/** The `SubagentRun` this attempt produced. Absent only when the dispatch was refused before a run row existed. */
|
|
96
|
+
runId?: string;
|
|
97
|
+
/** How the run settled. Never `running`: `dispatch` resolves only once the child is terminal. */
|
|
98
|
+
status: "completed" | "failed" | "stopped";
|
|
99
|
+
/** The child's final message. Carried forward to later steps, and read by the primary agent afterwards. */
|
|
100
|
+
output?: string;
|
|
101
|
+
outputTruncated?: boolean;
|
|
102
|
+
startedAt: string;
|
|
103
|
+
finishedAt?: string;
|
|
104
|
+
/** Why this attempt did not complete. Present only beside a `failed` or `stopped` status. */
|
|
105
|
+
error?: string;
|
|
106
|
+
};
|
|
107
|
+
/** One step of a plan. Its `attempts` array is append-only; `status` is the derived summary of it. */
|
|
108
|
+
export type WorkflowStep = {
|
|
109
|
+
index: number;
|
|
110
|
+
agentId: string;
|
|
111
|
+
/** An opaque label for a person to read. The engine never branches on it. */
|
|
112
|
+
label?: string;
|
|
113
|
+
/** The prompt as the model wrote it, before any prior output is composed into it. */
|
|
114
|
+
prompt: string;
|
|
115
|
+
attempts: WorkflowAttempt[];
|
|
116
|
+
status: WorkflowStepStatus;
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* A whole orchestrated plan, as persisted.
|
|
120
|
+
*
|
|
121
|
+
* The plan is written in full BEFORE step one runs, and that is the point of the record: progression is
|
|
122
|
+
* controlled by the server walking this structure, not by conventions inside successive prompts, so a
|
|
123
|
+
* browser that reloads mid-plan reads an accurate account of what was intended and not merely what
|
|
124
|
+
* happened to be written in a transcript so far.
|
|
125
|
+
*/
|
|
126
|
+
export type WorkflowRun = {
|
|
127
|
+
id: string;
|
|
128
|
+
conversationId: string;
|
|
129
|
+
/** One line describing what the whole plan is for, shown to a person above the timeline. */
|
|
130
|
+
summary: string;
|
|
131
|
+
onFailure: WorkflowFailurePolicy;
|
|
132
|
+
status: WorkflowStatus;
|
|
133
|
+
steps: WorkflowStep[];
|
|
134
|
+
/** Which step is executing or next. `steps.length` means the plan is past its last step. */
|
|
135
|
+
currentIndex: number;
|
|
136
|
+
startedAt: string;
|
|
137
|
+
finishedAt?: string;
|
|
138
|
+
};
|
|
139
|
+
/** One step as the primary agent is told about it: the last attempt, not the whole history. */
|
|
140
|
+
export type WorkflowStepSummary = {
|
|
141
|
+
n: number;
|
|
142
|
+
agentId: string;
|
|
143
|
+
label?: string;
|
|
144
|
+
status: WorkflowStepStatus;
|
|
145
|
+
/** How many tries this step took. Above one, the step failed before it passed, and that is visible. */
|
|
146
|
+
attempts: number;
|
|
147
|
+
output?: string;
|
|
148
|
+
outputTruncated?: boolean;
|
|
149
|
+
error?: string;
|
|
150
|
+
};
|
|
151
|
+
/**
|
|
152
|
+
* What the endpoint hands back so the extension can render one envelope.
|
|
153
|
+
*
|
|
154
|
+
* The last attempt's output rather than all of them, for the same reason the conversation transcript is
|
|
155
|
+
* not re-sent into every turn: the primary agent needs to know what the pipeline concluded, and the
|
|
156
|
+
* earlier attempts are already in the run records and the timeline. Rendering happens on the extension
|
|
157
|
+
* side because the envelope is the tool's return value, not the server's - the same split
|
|
158
|
+
* `task-tool.ts:17-19` states for `<task>`.
|
|
159
|
+
*/
|
|
160
|
+
export type WorkflowSummary = {
|
|
161
|
+
id: string;
|
|
162
|
+
summary: string;
|
|
163
|
+
status: WorkflowStatus;
|
|
164
|
+
onFailure: WorkflowFailurePolicy;
|
|
165
|
+
steps: WorkflowStepSummary[];
|
|
166
|
+
finishedAt?: string;
|
|
167
|
+
};
|
|
168
|
+
/** The plan shape the engine executes, after validation. What `validateWorkflowPlan` returns on success. */
|
|
169
|
+
export type WorkflowPlan = {
|
|
170
|
+
summary: string;
|
|
171
|
+
onFailure: WorkflowFailurePolicy;
|
|
172
|
+
steps: {
|
|
173
|
+
agentId: string;
|
|
174
|
+
label?: string;
|
|
175
|
+
prompt: string;
|
|
176
|
+
}[];
|
|
177
|
+
};
|
|
178
|
+
/**
|
|
179
|
+
* Text that had to lose its front, plus what it lost and why.
|
|
180
|
+
*
|
|
181
|
+
* `truncated` is carried rather than inferred from length so a CALLER can announce the loss. The house
|
|
182
|
+
* rule here is the one `task-tool.ts:102-116` already follows for an over-long roster: a payload that
|
|
183
|
+
* quietly omits part of itself makes a reader act on a belief that is not true, with no way to notice.
|
|
184
|
+
*/
|
|
185
|
+
export type CappedText = {
|
|
186
|
+
text: string;
|
|
187
|
+
truncated: boolean;
|
|
188
|
+
droppedChars: number;
|
|
189
|
+
};
|
|
190
|
+
/**
|
|
191
|
+
* Caps a block of text from the FRONT, and says in the surviving text that something was dropped.
|
|
192
|
+
*
|
|
193
|
+
* THE TAIL IS WHAT SURVIVES, and that is not an arbitrary choice. Every sub-agent in `agents/` is written
|
|
194
|
+
* to put its conclusion last - `verifier.md` says its last line is `通过`/`失败`/`无法验证` and nothing
|
|
195
|
+
* else - so keeping the end of a report keeps the part the next step and the human both need. Cutting
|
|
196
|
+
* from the end would keep the preamble and discard the verdict, which is the one line that matters.
|
|
197
|
+
*/
|
|
198
|
+
export declare function capText(text: string, budget: number, what: string): CappedText;
|
|
199
|
+
export declare const stepStatusWord: (status: WorkflowStepStatus) => string;
|
|
200
|
+
export declare const workflowStatusWord: (status: WorkflowStatus) => string;
|
|
201
|
+
/**
|
|
202
|
+
* Per-step cap on how much of one prior output is carried into the next step's prompt.
|
|
203
|
+
*
|
|
204
|
+
* Eight KB is roughly two thousand tokens of Chinese prose or a little more of code, which is enough for
|
|
205
|
+
* the conclusion-plus-evidence shape every sub-agent in `agents/` is instructed to produce, and small
|
|
206
|
+
* enough that eight of them plus the plan itself stay inside a normal model's working context.
|
|
207
|
+
*/
|
|
208
|
+
export declare const STEP_OUTPUT_BUDGET = 8000;
|
|
209
|
+
/**
|
|
210
|
+
* Total cap across all carried-forward output in one step's prompt.
|
|
211
|
+
*
|
|
212
|
+
* Separate from the per-step cap because the per-step one alone does not bound anything: eight steps
|
|
213
|
+
* carrying 8 KB each is 64 KB, which is larger than the whole context most sub-agents run with. The
|
|
214
|
+
* budget is spent newest-first, so the immediately preceding step - the one whose conclusion the current
|
|
215
|
+
* step is most likely acting on - is never the one that loses characters.
|
|
216
|
+
*/
|
|
217
|
+
export declare const WORKFLOW_CONTEXT_BUDGET = 32000;
|
|
218
|
+
/** The token that hands a step exactly the previous step's output, with nothing prepended. */
|
|
219
|
+
export declare const PREVIOUS_TOKEN = "{{previous}}";
|
|
220
|
+
/** The token that hands a step every prior step's output, with nothing prepended. */
|
|
221
|
+
export declare const HISTORY_TOKEN = "{{history}}";
|
|
222
|
+
/** What a composed prompt ended up carrying, so a caller can report it without re-parsing the string. */
|
|
223
|
+
export type ComposedPrompt = {
|
|
224
|
+
prompt: string;
|
|
225
|
+
/** How many prior steps actually contributed output. */
|
|
226
|
+
handedOff: number;
|
|
227
|
+
/** Whether anything was dropped for space. */
|
|
228
|
+
truncated: boolean;
|
|
229
|
+
};
|
|
230
|
+
/**
|
|
231
|
+
* Composes the prompt one step actually runs with, carrying earlier results into it.
|
|
232
|
+
*
|
|
233
|
+
* THE DEFAULT IS THE HANDOFF BLOCK, and the two tokens are the opt-outs. A model that does nothing
|
|
234
|
+
* special gets every prior step's output prepended, because "did the model remember to copy the last
|
|
235
|
+
* result forward" is not a property worth trusting a multi-step plan to. A model that WANTS control -
|
|
236
|
+
* wants the last output and nothing else, or wants the whole history verbatim - says so with a token,
|
|
237
|
+
* and in exchange the automatic block is suppressed entirely rather than being added alongside. Both
|
|
238
|
+
* spellings keep one rule intact: whatever this returns is the complete prompt, never a prefix that
|
|
239
|
+
* hides some of the instruction.
|
|
240
|
+
*
|
|
241
|
+
* AN EMPTY FIRST STEP IS THE PROMPT, VERBATIM. Composing a handoff header onto step one would tell a
|
|
242
|
+
* sub-agent about previous steps that do not exist, and would spend its attention on the header.
|
|
243
|
+
*/
|
|
244
|
+
export declare function composeStepPrompt(prompt: string, prior: readonly WorkflowStep[]): ComposedPrompt;
|
|
245
|
+
/**
|
|
246
|
+
* A plan that survived validation, or a sentence telling a MODEL what to fix.
|
|
247
|
+
*
|
|
248
|
+
* The refusal half exists because this value's first reader is a model about to retry: every message
|
|
249
|
+
* names what was wrong AND what an acceptable value looks like, following the rule `task-tool.ts:142`
|
|
250
|
+
* states for the depth refusal. `agents` is supplied in full even when the mistake is one member of it,
|
|
251
|
+
* because a model that guessed `scouter` has one correct answer and should not have to infer it.
|
|
252
|
+
*/
|
|
253
|
+
export type ValidatedPlan = {
|
|
254
|
+
plan: WorkflowPlan;
|
|
255
|
+
refusal?: undefined;
|
|
256
|
+
} | {
|
|
257
|
+
plan?: undefined;
|
|
258
|
+
refusal: string;
|
|
259
|
+
};
|
|
260
|
+
/**
|
|
261
|
+
* Checks an untrusted plan body against the roster that may actually be dispatched.
|
|
262
|
+
*
|
|
263
|
+
* VALIDATES, NEVER REWRITES, THE AGENT LIST. `agentId` is matched against the roster rather than
|
|
264
|
+
* defaulted, because defaulting would run a different sub-agent than the model asked for - a plan that
|
|
265
|
+
* silently substituted `patcher` for a misspelled `patchre` would edit files on a read-only request.
|
|
266
|
+
* The roster arrives already filtered to `mode: subagent` and non-hidden (`task-tool.ts:88`), so a
|
|
267
|
+
* primary agent's id is refused here by the same rule that refuses it at dispatch time
|
|
268
|
+
* (`subagents.ts:368`) - one lock, consulted twice, rather than a second list that could drift.
|
|
269
|
+
*/
|
|
270
|
+
export declare function validateWorkflowPlan(raw: unknown, roster: readonly SubagentRosterEntry[]): ValidatedPlan;
|
|
271
|
+
/**
|
|
272
|
+
* Reduces a persisted plan to what the primary agent is told when the workflow returns.
|
|
273
|
+
*
|
|
274
|
+
* ONE ATTEMPT PER STEP, THE LAST ONE. The earlier attempts stay in the run records and on the timeline,
|
|
275
|
+
* which is where a person debugging a retried plan looks; putting all of them in the envelope would make
|
|
276
|
+
* the model re-read a failure it has already acted on.
|
|
277
|
+
*/
|
|
278
|
+
export declare function workflowSummary(plan: WorkflowRun): WorkflowSummary;
|
|
279
|
+
/**
|
|
280
|
+
* The step a settled run became, as an attempt record.
|
|
281
|
+
*
|
|
282
|
+
* The narrow seam between the run recorder and this engine: everything about spawning, streaming and
|
|
283
|
+
* process teardown is the recorder's, and the only thing that crosses here is the settled record it
|
|
284
|
+
* always resolves with (`subagent-runs.ts:159`). `output` is copied rather than referenced because the
|
|
285
|
+
* store hands out clones, and a plan that kept a live reference would read whatever the run wrote next.
|
|
286
|
+
*/
|
|
287
|
+
export declare function attemptFromRun(run: SubagentRun, n: number): WorkflowAttempt;
|