@evandu/super-pi 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,387 @@
1
+ import { randomUUID } from "node:crypto";
2
+ /**
3
+ * The workflow PLAN: an ordered list of sub-agent steps the server will execute on the primary agent's
4
+ * behalf, carrying each step's output into the next one.
5
+ *
6
+ * WHY THIS FILE EXISTS, and why it holds no I/O at all.
7
+ *
8
+ * The dispatch pipe was already built and already proved: `task-tool.ts` is the policy half of the mouth,
9
+ * `subagent-runs.ts` records and publishes a run, `subagents.ts` spawns and drains a child. What was
10
+ * missing was that a MODEL, given a request that needs three different specialists in a row, had to
11
+ * remember the order itself and hand-copy each result into the next prompt by hand. That is the
12
+ * orchestration gap - and the reason the seam here is pure is the same reason `task-tool.ts` is: every
13
+ * rule in this file can then be proved without spawning a process, which is the only layer where a
14
+ * plan's shape, its refusals and its truncation budgets are cheap to test exhaustively.
15
+ *
16
+ * WHAT IS BORROWED FROM OPENCODE, and what is not: nothing. That is a deliberate departure from the
17
+ * sibling files, which each cite upstream lines they ported. OpenCode has no multi-sub-agent pipeline
18
+ * primitive to port - it composes `task` calls the same way this project did, by model convention, which
19
+ * is the gap. What this file reuses instead is local: the roster filter from `task-tool.ts` (the same one
20
+ * that decides who may be dispatched), and the `SubagentRunRecorder.dispatch` seam, which always resolves
21
+ * with a settled record and therefore needs no error parsing here.
22
+ *
23
+ * WHAT THIS FILE DELIBERATELY DOES NOT MODEL, because `docs/AGENT_LOOP_DESIGN.md` proposed all of it and
24
+ * each one is a second orchestration authority rather than a capability:
25
+ *
26
+ * - BRANCHES AND EDGES. A declared graph makes the model learn a language to express sequencing it can
27
+ * already express by dispatching twice. It also cannot be expressive enough, and a model that finds it
28
+ * too rigid bypasses it, leaving two ways to orchestrate that behave differently.
29
+ * - LOOP-BACK. `verifier.md` already ends its report with `通过`/`失败`, so the model can read a failed
30
+ * step and decide whether to run another round. That judgement is the thing worth keeping on the side
31
+ * that can read the output; encoding it as an edge would move it to a component that never sees the text.
32
+ * - A SEPARATE ARTIFACT STORE. `WorkflowAttempt` below IS the versioned artefact: one attempt's output is
33
+ * written once and never edited, and the next attempt appends beside it rather than replacing it.
34
+ */
35
+ /**
36
+ * The id an orchestrated plan answers to.
37
+ *
38
+ * Prefixed and uuid-shaped for the same reason run ids are: the string is a lookup key in `state.json`
39
+ * and appears in `data-*` attributes in the browser, and a shape a person can recognise in a log is worth
40
+ * more than a bare uuid. `engine.ts` validates caller-supplied ids against this prefix the way
41
+ * `subagents.ts:358` validates run ids, because the same string becomes a reconciliation key at startup.
42
+ */
43
+ export const newWorkflowRunId = () => `wf_${randomUUID()}`;
44
+ /** Matches the shape `newWorkflowRunId` produces, for validating a caller-supplied id. */
45
+ export const WORKFLOW_ID_PATTERN = /^wf_[A-Za-z0-9_-]{1,120}$/;
46
+ /**
47
+ * How many steps one plan may hold.
48
+ *
49
+ * A bound, because every step is a real Pi process that runs for minutes and every step's output is
50
+ * carried forward: the cost of a plan grows with both its length and the size of what it accumulates.
51
+ * Eight steps is more than the pipelines this install's four sub-agents are built for (`scout` to
52
+ * `patcher` to `verifier`, maybe twice), so the bound is one a real request would not reach rather than
53
+ * one it would hit by accident.
54
+ */
55
+ export const WORKFLOW_MAX_STEPS = 8;
56
+ /** Every accepted policy, in the order the refusal message lists them for a model that guesses wrong. */
57
+ export const WORKFLOW_FAILURE_POLICIES = ["stop", "skip", "retry:1", "retry:2", "retry:3"];
58
+ /**
59
+ * Reads a failure policy, or `undefined` for anything that is not one.
60
+ *
61
+ * `retry` alone normalizes to `retry:1`: a model that reaches for retry almost always means "try it
62
+ * again once", and refusing over a missing count would cost a retry of the plan itself. Everything else
63
+ * is refused rather than coerced, because guessing `stop` for a model that asked for `parallel` would
64
+ * silently run a different plan than the one it wrote.
65
+ */
66
+ export function parseFailurePolicy(raw) {
67
+ if (typeof raw !== "string")
68
+ return undefined;
69
+ const text = raw.trim().toLowerCase();
70
+ if (text === "retry")
71
+ return "retry:1";
72
+ return WORKFLOW_FAILURE_POLICIES.find((policy) => policy === text);
73
+ }
74
+ /** How many times a policy re-runs a failing step. `stop` and `skip` both run it zero further times. */
75
+ export function retriesFor(policy) {
76
+ const match = /^retry:([123])$/.exec(policy);
77
+ return match ? Number.parseInt(match[1], 10) : 0;
78
+ }
79
+ /**
80
+ * Caps a block of text from the FRONT, and says in the surviving text that something was dropped.
81
+ *
82
+ * THE TAIL IS WHAT SURVIVES, and that is not an arbitrary choice. Every sub-agent in `agents/` is written
83
+ * to put its conclusion last - `verifier.md` says its last line is `通过`/`失败`/`无法验证` and nothing
84
+ * else - so keeping the end of a report keeps the part the next step and the human both need. Cutting
85
+ * from the end would keep the preamble and discard the verdict, which is the one line that matters.
86
+ */
87
+ export function capText(text, budget, what) {
88
+ if (budget <= 0)
89
+ return { text: "", truncated: text.length > 0, droppedChars: text.length };
90
+ if (text.length <= budget)
91
+ return { text, truncated: false, droppedChars: 0 };
92
+ const dropped = text.length - budget;
93
+ return {
94
+ text: `…(前面 ${dropped} 个字符的${what}已省略,完整内容在 Super Pi 的运行记录里)\n${text.slice(text.length - budget)}`,
95
+ truncated: true,
96
+ droppedChars: dropped,
97
+ };
98
+ }
99
+ /** The word a PERSON reads beside a status. Colour is never the only signal, so this is not optional decoration. */
100
+ const STEP_STATUS_WORD = {
101
+ pending: "未开始",
102
+ running: "运行中",
103
+ completed: "已完成",
104
+ failed: "失败",
105
+ skipped: "已跳过",
106
+ stopped: "已停止",
107
+ };
108
+ /** As above, for a whole plan. `failed` and `stopped` differ: one ran out of steps, the other was halted. */
109
+ const WORKFLOW_STATUS_WORD = {
110
+ running: "运行中",
111
+ completed: "全部完成",
112
+ failed: "中途失败",
113
+ stopped: "已停止",
114
+ };
115
+ export const stepStatusWord = (status) => STEP_STATUS_WORD[status] ?? status;
116
+ export const workflowStatusWord = (status) => WORKFLOW_STATUS_WORD[status] ?? status;
117
+ /**
118
+ * Per-step cap on how much of one prior output is carried into the next step's prompt.
119
+ *
120
+ * Eight KB is roughly two thousand tokens of Chinese prose or a little more of code, which is enough for
121
+ * the conclusion-plus-evidence shape every sub-agent in `agents/` is instructed to produce, and small
122
+ * enough that eight of them plus the plan itself stay inside a normal model's working context.
123
+ */
124
+ export const STEP_OUTPUT_BUDGET = 8_000;
125
+ /**
126
+ * Total cap across all carried-forward output in one step's prompt.
127
+ *
128
+ * Separate from the per-step cap because the per-step one alone does not bound anything: eight steps
129
+ * carrying 8 KB each is 64 KB, which is larger than the whole context most sub-agents run with. The
130
+ * budget is spent newest-first, so the immediately preceding step - the one whose conclusion the current
131
+ * step is most likely acting on - is never the one that loses characters.
132
+ */
133
+ export const WORKFLOW_CONTEXT_BUDGET = 32_000;
134
+ /** The token that hands a step exactly the previous step's output, with nothing prepended. */
135
+ export const PREVIOUS_TOKEN = "{{previous}}";
136
+ /** The token that hands a step every prior step's output, with nothing prepended. */
137
+ export const HISTORY_TOKEN = "{{history}}";
138
+ /**
139
+ * Renders one prior step as a block, or as a sentence when it has no output to hand on.
140
+ *
141
+ * A SKIPPED STEP IS SPOKEN, NOT OMITTED. Silence about a step that never ran would leave the next
142
+ * sub-agent - which has a fresh context and cannot see the plan - working from a gap it has no way to
143
+ * know about. So a skipped step contributes a line saying it was skipped, and contributes no output.
144
+ */
145
+ function renderPriorStep(step, output) {
146
+ const heading = `### 第 ${step.index + 1} 步 · ${step.label?.trim() || step.agentId}(${step.agentId})— ${stepStatusWord(step.status)}`;
147
+ if (step.status === "skipped")
148
+ return `${heading}\n(这一步按编排声明的失败策略被跳过了,没有产出。)`;
149
+ if (!output?.trim())
150
+ return `${heading}\n(这一步没有留下文字输出。)`;
151
+ return `${heading}\n${output}`;
152
+ }
153
+ /**
154
+ * Composes the prompt one step actually runs with, carrying earlier results into it.
155
+ *
156
+ * THE DEFAULT IS THE HANDOFF BLOCK, and the two tokens are the opt-outs. A model that does nothing
157
+ * special gets every prior step's output prepended, because "did the model remember to copy the last
158
+ * result forward" is not a property worth trusting a multi-step plan to. A model that WANTS control -
159
+ * wants the last output and nothing else, or wants the whole history verbatim - says so with a token,
160
+ * and in exchange the automatic block is suppressed entirely rather than being added alongside. Both
161
+ * spellings keep one rule intact: whatever this returns is the complete prompt, never a prefix that
162
+ * hides some of the instruction.
163
+ *
164
+ * AN EMPTY FIRST STEP IS THE PROMPT, VERBATIM. Composing a handoff header onto step one would tell a
165
+ * sub-agent about previous steps that do not exist, and would spend its attention on the header.
166
+ */
167
+ export function composeStepPrompt(prompt, prior) {
168
+ const own = prompt.trim();
169
+ // Steps that ran, oldest first. A pending or running step is not a result yet, and neither is a step
170
+ // beyond the one being composed - the caller passes a slice, but the filter makes the contract local.
171
+ const ran = prior.filter((step) => step.status !== "pending");
172
+ if (own.includes(HISTORY_TOKEN)) {
173
+ const filled = fillHistoryToken(own, ran);
174
+ return { prompt: filled.text, handedOff: filled.used, truncated: filled.truncated };
175
+ }
176
+ if (own.includes(PREVIOUS_TOKEN)) {
177
+ const filled = fillPreviousToken(own, ran);
178
+ return { prompt: filled.text, handedOff: filled.used, truncated: filled.truncated };
179
+ }
180
+ if (!ran.length)
181
+ return { prompt: own, handedOff: 0, truncated: false };
182
+ const { blocks, used, truncated } = budgetPrior(ran);
183
+ // Gated on BLOCKS, NOT ON OUTPUT. A skipped step contributes no output at all, so an output count of
184
+ // zero does not mean there is nothing to say - it means the one thing that must be said is that the step
185
+ // was skipped. Gating on the count would drop exactly the message a fresh-context sub-agent most needs,
186
+ // since it has no other way to learn a step was walked past. This is the bug the skip test caught.
187
+ if (!blocks.length)
188
+ return { prompt: own, handedOff: 0, truncated: false };
189
+ // Blocks are joined with a BLANK line, not a newline: each one starts with a `###` heading, and a
190
+ // heading flush against the paragraph above it does not render as a heading under most Markdown
191
+ // parsers - so the report of the step before would be swallowed into the heading. The same blank line
192
+ // after the rule keeps the model's own task from being read as part of the handoff.
193
+ const header = [
194
+ "编排上下文:下面是这个编排里前面已经跑过的步骤和它们的结论。你自己是一个全新上下文的子 agent,看不到编排本身,所以这段是你唯一的前序信息。最后才是这次派给你的活。",
195
+ "",
196
+ blocks.join("\n\n"),
197
+ "",
198
+ "--- 以下是你这次的任务 ---",
199
+ "",
200
+ "",
201
+ ].join("\n");
202
+ return { prompt: `${header}${own}`, handedOff: used, truncated };
203
+ }
204
+ /** The output a step hands forward: its LAST attempt's, because that is the one a retry corrected. */
205
+ function lastOutputOf(step) {
206
+ for (let index = step.attempts.length - 1; index >= 0; index -= 1) {
207
+ const output = step.attempts[index]?.output;
208
+ if (output?.trim())
209
+ return output;
210
+ }
211
+ return undefined;
212
+ }
213
+ /**
214
+ * Spends the context budget across prior steps, newest first, and renders the survivors oldest-first.
215
+ *
216
+ * NEWEST FIRST IS THE ALLOCATION ORDER, and it is also why a long plan degrades gracefully rather than
217
+ * dropping its most recent context. A plan whose earlier steps produced large reports keeps its tail
218
+ * intact and loses its preamble, which is the right thing to lose: step seven acting on step six's
219
+ * conclusion does not need step one's full output, and step two acting on step one already happened
220
+ * while the budget still had room.
221
+ */
222
+ function budgetPrior(ran) {
223
+ let remaining = WORKFLOW_CONTEXT_BUDGET;
224
+ const chosen = new Map();
225
+ let used = 0;
226
+ let truncated = false;
227
+ for (let index = ran.length - 1; index >= 0 && remaining > 0; index -= 1) {
228
+ const step = ran[index];
229
+ const output = lastOutputOf(step);
230
+ if (!output?.trim()) {
231
+ // Still renders - as the "no output" or "was skipped" line - but spends no budget, because a line
232
+ // that says nothing took nothing.
233
+ chosen.set(index, { output: "", truncated: false });
234
+ continue;
235
+ }
236
+ const capped = capText(output, Math.min(STEP_OUTPUT_BUDGET, remaining), "前序输出");
237
+ if (!capped.text.trim())
238
+ continue;
239
+ truncated ||= capped.truncated;
240
+ chosen.set(index, { output: capped.text, truncated: capped.truncated });
241
+ remaining -= capped.text.length;
242
+ used += 1;
243
+ }
244
+ const blocks = [];
245
+ for (let index = 0; index < ran.length; index += 1) {
246
+ const entry = chosen.get(index);
247
+ // A step whose output did not fit the budget is dropped from the rendered block entirely rather
248
+ // than rendered with nothing in it, which would read as "this step produced no conclusion".
249
+ if (entry === undefined) {
250
+ truncated = true;
251
+ continue;
252
+ }
253
+ blocks.push(renderPriorStep(ran[index], entry.output || undefined));
254
+ }
255
+ return { blocks, used, truncated };
256
+ }
257
+ /**
258
+ * Replaces `{{previous}}` with the output of the single nearest preceding step that produced any.
259
+ *
260
+ * NEAREST, NOT ALL, because the token's name is a promise: a model that wrote `{{previous}}` asked for the
261
+ * last result, and quietly handing it the whole plan instead would be the same surprise as prepending a
262
+ * block it declined. A skipped step does not count as the previous step for this purpose - it has no
263
+ * output - so the search walks back to the nearest step that has one, and the rendered header still says
264
+ * which step it was, so the gap in the numbering is visible rather than inferred.
265
+ *
266
+ * The token is REPLACED EVEN WHEN THERE IS NOTHING TO REPLACE IT WITH. Leaving a literal `{{previous}}`
267
+ * in front of a sub-agent would read as an instruction it cannot satisfy; saying plainly that the first
268
+ * step has nothing before it is the honest version of the same fact.
269
+ */
270
+ function fillPreviousToken(prompt, ran) {
271
+ const nearest = [...ran].reverse().find((step) => Boolean(lastOutputOf(step)?.trim()));
272
+ if (!nearest) {
273
+ return { text: prompt.split(PREVIOUS_TOKEN).join("(这是编排的第一步,前面还没有上一步的产出。)"), used: 0, truncated: false };
274
+ }
275
+ const output = lastOutputOf(nearest) ?? "";
276
+ const capped = capText(output, STEP_OUTPUT_BUDGET, "上一步输出");
277
+ return { text: prompt.split(PREVIOUS_TOKEN).join(renderPriorStep(nearest, capped.text)), used: 1, truncated: capped.truncated };
278
+ }
279
+ /**
280
+ * Replaces `{{history}}` with every prior step, under the same budget the default block uses.
281
+ *
282
+ * Shares `budgetPrior` with the default path on purpose: the two spellings of "hand me what came before"
283
+ * must not be able to disagree about what fits, or a model that chose the explicit token would get a
284
+ * different conversation than one that took the default for the same plan.
285
+ */
286
+ function fillHistoryToken(prompt, ran) {
287
+ const { blocks, used, truncated } = budgetPrior(ran);
288
+ const replacement = blocks.join("\n\n");
289
+ return { text: replacement ? prompt.split(HISTORY_TOKEN).join(replacement) : prompt.split(HISTORY_TOKEN).join("(这是编排的第一步,前面还没有任何产出。)"), used, truncated };
290
+ }
291
+ /**
292
+ * Checks an untrusted plan body against the roster that may actually be dispatched.
293
+ *
294
+ * VALIDATES, NEVER REWRITES, THE AGENT LIST. `agentId` is matched against the roster rather than
295
+ * defaulted, because defaulting would run a different sub-agent than the model asked for - a plan that
296
+ * silently substituted `patcher` for a misspelled `patchre` would edit files on a read-only request.
297
+ * The roster arrives already filtered to `mode: subagent` and non-hidden (`task-tool.ts:88`), so a
298
+ * primary agent's id is refused here by the same rule that refuses it at dispatch time
299
+ * (`subagents.ts:368`) - one lock, consulted twice, rather than a second list that could drift.
300
+ */
301
+ export function validateWorkflowPlan(raw, roster) {
302
+ const ids = roster.map((entry) => entry.id);
303
+ const available = ids.length ? ids.join(", ") : "(none)";
304
+ if (typeof raw !== "object" || raw === null)
305
+ return { refusal: `A workflow must be an object with "summary", "onFailure" and "steps". Got ${raw === null ? "null" : typeof raw}.` };
306
+ const body = raw;
307
+ const rawSteps = body.steps;
308
+ if (!Array.isArray(rawSteps))
309
+ return { refusal: `workflow.steps must be an array of at least 1 and at most ${WORKFLOW_MAX_STEPS} steps. Got ${rawSteps === undefined ? "nothing" : typeof rawSteps}.` };
310
+ if (rawSteps.length < 1)
311
+ return { refusal: "workflow.steps was empty. A workflow needs at least one step; for a single sub-agent, call the task tool instead." };
312
+ if (rawSteps.length > WORKFLOW_MAX_STEPS)
313
+ return { refusal: `workflow.steps holds ${rawSteps.length} steps and the limit is ${WORKFLOW_MAX_STEPS}. Shorten the plan, or split it into two workflows and use background dispatch for the parts that are independent.` };
314
+ const onFailure = body.onFailure === undefined ? "stop" : parseFailurePolicy(body.onFailure);
315
+ if (!onFailure)
316
+ return { refusal: `workflow.onFailure must be one of ${WORKFLOW_FAILURE_POLICIES.join(", ")}, or "retry" on its own for a single retry. Got ${JSON.stringify(body.onFailure)}. "stop" is used when you leave it out.` };
317
+ const steps = [];
318
+ for (let index = 0; index < rawSteps.length; index += 1) {
319
+ const entry = rawSteps[index];
320
+ const where = `workflow.steps[${index}]`;
321
+ if (typeof entry !== "object" || entry === null)
322
+ return { refusal: `${where} must be an object with "agentId" and "prompt". Got ${entry === null ? "null" : typeof entry}.` };
323
+ const step = entry;
324
+ const agentId = typeof step.agentId === "string" ? step.agentId.trim() : "";
325
+ if (!agentId)
326
+ return { refusal: `${where}.agentId is required. Dispatchable sub-agents: ${available}.` };
327
+ if (!ids.includes(agentId))
328
+ return { refusal: `${JSON.stringify(agentId)} is not a dispatchable sub-agent (${where}.agentId). Dispatchable sub-agents: ${available}. Pick one of those, or call the task tool directly for a single delegation.` };
329
+ const prompt = typeof step.prompt === "string" ? step.prompt.trim() : "";
330
+ if (!prompt)
331
+ return { refusal: `${where}.prompt is required and must not be empty: the sub-agent starts with a fresh context, so this is everything it will know about the work.` };
332
+ const label = typeof step.label === "string" ? step.label.trim() : "";
333
+ steps.push({ agentId, prompt, ...(label ? { label } : {}) });
334
+ }
335
+ // Defaulted rather than refused. The summary is a heading for a human, and a model that wrote three
336
+ // good steps but no summary has still written a runnable plan; refusing would spend a whole retry on
337
+ // a field that exists to be pretty.
338
+ const summary = typeof body.summary === "string" && body.summary.trim() ? body.summary.trim().slice(0, 200) : `${steps.length} 步编排`;
339
+ return { plan: { summary, onFailure, steps } };
340
+ }
341
+ /**
342
+ * Reduces a persisted plan to what the primary agent is told when the workflow returns.
343
+ *
344
+ * ONE ATTEMPT PER STEP, THE LAST ONE. The earlier attempts stay in the run records and on the timeline,
345
+ * which is where a person debugging a retried plan looks; putting all of them in the envelope would make
346
+ * the model re-read a failure it has already acted on.
347
+ */
348
+ export function workflowSummary(plan) {
349
+ return {
350
+ id: plan.id,
351
+ summary: plan.summary,
352
+ status: plan.status,
353
+ onFailure: plan.onFailure,
354
+ steps: plan.steps.map((step) => {
355
+ const last = step.attempts[step.attempts.length - 1];
356
+ return {
357
+ n: step.index + 1,
358
+ agentId: step.agentId,
359
+ ...(step.label ? { label: step.label } : {}),
360
+ status: step.status,
361
+ attempts: step.attempts.length,
362
+ ...(last?.output !== undefined ? { output: last.output, ...(last.outputTruncated ? { outputTruncated: true } : {}) } : {}),
363
+ ...(last?.error ? { error: last.error } : {}),
364
+ };
365
+ }),
366
+ ...(plan.finishedAt ? { finishedAt: plan.finishedAt } : {}),
367
+ };
368
+ }
369
+ /**
370
+ * The step a settled run became, as an attempt record.
371
+ *
372
+ * The narrow seam between the run recorder and this engine: everything about spawning, streaming and
373
+ * process teardown is the recorder's, and the only thing that crosses here is the settled record it
374
+ * always resolves with (`subagent-runs.ts:159`). `output` is copied rather than referenced because the
375
+ * store hands out clones, and a plan that kept a live reference would read whatever the run wrote next.
376
+ */
377
+ export function attemptFromRun(run, n) {
378
+ return {
379
+ n,
380
+ runId: run.id,
381
+ status: run.status === "completed" ? "completed" : run.status === "stopped" ? "stopped" : "failed",
382
+ ...(run.output !== undefined ? { output: run.output, ...(run.outputTruncated ? { outputTruncated: true } : {}) } : {}),
383
+ startedAt: run.startedAt,
384
+ ...(run.finishedAt ? { finishedAt: run.finishedAt } : {}),
385
+ ...(run.error ? { error: run.error } : {}),
386
+ };
387
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -53,6 +53,10 @@
53
53
  "scripts/subagent-runs.test.mjs",
54
54
  "scripts/conversation-reconcile.test.mjs",
55
55
  "scripts/subagent-dispatch.test.mjs",
56
+ "scripts/workflow-plan.test.mjs",
57
+ "scripts/workflow-engine.test.mjs",
58
+ "scripts/workflow-endpoint.test.mjs",
59
+ "scripts/workflow-ui.test.mjs",
56
60
  "scripts/agent-vis-records.test.mjs",
57
61
  "scripts/queue-store.test.mjs",
58
62
  "scripts/followup.test.mjs",