@evandu/super-pi 0.1.141 → 0.1.143
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 +55 -27
- package/agents/super-pi-task/index.ts +76 -4
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +7 -8
- package/dist/client/assets/{CodeMirrorEditor-Dm5IDKy9.js → CodeMirrorEditor-CXDQ0ikN.js} +2 -2
- package/dist/client/assets/{dist-BGaKp7BG.js → dist-BK_KWsQr.js} +1 -1
- package/dist/client/assets/{dist-CN1j5LO2.js → dist-BKsr7wc2.js} +1 -1
- package/dist/client/assets/{dist-C7UQGfmz.js → dist-BXQWaRci.js} +1 -1
- package/dist/client/assets/{dist-BvCNiV_z.js → dist-B_uWAP2-.js} +1 -1
- package/dist/client/assets/{dist-CNgQgdyS.js → dist-BwwacXyS.js} +1 -1
- package/dist/client/assets/{dist-GAPsnCM8.js → dist-CWd7iD1S.js} +1 -1
- package/dist/client/assets/{dist-DWj0wg8M.js → dist-CbjfwVs-.js} +1 -1
- package/dist/client/assets/{dist-PQZBepmy.js → dist-CoEavaUe.js} +1 -1
- package/dist/client/assets/{dist-BqXU6S8h.js → dist-D60BDUMe.js} +1 -1
- package/dist/client/assets/{dist-LCDxbTTI.js → dist-DBbQHcwG.js} +1 -1
- package/dist/client/assets/{dist-DtO-7WEZ.js → dist-DVEBhaUf.js} +1 -1
- package/dist/client/assets/{dist-D0eIQjoI.js → dist-Db6KH9Qv.js} +1 -1
- package/dist/client/assets/{dist-BMbwbGH9.js → dist-Dsj0Q8gO.js} +1 -1
- package/dist/client/assets/{dist-4baaMdqn.js → dist-H0dUhAgy.js} +1 -1
- package/dist/client/assets/{dist-D0-9TI_k.js → dist-MZSBP_1m.js} +1 -1
- package/dist/client/assets/{dist-B-edBMDa.js → dist-PNt22XLU.js} +1 -1
- package/dist/client/assets/{dist-M3BFsjal.js → dist-S5UwGLvb.js} +1 -1
- package/dist/client/assets/{dist-F7KOq8Xm.js → dist-_jioFcsD.js} +1 -1
- package/dist/client/assets/{dist-CSDLiQKt.js → dist-hmP8KP8-.js} +1 -1
- package/dist/client/assets/{dist-D7YZUqfA.js → dist-jV9tqAoC.js} +1 -1
- package/dist/client/assets/{dist-CMDW84Eh.js → dist-oqETQw0G.js} +1 -1
- package/dist/client/assets/{dist-DQWPdnen.js → dist-oz2WOWPd.js} +1 -1
- package/dist/client/assets/{index-BJIfLtDs.css → index-6h_u-X47.css} +1 -1
- package/dist/client/assets/{index-DHsjaI7m.js → index-iSnWwgJ-.js} +26 -26
- package/dist/client/index.html +2 -2
- package/dist/server/application/conversation-service.d.ts +2 -1
- package/dist/server/application/conversation-service.js +25 -7
- package/dist/server/application/run-service.d.ts +15 -0
- package/dist/server/application/run-service.js +59 -1
- package/dist/server/application/session-orchestrator-service.d.ts +9 -1
- package/dist/server/application/session-orchestrator-service.js +79 -10
- package/dist/server/http-server.js +18 -11
- package/dist/server/runtime.js +7 -6
- package/dist/server/store.d.ts +12 -1
- package/dist/server/store.js +77 -4
- package/dist/server/task-tool.d.ts +4 -2
- package/dist/server/task-tool.js +6 -3
- package/dist/server/time-zone.d.ts +20 -0
- package/dist/server/time-zone.js +32 -0
- package/dist/server/types.d.ts +12 -0
- package/dist/server/workflow-engine.d.ts +11 -95
- package/dist/server/workflow-engine.js +122 -134
- package/dist/server/workflow.d.ts +73 -31
- package/dist/server/workflow.js +112 -30
- package/package.json +17 -2
|
@@ -1,32 +1,14 @@
|
|
|
1
|
-
import { attemptFromRun, composeStepPrompt, newWorkflowRunId, retriesFor, } from "./workflow.js";
|
|
1
|
+
import { attemptFromRun, composeStepPrompt, newWorkflowRunId, retriesFor, substituteWorkflowVariables, workflowUntilMet, WORKFLOW_DEFAULT_LOOP_MAX, } from "./workflow.js";
|
|
2
2
|
const now = () => new Date().toISOString();
|
|
3
3
|
export class WorkflowEngine {
|
|
4
4
|
recorder;
|
|
5
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
6
|
controllers = new Map();
|
|
7
|
+
owners = new Map();
|
|
15
8
|
constructor(recorder, hooks) {
|
|
16
9
|
this.recorder = recorder;
|
|
17
10
|
this.hooks = hooks;
|
|
18
11
|
}
|
|
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
12
|
stop(workflowId) {
|
|
31
13
|
const controller = this.controllers.get(workflowId);
|
|
32
14
|
if (!controller)
|
|
@@ -34,7 +16,6 @@ export class WorkflowEngine {
|
|
|
34
16
|
controller.abort();
|
|
35
17
|
return true;
|
|
36
18
|
}
|
|
37
|
-
/** Stops every plan in flight for one conversation, and returns how many were signalled. */
|
|
38
19
|
stopConversation(conversationId) {
|
|
39
20
|
let stopped = 0;
|
|
40
21
|
for (const [id, controller] of this.controllers) {
|
|
@@ -45,36 +26,17 @@ export class WorkflowEngine {
|
|
|
45
26
|
}
|
|
46
27
|
return stopped;
|
|
47
28
|
}
|
|
48
|
-
/** Aborts every plan in flight. Called on shutdown, so no child outlives the server that owns it. */
|
|
49
29
|
abortAll() {
|
|
50
30
|
for (const controller of this.controllers.values())
|
|
51
31
|
controller.abort();
|
|
52
32
|
}
|
|
53
|
-
/** Waits until aborted plans have committed their terminal state. */
|
|
54
33
|
async waitForIdle() {
|
|
55
|
-
while (this.controllers.size > 0)
|
|
34
|
+
while (this.controllers.size > 0)
|
|
56
35
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
57
|
-
}
|
|
58
36
|
}
|
|
59
|
-
/** The conversation a plan belongs to, which only this map knows while the plan is running. */
|
|
60
37
|
conversationOf(workflowId) {
|
|
61
38
|
return this.owners.get(workflowId);
|
|
62
39
|
}
|
|
63
|
-
/**
|
|
64
|
-
* Which conversation each in-flight plan belongs to.
|
|
65
|
-
*
|
|
66
|
-
* Held beside the controllers rather than read back from the store, because the store is the right
|
|
67
|
-
* place for what a person may read later and the wrong place for a synchronous lookup in a stop
|
|
68
|
-
* handler - it clones the entire state, and this runs on a click.
|
|
69
|
-
*/
|
|
70
|
-
owners = new Map();
|
|
71
|
-
/**
|
|
72
|
-
* Executes one plan to a terminal state, and returns the record as it ended.
|
|
73
|
-
*
|
|
74
|
-
* ALWAYS RESOLVES, like `dispatch` does and for the same reason: by the time the first await runs, the
|
|
75
|
-
* whole plan is already on disk, so the honest account of a plan that cannot proceed is a record that
|
|
76
|
-
* says why, rather than a rejection that leaves it claiming to be running until the next restart.
|
|
77
|
-
*/
|
|
78
40
|
async run(input) {
|
|
79
41
|
const id = newWorkflowRunId();
|
|
80
42
|
const controller = new AbortController();
|
|
@@ -84,63 +46,34 @@ export class WorkflowEngine {
|
|
|
84
46
|
controller.abort();
|
|
85
47
|
this.controllers.set(id, controller);
|
|
86
48
|
this.owners.set(id, input.conversationId);
|
|
87
|
-
|
|
88
|
-
// makes the record a claim the loop makes good rather than a log that fills in after the fact.
|
|
49
|
+
const definitions = input.plan.nodes ?? input.plan.steps.map((step) => ({ kind: "step", ...step }));
|
|
89
50
|
const plan = {
|
|
90
51
|
id,
|
|
91
52
|
conversationId: input.conversationId,
|
|
92
53
|
summary: input.plan.summary,
|
|
93
54
|
onFailure: input.plan.onFailure,
|
|
94
55
|
status: "running",
|
|
95
|
-
steps:
|
|
96
|
-
index,
|
|
97
|
-
agentId: step.agentId,
|
|
98
|
-
...(step.label ? { label: step.label } : {}),
|
|
99
|
-
prompt: step.prompt,
|
|
100
|
-
attempts: [],
|
|
101
|
-
status: "pending",
|
|
102
|
-
})),
|
|
56
|
+
steps: [],
|
|
103
57
|
currentIndex: 0,
|
|
104
58
|
startedAt: now(),
|
|
105
59
|
};
|
|
60
|
+
// The complete definition and every statically known iteration are persisted before the first dispatch.
|
|
61
|
+
// Loop rounds are materialized as they begin because their number depends on prior conclusion lines.
|
|
62
|
+
plan.nodes = this.materializeNodes(definitions, plan);
|
|
106
63
|
try {
|
|
107
|
-
// The INSERT, distinct from every later `commit`: this is the one write that makes the plan exist,
|
|
108
|
-
// and it happens before the loop rather than inside it. Separate from `commit` rather than folded
|
|
109
|
-
// into it because "insert if absent" is a different store operation with a different failure mode -
|
|
110
|
-
// a `commit` that upserted would let a plan start executing without ever having been written down.
|
|
111
64
|
const created = await this.hooks.store.beginWorkflow(plan);
|
|
112
65
|
this.hooks.broadcast({ type: "workflow.event", workflow: created });
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
// start the second one, and the signal that stopped it may have arrived while no child was out.
|
|
116
|
-
if (controller.signal.aborted)
|
|
117
|
-
break;
|
|
118
|
-
const halted = await this.runStep(plan, plan.steps[index], input, controller.signal);
|
|
119
|
-
if (halted)
|
|
120
|
-
break;
|
|
121
|
-
plan.currentIndex = index + 1;
|
|
122
|
-
await this.commit(plan);
|
|
123
|
-
}
|
|
124
|
-
// The terminal status is DERIVED, not set optimistically as the loop goes. A plan that finished all
|
|
125
|
-
// its steps including deliberate skips is `completed` - skips are the plan's own declared intent,
|
|
126
|
-
// not a shortfall. A plan that ran out of steps because one failed under a `stop` policy is `failed`.
|
|
127
|
-
//
|
|
128
|
-
// BEING HALTED OUTRANKS ALL OF IT, and it is keyed on a STEP that ended `stopped` rather than only
|
|
129
|
-
// on this engine's own signal - because a plan can be halted by a button that never touches the
|
|
130
|
-
// engine at all. Pressing stop on a step's OWN run card aborts the child through the recorder, which
|
|
131
|
-
// settles that run as `stopped`; the engine's controller is still perfectly un-aborted at that point.
|
|
132
|
-
// Keying only on the signal reported that plan as `failed`, which is a verdict nothing established:
|
|
133
|
-
// the work was not broken, a person stopped it. So the test is whether anything in the plan ended
|
|
134
|
-
// `stopped`, whoever did the stopping.
|
|
66
|
+
const history = [];
|
|
67
|
+
await this.runNodes(plan.nodes, plan, input, controller.signal, {}, history);
|
|
135
68
|
if (controller.signal.aborted || plan.steps.some((step) => step.status === "stopped")) {
|
|
136
|
-
this.haltRemaining(plan
|
|
69
|
+
this.haltRemaining(plan);
|
|
137
70
|
plan.status = "stopped";
|
|
138
71
|
}
|
|
139
72
|
else if (plan.steps.every((step) => step.status === "completed" || step.status === "skipped")) {
|
|
140
73
|
plan.status = "completed";
|
|
141
74
|
}
|
|
142
75
|
else {
|
|
143
|
-
this.haltRemaining(plan
|
|
76
|
+
this.haltRemaining(plan);
|
|
144
77
|
plan.status = "failed";
|
|
145
78
|
}
|
|
146
79
|
plan.finishedAt = now();
|
|
@@ -153,30 +86,116 @@ export class WorkflowEngine {
|
|
|
153
86
|
this.owners.delete(id);
|
|
154
87
|
}
|
|
155
88
|
}
|
|
156
|
-
/**
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
89
|
+
/** Allocates execution rows once per statically known occurrence; loop occurrences are allocated per round. */
|
|
90
|
+
materializeNodes(definitions, plan) {
|
|
91
|
+
return definitions.map((definition) => {
|
|
92
|
+
if (definition.kind === "step") {
|
|
93
|
+
const stepIndex = plan.steps.length;
|
|
94
|
+
plan.steps.push({
|
|
95
|
+
index: stepIndex,
|
|
96
|
+
agentId: definition.agentId,
|
|
97
|
+
...(definition.label ? { label: definition.label } : {}),
|
|
98
|
+
prompt: definition.prompt,
|
|
99
|
+
attempts: [],
|
|
100
|
+
status: "pending",
|
|
101
|
+
});
|
|
102
|
+
return { kind: "step", stepIndex };
|
|
103
|
+
}
|
|
104
|
+
if (definition.kind === "foreach") {
|
|
105
|
+
return {
|
|
106
|
+
kind: "foreach",
|
|
107
|
+
items: [...definition.items],
|
|
108
|
+
iterations: definition.items.map((item) => ({ item, nodes: this.materializeNodes(definition.do, plan) })),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
kind: "loop",
|
|
113
|
+
until: definition.until,
|
|
114
|
+
max: definition.max ?? WORKFLOW_DEFAULT_LOOP_MAX,
|
|
115
|
+
do: structuredClone(definition.do),
|
|
116
|
+
rounds: [],
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
async runNodes(nodes, plan, input, signal, variables, history) {
|
|
121
|
+
let output;
|
|
122
|
+
for (const node of nodes) {
|
|
123
|
+
if (signal.aborted)
|
|
124
|
+
return { halted: true, output };
|
|
125
|
+
let result;
|
|
126
|
+
if (node.kind === "step") {
|
|
127
|
+
const step = plan.steps[node.stepIndex];
|
|
128
|
+
result = await this.runStep(plan, step, input, signal, variables, history);
|
|
129
|
+
if (!result.halted) {
|
|
130
|
+
history.push(step);
|
|
131
|
+
plan.currentIndex = history.length;
|
|
132
|
+
await this.commit(plan);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
else if (node.kind === "loop") {
|
|
136
|
+
result = await this.runLoop(node, plan, input, signal, variables, history);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
result = await this.runForeach(node, plan, input, signal, variables, history);
|
|
140
|
+
}
|
|
141
|
+
// The last node's output replaces the earlier one even when it is absent; an empty final node must
|
|
142
|
+
// not accidentally satisfy `until` by exposing an older sibling's conclusion.
|
|
143
|
+
output = result.output;
|
|
144
|
+
if (result.halted)
|
|
145
|
+
return { halted: true, output };
|
|
146
|
+
}
|
|
147
|
+
return { halted: false, output };
|
|
148
|
+
}
|
|
149
|
+
async runLoop(node, plan, input, signal, variables, history) {
|
|
150
|
+
let last = "";
|
|
151
|
+
let output;
|
|
152
|
+
for (let n = 1; n <= node.max; n += 1) {
|
|
153
|
+
if (signal.aborted)
|
|
154
|
+
return { halted: true, output };
|
|
155
|
+
const round = { n, nodes: this.materializeNodes(node.do, plan), untilMet: false };
|
|
156
|
+
node.rounds.push(round);
|
|
157
|
+
await this.commit(plan);
|
|
158
|
+
const result = await this.runNodes(round.nodes, plan, input, signal, { ...variables, last }, history);
|
|
159
|
+
output = result.output;
|
|
160
|
+
if (output !== undefined)
|
|
161
|
+
round.output = output;
|
|
162
|
+
round.untilMet = !result.halted && workflowUntilMet(output, node.until);
|
|
163
|
+
await this.commit(plan);
|
|
164
|
+
if (result.halted)
|
|
165
|
+
return { halted: true, output };
|
|
166
|
+
if (round.untilMet)
|
|
167
|
+
return { halted: false, output };
|
|
168
|
+
last = output ?? "";
|
|
169
|
+
}
|
|
170
|
+
// Exhausting the declared bound is a completed bounded plan, with every round's untilMet=false visible.
|
|
171
|
+
return { halted: false, output };
|
|
172
|
+
}
|
|
173
|
+
async runForeach(node, plan, input, signal, variables, history) {
|
|
174
|
+
let output;
|
|
175
|
+
for (const iteration of node.iterations) {
|
|
176
|
+
if (signal.aborted)
|
|
177
|
+
return { halted: true, output };
|
|
178
|
+
const result = await this.runNodes(iteration.nodes, plan, input, signal, { ...variables, item: iteration.item }, history);
|
|
179
|
+
output = result.output;
|
|
180
|
+
if (result.halted)
|
|
181
|
+
return { halted: true, output };
|
|
182
|
+
}
|
|
183
|
+
return { halted: false, output };
|
|
184
|
+
}
|
|
185
|
+
async runStep(plan, step, input, signal, variables, history) {
|
|
164
186
|
const retries = retriesFor(plan.onFailure);
|
|
165
187
|
let attempt = 0;
|
|
166
188
|
while (attempt <= retries) {
|
|
167
189
|
if (signal.aborted) {
|
|
168
190
|
step.status = "stopped";
|
|
169
191
|
await this.commit(plan);
|
|
170
|
-
return true;
|
|
192
|
+
return { halted: true };
|
|
171
193
|
}
|
|
172
194
|
attempt += 1;
|
|
173
195
|
step.status = "running";
|
|
174
|
-
plan.currentIndex =
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
// burns the same sub-agent's answer twice and reports it as progress.
|
|
178
|
-
const composed = composeStepPrompt(step.prompt, plan.steps.slice(0, step.index));
|
|
179
|
-
// A retry receives the failed attempt's evidence, rather than repeating a blind prompt.
|
|
196
|
+
plan.currentIndex = history.length;
|
|
197
|
+
const prompt = substituteWorkflowVariables(step.prompt, variables);
|
|
198
|
+
const composed = composeStepPrompt(prompt, history);
|
|
180
199
|
const previousAttempt = step.attempts.at(-1);
|
|
181
200
|
if (previousAttempt) {
|
|
182
201
|
const evidence = [previousAttempt.error, previousAttempt.output].filter(Boolean).join("\n").slice(-8000);
|
|
@@ -189,77 +208,46 @@ export class WorkflowEngine {
|
|
|
189
208
|
agentId: step.agentId,
|
|
190
209
|
task: composed.prompt,
|
|
191
210
|
dispatchedBy: "model",
|
|
192
|
-
// The two fields that were carried in every run record from the start and read by nothing until
|
|
193
|
-
// now. A step's run therefore names the plan it belongs to and the label the model gave it, which
|
|
194
|
-
// is what lets the timeline link a card back to its step without any new field on `SubagentRun`.
|
|
195
211
|
loopId: plan.id,
|
|
196
212
|
role: step.label,
|
|
197
213
|
model: input.model,
|
|
198
214
|
denyTools: input.denyTools,
|
|
199
215
|
signal,
|
|
200
216
|
});
|
|
201
|
-
// Appended, never assigned over: the attempt that failed is exactly what makes the retry legible,
|
|
202
|
-
// and it is the record a person reads when a verifier failed twice.
|
|
203
217
|
step.attempts.push(attemptFromRun(run, attempt));
|
|
218
|
+
const output = step.attempts.at(-1)?.output;
|
|
204
219
|
if (run.status === "completed") {
|
|
205
220
|
step.status = "completed";
|
|
206
221
|
await this.commit(plan);
|
|
207
|
-
return false;
|
|
222
|
+
return { halted: false, output };
|
|
208
223
|
}
|
|
209
224
|
if (run.status === "stopped") {
|
|
210
225
|
step.status = "stopped";
|
|
211
226
|
await this.commit(plan);
|
|
212
|
-
return true;
|
|
227
|
+
return { halted: true, output };
|
|
213
228
|
}
|
|
214
|
-
// Failed. The policy decides whether this step is tried again, walked past, or ends the plan.
|
|
215
229
|
if (attempt <= retries) {
|
|
216
230
|
await this.commit(plan);
|
|
217
231
|
continue;
|
|
218
232
|
}
|
|
219
233
|
if (plan.onFailure === "skip") {
|
|
220
|
-
// Marked `skipped` rather than `failed`, because the two mean different things to what comes
|
|
221
|
-
// after: a skipped step contributed no output and says so in the next step's handoff block, while
|
|
222
|
-
// a failed one contributed output that failed. Both are visible; conflating them would lose which.
|
|
223
234
|
step.status = "skipped";
|
|
224
235
|
await this.commit(plan);
|
|
225
|
-
return false;
|
|
236
|
+
return { halted: false, output };
|
|
226
237
|
}
|
|
227
238
|
step.status = "failed";
|
|
228
239
|
await this.commit(plan);
|
|
229
|
-
return true;
|
|
240
|
+
return { halted: true, output };
|
|
230
241
|
}
|
|
231
|
-
// Unreachable: the loop returns on every path that sets a terminal step status, and the bound is
|
|
232
|
-
// checked on entry. Present so a future edit that adds a path fails to compile rather than silently
|
|
233
|
-
// leaving a step `running` with no plan status to match it.
|
|
234
242
|
step.status = "failed";
|
|
235
|
-
return true;
|
|
243
|
+
return { halted: true };
|
|
236
244
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
*
|
|
240
|
-
* LEAVING THEM `pending` WOULD BE A LIE IN THE SPECIFIC DIRECTION THAT MATTERS: a browser renders
|
|
241
|
-
* "未开始" for a pending step, so a plan that died after step one of three would show three steps with
|
|
242
|
-
* two still waiting, and a person would reasonably expect the rest to happen. They will not.
|
|
243
|
-
*/
|
|
244
|
-
haltRemaining(plan, from) {
|
|
245
|
-
for (const step of plan.steps.slice(from))
|
|
245
|
+
haltRemaining(plan) {
|
|
246
|
+
for (const step of plan.steps)
|
|
246
247
|
if (step.status === "pending" || step.status === "running")
|
|
247
248
|
step.status = "stopped";
|
|
248
249
|
}
|
|
249
|
-
/**
|
|
250
|
-
* Persists a plan, THEN announces it.
|
|
251
|
-
*
|
|
252
|
-
* PERSIST FIRST, ANNOUNCE SECOND, and the order is load-bearing here for exactly the reason it is in
|
|
253
|
-
* `SubagentRunRecorder.settle`: a browser that is told a step finished while `state.json` still says it
|
|
254
|
-
* is running will show a timeline a reload immediately contradicts, and the startup reconciliation that
|
|
255
|
-
* would fix it runs on a SERVER restart, not a page refresh.
|
|
256
|
-
*
|
|
257
|
-
* Unlike the recorder, this AWAITS both halves rather than writing fire-and-forget, and that is not an
|
|
258
|
-
* inconsistency. The recorder cannot await: it publishes on every text delta, and a drain loop that
|
|
259
|
-
* flushed to disk per token would throttle the child it is streaming. This publishes twice per step -
|
|
260
|
-
* at most sixteen times for a whole plan - so awaiting costs nothing measurable and buys the strong
|
|
261
|
-
* version of the guarantee.
|
|
262
|
-
*/
|
|
250
|
+
/** Persist first, announce second, so a reload never contradicts an event already shown. */
|
|
263
251
|
async commit(plan) {
|
|
264
252
|
const persisted = await this.hooks.store.setWorkflow(plan);
|
|
265
253
|
this.hooks.broadcast({ type: "workflow.event", workflow: persisted });
|
|
@@ -1,37 +1,14 @@
|
|
|
1
1
|
import type { SubagentRosterEntry } from "./task-tool.js";
|
|
2
2
|
import type { SubagentRun } from "./types.js";
|
|
3
3
|
/**
|
|
4
|
-
* The workflow PLAN:
|
|
5
|
-
*
|
|
4
|
+
* The workflow PLAN: a tree of steps, bounded loops and primary-supplied foreach iterations. The server
|
|
5
|
+
* executes the tree; it never asks a sub-agent to choose the control flow. Step outputs are still carried
|
|
6
|
+
* into later prompts, and every rule in this file remains pure so validation and conclusion matching can
|
|
7
|
+
* be tested without spawning a process.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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.
|
|
9
|
+
* The dispatch and persistence seams are the existing ones: `task-tool.ts` supplies the roster,
|
|
10
|
+
* `SubagentRunRecorder.dispatch` settles every child, and `WorkflowAttempt` is the append-only artefact.
|
|
11
|
+
* There is deliberately no graph, expression language, server-side item discovery, or second artefact store.
|
|
35
12
|
*/
|
|
36
13
|
/**
|
|
37
14
|
* The id an orchestrated plan answers to.
|
|
@@ -54,6 +31,9 @@ export declare const WORKFLOW_ID_PATTERN: RegExp;
|
|
|
54
31
|
* one it would hit by accident.
|
|
55
32
|
*/
|
|
56
33
|
export declare const WORKFLOW_MAX_STEPS = 8;
|
|
34
|
+
/** A loop always has a finite bound. The primary may lower it or explicitly raise it to this hard cap. */
|
|
35
|
+
export declare const WORKFLOW_DEFAULT_LOOP_MAX = 8;
|
|
36
|
+
export declare const WORKFLOW_MAX_LOOP_ROUNDS = 100;
|
|
57
37
|
/**
|
|
58
38
|
* What happens to a step that failed.
|
|
59
39
|
*
|
|
@@ -130,12 +110,44 @@ export type WorkflowRun = {
|
|
|
130
110
|
summary: string;
|
|
131
111
|
onFailure: WorkflowFailurePolicy;
|
|
132
112
|
status: WorkflowStatus;
|
|
113
|
+
/** Execution rows. The tree below references these by index rather than duplicating attempts and output. */
|
|
133
114
|
steps: WorkflowStep[];
|
|
134
|
-
/**
|
|
115
|
+
/** Optional only for records written before recursive plans existed. */
|
|
116
|
+
nodes?: WorkflowRunNode[];
|
|
117
|
+
/** Number of settled execution rows; while running, the row currently executing or next. */
|
|
135
118
|
currentIndex: number;
|
|
136
119
|
startedAt: string;
|
|
137
120
|
finishedAt?: string;
|
|
138
121
|
};
|
|
122
|
+
export type WorkflowStepRef = {
|
|
123
|
+
kind: "step";
|
|
124
|
+
stepIndex: number;
|
|
125
|
+
};
|
|
126
|
+
export type WorkflowLoopRound = {
|
|
127
|
+
n: number;
|
|
128
|
+
nodes: WorkflowRunNode[];
|
|
129
|
+
/** The round's last node output. Missing output can never satisfy `until`. */
|
|
130
|
+
output?: string;
|
|
131
|
+
untilMet: boolean;
|
|
132
|
+
};
|
|
133
|
+
export type WorkflowLoopRun = {
|
|
134
|
+
kind: "loop";
|
|
135
|
+
until: string;
|
|
136
|
+
max: number;
|
|
137
|
+
/** The primary's body definition, retained so future rounds survive a reload with the whole plan. */
|
|
138
|
+
do: WorkflowPlanNode[];
|
|
139
|
+
rounds: WorkflowLoopRound[];
|
|
140
|
+
};
|
|
141
|
+
export type WorkflowForeachIteration = {
|
|
142
|
+
item: string;
|
|
143
|
+
nodes: WorkflowRunNode[];
|
|
144
|
+
};
|
|
145
|
+
export type WorkflowForeachRun = {
|
|
146
|
+
kind: "foreach";
|
|
147
|
+
items: string[];
|
|
148
|
+
iterations: WorkflowForeachIteration[];
|
|
149
|
+
};
|
|
150
|
+
export type WorkflowRunNode = WorkflowStepRef | WorkflowLoopRun | WorkflowForeachRun;
|
|
139
151
|
/** One step as the primary agent is told about it: the last attempt, not the whole history. */
|
|
140
152
|
export type WorkflowStepSummary = {
|
|
141
153
|
n: number;
|
|
@@ -165,15 +177,36 @@ export type WorkflowSummary = {
|
|
|
165
177
|
steps: WorkflowStepSummary[];
|
|
166
178
|
finishedAt?: string;
|
|
167
179
|
};
|
|
180
|
+
export type WorkflowStepPlan = {
|
|
181
|
+
kind: "step";
|
|
182
|
+
agentId: string;
|
|
183
|
+
label?: string;
|
|
184
|
+
prompt: string;
|
|
185
|
+
};
|
|
186
|
+
export type WorkflowLoopPlan = {
|
|
187
|
+
kind: "loop";
|
|
188
|
+
until: string;
|
|
189
|
+
max?: number;
|
|
190
|
+
do: WorkflowPlanNode[];
|
|
191
|
+
};
|
|
192
|
+
export type WorkflowForeachPlan = {
|
|
193
|
+
kind: "foreach";
|
|
194
|
+
items: string[];
|
|
195
|
+
do: WorkflowPlanNode[];
|
|
196
|
+
};
|
|
197
|
+
export type WorkflowPlanNode = WorkflowStepPlan | WorkflowLoopPlan | WorkflowForeachPlan;
|
|
168
198
|
/** The plan shape the engine executes, after validation. What `validateWorkflowPlan` returns on success. */
|
|
169
199
|
export type WorkflowPlan = {
|
|
170
200
|
summary: string;
|
|
171
201
|
onFailure: WorkflowFailurePolicy;
|
|
202
|
+
/** Legacy input, and a flat declaration index for recursive input. Runtime executions live on WorkflowRun. */
|
|
172
203
|
steps: {
|
|
173
204
|
agentId: string;
|
|
174
205
|
label?: string;
|
|
175
206
|
prompt: string;
|
|
176
207
|
}[];
|
|
208
|
+
/** Present when the primary used the recursive spelling. Legacy steps are normalized by the engine. */
|
|
209
|
+
nodes?: WorkflowPlanNode[];
|
|
177
210
|
};
|
|
178
211
|
/**
|
|
179
212
|
* Text that had to lose its front, plus what it lost and why.
|
|
@@ -219,6 +252,15 @@ export declare const WORKFLOW_CONTEXT_BUDGET = 32000;
|
|
|
219
252
|
export declare const PREVIOUS_TOKEN = "{{previous}}";
|
|
220
253
|
/** The token that hands a step every prior step's output, with nothing prepended. */
|
|
221
254
|
export declare const HISTORY_TOKEN = "{{history}}";
|
|
255
|
+
export declare const ITEM_TOKEN = "{{item}}";
|
|
256
|
+
export declare const LAST_TOKEN = "{{last}}";
|
|
257
|
+
/** Single-pass replacement: token-looking text inside an item or prior output is data, not another expression. */
|
|
258
|
+
export declare function substituteWorkflowVariables(prompt: string, variables: {
|
|
259
|
+
item?: string;
|
|
260
|
+
last?: string;
|
|
261
|
+
}): string;
|
|
262
|
+
/** Exact whole-line matching against the final line. Empty or missing output is false, never an error. */
|
|
263
|
+
export declare function workflowUntilMet(output: string | undefined, until: string): boolean;
|
|
222
264
|
/** What a composed prompt ended up carrying, so a caller can report it without re-parsing the string. */
|
|
223
265
|
export type ComposedPrompt = {
|
|
224
266
|
prompt: string;
|