@evandu/super-pi 0.1.142 → 0.1.144

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.
Files changed (47) hide show
  1. package/agents/super-pi-task/core.ts +55 -27
  2. package/agents/super-pi-task/index.ts +76 -4
  3. package/dist/.super-pi-check-inputs.json +2 -2
  4. package/dist/client/.super-pi-client-sources.json +7 -7
  5. package/dist/client/assets/{CodeMirrorEditor-iLUdQy9q.js → CodeMirrorEditor-DfQHX1Hy.js} +2 -2
  6. package/dist/client/assets/{dist-Bt8G7e7e.js → dist-B8oH6dsc.js} +1 -1
  7. package/dist/client/assets/{dist-DbLEngKN.js → dist-BCLhQXYY.js} +1 -1
  8. package/dist/client/assets/{dist-DBAoCOnn.js → dist-BEbEv2KM.js} +1 -1
  9. package/dist/client/assets/{dist-Dxq1s4j3.js → dist-BGBinEie.js} +1 -1
  10. package/dist/client/assets/{dist-CWBm3q4a.js → dist-BRjyQs_L.js} +1 -1
  11. package/dist/client/assets/{dist-JuLMNOUt.js → dist-BS-FBUKW.js} +1 -1
  12. package/dist/client/assets/{dist-Cfa_e2VZ.js → dist-BsMikSTM.js} +1 -1
  13. package/dist/client/assets/{dist-0dCNvYEE.js → dist-BuPimjY-.js} +1 -1
  14. package/dist/client/assets/{dist-rIYPNzbY.js → dist-C5BQsmB7.js} +1 -1
  15. package/dist/client/assets/{dist-B8FbKhnf.js → dist-CDtFD-PW.js} +1 -1
  16. package/dist/client/assets/{dist-ic-MopV2.js → dist-CgQ7zaWY.js} +1 -1
  17. package/dist/client/assets/{dist-F634gv7V.js → dist-Cu3B0JQX.js} +1 -1
  18. package/dist/client/assets/{dist-Bj2C-QqM.js → dist-Cw_KCDH9.js} +1 -1
  19. package/dist/client/assets/{dist-BA6EsWzo.js → dist-D7aAMRTb.js} +1 -1
  20. package/dist/client/assets/{dist-B4JqBNQI.js → dist-DKkMk3XX.js} +1 -1
  21. package/dist/client/assets/{dist-CoTJns5x.js → dist-DOvzruAQ.js} +1 -1
  22. package/dist/client/assets/{dist-Nt-BWmBB.js → dist-Dnru8_uX.js} +1 -1
  23. package/dist/client/assets/{dist-DHyWJ9kA.js → dist-ISoxXlmW.js} +1 -1
  24. package/dist/client/assets/{dist-DEUl3Yqk.js → dist-KQJCVwxy.js} +1 -1
  25. package/dist/client/assets/{dist-Dq6y_nK3.js → dist-LRNzDUVb.js} +1 -1
  26. package/dist/client/assets/{dist-CtCiGxne.js → dist-SzZEAk24.js} +1 -1
  27. package/dist/client/assets/{dist-Df9zcCXs.js → dist-qAUj_2Ue.js} +1 -1
  28. package/dist/client/assets/{index-CX5gWDve.js → index-BLWr70ej.js} +22 -22
  29. package/dist/client/assets/{index-DMhigTiY.css → index-Xaui0uUI.css} +1 -1
  30. package/dist/client/index.html +2 -2
  31. package/dist/server/application/run-service.d.ts +15 -0
  32. package/dist/server/application/run-service.js +59 -1
  33. package/dist/server/application/session-orchestrator-service.d.ts +9 -1
  34. package/dist/server/application/session-orchestrator-service.js +79 -10
  35. package/dist/server/http-server.js +18 -11
  36. package/dist/server/runtime.js +7 -6
  37. package/dist/server/store.js +7 -1
  38. package/dist/server/task-tool.d.ts +4 -2
  39. package/dist/server/task-tool.js +6 -3
  40. package/dist/server/time-zone.d.ts +20 -0
  41. package/dist/server/time-zone.js +32 -0
  42. package/dist/server/types.d.ts +9 -0
  43. package/dist/server/workflow-engine.d.ts +11 -95
  44. package/dist/server/workflow-engine.js +122 -134
  45. package/dist/server/workflow.d.ts +73 -31
  46. package/dist/server/workflow.js +112 -30
  47. package/package.json +331 -325
@@ -3,129 +3,45 @@ import type { Store } from "./store.js";
3
3
  import type { ModelSelection, ServerEvent } from "./types.js";
4
4
  import { type WorkflowPlan, type WorkflowRun } from "./workflow.js";
5
5
  /**
6
- * Walking an orchestrated plan: the half of the feature that DECIDES anything.
7
- *
8
- * WHY THIS FILE HOLDS NO SPAWNING, NO STREAMING AND NO PROCESS TEARDOWN. Every one of those already
9
- * exists and is already proved - `subagents.ts` spawns and drains, `subagent-runs.ts` records and
10
- * publishes. The engine's whole job is to call `dispatch` in the right ORDER and decide what to do when
11
- * a step does not succeed, and `SubagentRunRecorder.dispatch` is the seam that makes that possible: it
12
- * ALWAYS RESOLVES with a settled record, including for a dispatch that never started (`subagent-runs.ts:159`).
13
- * So an engine step is `await` one call, read `status` off the record, and move on. There is no error
14
- * parsing here, no exception handling around a spawn, and no way for a child to be left running because
15
- * the thing above it threw - which is the entire reason this feature is small.
16
- *
17
- * WHAT THE ENGINE IS NOT ALLOWED TO DO, stated as the two rules it is built around:
18
- *
19
- * 1. IT DOES NOT DECIDE WHAT A STEP MEANS. `role` is written through to the run record and read by
20
- * nobody in this process. A step that says "verify" and a step that says "look at it" execute the same
21
- * way. The rules about who does what live in `agents/*.md` and in the model that wrote the plan, not
22
- * in a dispatcher holding a table of role names.
23
- * 2. IT DOES NOT JUDGE RESULTS. A `verifier` step that completes with 结论:失败 has SUCCEEDED, as far as
24
- * this loop is concerned, and the plan advances. The verdict lives in the text, and the component that
25
- * can read the text - the primary agent - is what decides whether to run another round. A server that
26
- * parsed 通过/失败 would be guessing at a convention `verifier.md` states in prose and `scout.md` and
27
- * `critic.md` do not have at all, and would have no correct answer when the text said neither.
6
+ * Recursive descent over the primary's plan. This engine owns execution order and the mechanical `until`
7
+ * comparison; it does not infer tasks, discover foreach items, or ask a child whether another round should
8
+ * run. Spawning, streaming and teardown remain behind `SubagentRunRecorder.dispatch`, which always resolves
9
+ * with a settled record.
28
10
  */
29
- /** What the engine needs from the server around it. Injected, so this file holds no globals. */
30
11
  export type WorkflowEngineHooks = {
31
12
  store: Store;
32
- /** Publishes one event to whoever is listening. A plain function, so a test can pass an array. */
33
13
  broadcast: (event: ServerEvent) => void;
34
14
  };
35
- /** Everything one plan needs to run. The plan itself arrives already validated and normalized. */
36
15
  export type WorkflowRunInput = {
37
16
  conversationId: string;
38
- /** The dispatching conversation's workspace, resolved by the caller. Never defaulted to the server's own directory. */
39
17
  cwd: string;
40
18
  plan: WorkflowPlan;
41
19
  model?: ModelSelection;
42
- /** Tools the primary agent denied, inherited by every step, exactly as a single dispatch inherits them. */
43
20
  denyTools?: readonly string[];
44
- /** Cancels the whole plan. Each step is dispatched under it, so one signal reaches the loop and the child. */
45
21
  signal?: AbortSignal;
46
22
  };
47
- /** The outcome of a plan, as the endpoint reports it. Always produced, never thrown. */
48
23
  export type WorkflowOutcome = {
49
24
  workflow: WorkflowRun;
50
25
  };
51
26
  export declare class WorkflowEngine {
52
27
  private readonly recorder;
53
28
  private readonly hooks;
54
- /**
55
- * One controller per in-flight plan.
56
- *
57
- * NEEDED SEPARATELY FROM THE RECORDER'S, and the difference is what stops a plan between steps. The
58
- * recorder's map only holds runs that are executing right now, so a plan halted by a signal while no
59
- * child is out would have nothing to abort and would happily start the next one. This map holds the
60
- * PLAN, which exists for its whole length, so `stop` reaches it in every phase.
61
- */
62
29
  private readonly controllers;
30
+ private readonly owners;
63
31
  constructor(recorder: SubagentRunRecorder, hooks: WorkflowEngineHooks);
64
- /**
65
- * Stops ONE plan, and says whether there was anything to stop.
66
- *
67
- * `false` for an unknown or already-finished id is deliberate and matches `SubagentRunRecorder.stop`:
68
- * the click that produces it raced the plan's own terminal event, and an error reply would put a
69
- * failure banner on a timeline that says it finished normally.
70
- *
71
- * The abort reaches the child too, because every step is dispatched under this controller's signal and
72
- * `dispatch` turns it into the same abort-then-killTree the recorder uses. Stopping a plan really does
73
- * stop the process it is on.
74
- */
75
32
  stop(workflowId: string): boolean;
76
- /** Stops every plan in flight for one conversation, and returns how many were signalled. */
77
33
  stopConversation(conversationId: string): number;
78
- /** Aborts every plan in flight. Called on shutdown, so no child outlives the server that owns it. */
79
34
  abortAll(): void;
80
- /** Waits until aborted plans have committed their terminal state. */
81
35
  waitForIdle(): Promise<void>;
82
- /** The conversation a plan belongs to, which only this map knows while the plan is running. */
83
36
  private conversationOf;
84
- /**
85
- * Which conversation each in-flight plan belongs to.
86
- *
87
- * Held beside the controllers rather than read back from the store, because the store is the right
88
- * place for what a person may read later and the wrong place for a synchronous lookup in a stop
89
- * handler - it clones the entire state, and this runs on a click.
90
- */
91
- private readonly owners;
92
- /**
93
- * Executes one plan to a terminal state, and returns the record as it ended.
94
- *
95
- * ALWAYS RESOLVES, like `dispatch` does and for the same reason: by the time the first await runs, the
96
- * whole plan is already on disk, so the honest account of a plan that cannot proceed is a record that
97
- * says why, rather than a rejection that leaves it claiming to be running until the next restart.
98
- */
99
37
  run(input: WorkflowRunInput): Promise<WorkflowOutcome>;
100
- /**
101
- * Runs one step until it succeeds, is skipped, or stops the plan. Returns whether the PLAN should halt.
102
- *
103
- * The retry is a loop rather than a recursive call so the bound in the policy is the loop's own
104
- * condition and cannot be exceeded by a bug in the call structure - the one property a retry policy has
105
- * to have absolutely.
106
- */
38
+ /** Allocates execution rows once per statically known occurrence; loop occurrences are allocated per round. */
39
+ private materializeNodes;
40
+ private runNodes;
41
+ private runLoop;
42
+ private runForeach;
107
43
  private runStep;
108
- /**
109
- * Marks every step from `from` onward as stopped, because none of them will run.
110
- *
111
- * LEAVING THEM `pending` WOULD BE A LIE IN THE SPECIFIC DIRECTION THAT MATTERS: a browser renders
112
- * "未开始" for a pending step, so a plan that died after step one of three would show three steps with
113
- * two still waiting, and a person would reasonably expect the rest to happen. They will not.
114
- */
115
44
  private haltRemaining;
116
- /**
117
- * Persists a plan, THEN announces it.
118
- *
119
- * PERSIST FIRST, ANNOUNCE SECOND, and the order is load-bearing here for exactly the reason it is in
120
- * `SubagentRunRecorder.settle`: a browser that is told a step finished while `state.json` still says it
121
- * is running will show a timeline a reload immediately contradicts, and the startup reconciliation that
122
- * would fix it runs on a SERVER restart, not a page refresh.
123
- *
124
- * Unlike the recorder, this AWAITS both halves rather than writing fire-and-forget, and that is not an
125
- * inconsistency. The recorder cannot await: it publishes on every text delta, and a drain loop that
126
- * flushed to disk per token would throttle the child it is streaming. This publishes twice per step -
127
- * at most sixteen times for a whole plan - so awaiting costs nothing measurable and buys the strong
128
- * version of the guarantee.
129
- */
45
+ /** Persist first, announce second, so a reload never contradicts an event already shown. */
130
46
  private commit;
131
47
  }
@@ -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
- // THE WHOLE PLAN, IN FULL, BEFORE STEP ONE RUNS. See `Store.beginWorkflow`: this is the write that
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: input.plan.steps.map((step, index) => ({
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
- for (let index = 0; index < plan.steps.length; index += 1) {
114
- // Checked BEFORE each step, not only after one: a plan stopped between two children must not
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, plan.currentIndex);
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, plan.currentIndex);
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
- * Runs one step until it succeeds, is skipped, or stops the plan. Returns whether the PLAN should halt.
158
- *
159
- * The retry is a loop rather than a recursive call so the bound in the policy is the loop's own
160
- * condition and cannot be exceeded by a bug in the call structure - the one property a retry policy has
161
- * to have absolutely.
162
- */
163
- async runStep(plan, step, input, signal) {
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 = step.index;
175
- // Recomposed EVERY attempt, not once before the loop. A retry exists because the first answer was
176
- // not acceptable, so handing the second attempt the same inputs would make the policy a no-op that
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
- * Marks every step from `from` onward as stopped, because none of them will run.
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 });