bullswarm 0.13.2 → 0.14.0
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/CHANGELOG.md +34 -0
- package/docs/claude-dynamic-workflow-mechanics.md +17 -7
- package/docs/planner-prompt-audit-2026-08-29.md +157 -0
- package/package.json +1 -1
- package/skill/SKILL.md +5 -0
- package/src/workflow/decision.js +18 -4
- package/src/workflow/goal.js +30 -21
- package/src/workflow/runner.js +13 -3
- package/src/workflow/runtime.js +211 -86
- package/src/workflow/schema.js +80 -0
- package/src/workflow/template.js +4 -1
- package/src/workflow/validate.js +13 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# bullswarm changelog
|
|
2
2
|
|
|
3
|
+
## 0.14.0 — structured worker output, compact planner contract
|
|
4
|
+
|
|
5
|
+
- A verify whose reply cannot be parsed as the verdict JSON gets ONE bounded
|
|
6
|
+
re-ask (event `verify.verdict_retry`) before its failure can reach a planner
|
|
7
|
+
boundary — observed on run `ejk9w2`: one unparseable verdict cost a full
|
|
8
|
+
planner turn plus ~8 minutes of re-proving a passing state.
|
|
9
|
+
- Planner contract amendments from the same run's observations: a verify is
|
|
10
|
+
scoped to what can be true at its point in the graph (later-scheduled work is
|
|
11
|
+
not a defect; cosmetic mismatches are concerns, never ok:false); when the
|
|
12
|
+
goal's acceptance checks pass the planner returns complete instead of adding
|
|
13
|
+
polish actions; restored the shared-working-tree, redundant-verification,
|
|
14
|
+
and operatorSteering guidance dropped by the contract merge.
|
|
15
|
+
- "Full" planner-context excerpts (scout, new-since-last-decision, failing
|
|
16
|
+
verifies) obey the per-excerpt and total budgets again; the compaction must
|
|
17
|
+
never rebuild the 163 k-char contexts it replaced.
|
|
18
|
+
- Planner context and contract compacted: complete emitted planner task text up to the durable-context marker, worktree-isolation suffix included **OBSERVED** `16,316 -> 5,208` characters, and a sample turn-2 durable context **COMPUTED** `163,000 -> 23,547` characters by replacing full attempt records with compact ledger rows and retaining full output excerpts only for new/scout or `ok:false` verify actions.
|
|
19
|
+
- Planner `run` actions and fan-out `stepTemplate`s may declare an optional
|
|
20
|
+
`outputSchema`, an object-typed JSON-Schema subset. The runtime tells the
|
|
21
|
+
worker to end its output with one matching JSON object, parses and validates
|
|
22
|
+
it, and persists a `run` result as `outputs.<id>.data` with `schemaOk: true`;
|
|
23
|
+
fan-out results store those fields inside each `outputs.<fanoutId>.items[]`
|
|
24
|
+
entry. Successful validation emits `action.output_validated`.
|
|
25
|
+
- Schema failures emit `action.output_schema_retry` and receive exactly one
|
|
26
|
+
bounded retry with the validation errors and the previous output tail. If
|
|
27
|
+
that retry also fails, the action remains `ok:false`, records
|
|
28
|
+
`schemaOk:false` and `schemaErrors`, and keeps the output text with the
|
|
29
|
+
reason `output did not match outputSchema: <errors>`. Resumed runs do not
|
|
30
|
+
re-dispatch actions already marked `schemaOk:true`.
|
|
31
|
+
- Dependent prompts can render `{{outputs.<id>.data.<field>}}`, and
|
|
32
|
+
`fanout.itemsFrom` accepts `outputs.<id>.data.items` without an extraction
|
|
33
|
+
agent when the array is already present. Planner decision validation rejects
|
|
34
|
+
`outputSchema` on a proposed `verify` because verify has a fixed verdict
|
|
35
|
+
shape.
|
|
36
|
+
|
|
3
37
|
## 0.13.2 — user text is never a template
|
|
4
38
|
|
|
5
39
|
- `workflow goal` failed before anything ran when the goal text quoted
|
|
@@ -234,7 +234,7 @@ from `docs/experiments/2026-08-29-ultracode-vs-bullswarm.md`, never projected.
|
|
|
234
234
|
| Phases | Labels for grouping; never synchronise | Forward-only kebab-case names per action; also just labels | None |
|
|
235
235
|
| Parallelism | `pipeline` default, `parallel` barrier; cap min(16, CPUs−2) | `executeActions` ran dependency-ready siblings **serially** (`runner.js:558`); only `fanout` items ran concurrently; goal default concurrency 3 | **Fixed in 0.11.0** — ready-set scheduler + default 8 |
|
|
236
236
|
| Planner bias | Script author is told to fan out and default to pipeline | Goal prompt said "return needs_more_work with the **smallest useful set** of bounded … actions" (`goal.js:18`) and planner prompt said "keep actions cohesive" | **Fixed in 0.11.0** — "propose the COMPLETE dependency graph", per-item fix→verify chains, file ownership, self-contained prompts |
|
|
237
|
-
| Per-agent prompt | Self-contained, plus JSON schema enforced at tool layer | Planner-authored prompt;
|
|
237
|
+
| Per-agent prompt | Self-contained, plus JSON schema enforced at tool layer | Planner-authored prompt; `outputSchema` validates structured worker data, while `verify` retains its fixed JSON verdict | Adopted for declared schemas; tool-layer enforcement remains a difference |
|
|
238
238
|
| Failure handling | Loops in code; `null` on agent death | Planner replans (costly); 0.10.9 added corrective turns for invalid decisions and 0.11.0 recovers mis-shaped `verify.review` before dispatch | Improved; retry-in-code per action still absent |
|
|
239
239
|
| Determinism / resume | Journal of return values; prefix cache | Durable `state.json` + `events.jsonl` + action ledger; resume skips durable outputs | Equivalent |
|
|
240
240
|
| Data-driven fan-out | `pipeline(discovered.items, …)` — count unknown when the script is written | Decision schema forced inline `items`; the planner spent a turn waiting for discovery | **Fixed in 0.12.0** — `itemsFrom` on proposed fan-outs + one bounded extraction retry |
|
|
@@ -288,8 +288,7 @@ author and the `Workflow` runtime.
|
|
|
288
288
|
if the output still has no array the runtime runs ONE bounded, read-only
|
|
289
289
|
extraction action over it (never re-running the producer, which may have
|
|
290
290
|
mutated files). That is the "schema retry" of Claude's `StructuredOutput`,
|
|
291
|
-
done as a second cheap agent instead of a tool-layer retry.
|
|
292
|
-
`outputSchema` on run actions is still open (§5).
|
|
291
|
+
done as a second cheap agent instead of a tool-layer retry.
|
|
293
292
|
3. **Pre-authored repair** — shipped. `repair: { prompt, maxRounds }` on a
|
|
294
293
|
verify: verify-fail → `<verifyId>-repair-<n>` (concerns verbatim) →
|
|
295
294
|
re-verify, inside the executor. Claude's fix-loop as code.
|
|
@@ -350,6 +349,21 @@ author and the `Workflow` runtime.
|
|
|
350
349
|
exist — the script's `while (!ok)` loop *is* the evidence — which is the
|
|
351
350
|
general lesson: every piece of control flow bullswarm moves from planner
|
|
352
351
|
into runtime needs its evidence rule moved with it.
|
|
352
|
+
11. **[SPEC] Schema-enforced worker output** — a planner `run` action or fan-out
|
|
353
|
+
`stepTemplate` may declare an object-typed `outputSchema` subset. The
|
|
354
|
+
runtime appends instructions for one trailing matching JSON object, with no
|
|
355
|
+
prose or markdown fences after it, then parses and validates the object.
|
|
356
|
+
A successful `run` persists `outputs.<id>.data` and `schemaOk: true`; a
|
|
357
|
+
fan-out stores those schema results inside each
|
|
358
|
+
`outputs.<fanoutId>.items[]` entry. Both emit `action.output_validated`. A mismatch emits
|
|
359
|
+
`action.output_schema_retry` and gets exactly one bounded retry carrying
|
|
360
|
+
the validation errors and the previous output tail; a second mismatch
|
|
361
|
+
fails the action while retaining its output text and recording
|
|
362
|
+
`schemaOk:false` and `schemaErrors`. Dependent prompts can render data
|
|
363
|
+
fields, and `fanout.itemsFrom` can consume `outputs.<id>.data.items` without
|
|
364
|
+
extraction when it is already an array. Planner decision validation rejects
|
|
365
|
+
`outputSchema` on a proposed `verify` because verify has a fixed verdict
|
|
366
|
+
shape.
|
|
353
367
|
|
|
354
368
|
**Honest limitation.** `itemsFrom` removes the planner *turn*, not the stage
|
|
355
369
|
*barrier*: a verify depending on a data-driven fan-out waits for all items,
|
|
@@ -361,10 +375,6 @@ them.
|
|
|
361
375
|
|
|
362
376
|
## 5. Not adopted (yet), and why
|
|
363
377
|
|
|
364
|
-
- **Schema-enforced worker output.** bullswarm's content verification and the
|
|
365
|
-
JSON `verify` verdict cover the failure mode today; adding per-action
|
|
366
|
-
`outputSchema` is the next step if planners keep re-asking workers for
|
|
367
|
-
structure.
|
|
368
378
|
- **Per-action worktree isolation.** File ownership declared by the planner is
|
|
369
379
|
cheaper and matches Claude's own guidance ("EXPENSIVE … use ONLY when agents
|
|
370
380
|
mutate files in parallel and would otherwise conflict").
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Planner prompt and context audit — 2026-08-29
|
|
2
|
+
|
|
3
|
+
Question from the user: after the 0.11 → 0.14 iterations, does the instruction
|
|
4
|
+
set given to the orchestrator/planner still make sense, or should it be
|
|
5
|
+
simplified or refactored?
|
|
6
|
+
|
|
7
|
+
Method: measure what the planner actually receives, not what the source files
|
|
8
|
+
look like. The two planner task files of dogfood run `wf-mtdq1l9v-ed22fe`
|
|
9
|
+
(`75t4n2`, bullswarm building `outputSchema` in its own repo, runtime 0.13.2)
|
|
10
|
+
are the specimens; sizes are characters of the task text
|
|
11
|
+
(`task-orchestrator-*.md`), tokens ≈ chars / 4.
|
|
12
|
+
|
|
13
|
+
## 1. What one planner turn receives
|
|
14
|
+
|
|
15
|
+
| section | source | turn 1 | turn 2 |
|
|
16
|
+
| --- | --- | ---: | ---: |
|
|
17
|
+
| orchestrator prompt + worktree line | `goal.js` `AUTONOMOUS_ORCHESTRATOR_PROMPT` | 5 000 | 5 000 |
|
|
18
|
+
| PLANNING DOCTRINE (11 bullets) | `runtime.js` runDecision | 3 605 | 3 605 |
|
|
19
|
+
| Action skeletons (6 shapes + verify semantics) | `runtime.js` | 1 342 | 1 342 |
|
|
20
|
+
| Program skeleton (discovery → fan-out → verify → suite) | `runtime.js` | 1 217 | 1 217 |
|
|
21
|
+
| Graph skeleton (two chains + suite) + fanout paragraph + runtime-owned line | `runtime.js` | 4 054 | 4 054 |
|
|
22
|
+
| durable context (JSON) | `runtime.js` plannerContext | 17 474 | 162 946 |
|
|
23
|
+
| **total** | | **32 730** (~8 k tokens) | **178 452** (~45 k tokens) |
|
|
24
|
+
|
|
25
|
+
The fixed prefix is 15.2 k chars on every turn. The durable context grew
|
|
26
|
+
**9×** between turn 1 and turn 2 of the same run.
|
|
27
|
+
|
|
28
|
+
### Where the 163 k of turn 2 went
|
|
29
|
+
|
|
30
|
+
| key | chars | what it is |
|
|
31
|
+
| --- | ---: | --- |
|
|
32
|
+
| `completedActions` (19 entries) | 66 700 | every finished action **with its full attempt records**: routing candidates and pace numbers, usage, pricing table, child pid, timings — ~3.5 k per action |
|
|
33
|
+
| `outputs` (19 entries) | 41 560 | `outputExcerpt` of ~3 k chars for *every* finished action, including ones that finished ok and were already verified in the previous program |
|
|
34
|
+
| `intent` | 6 657 | the goal text (6.5 k) + cwd + policy — needed, once |
|
|
35
|
+
| `failures` | 1 073 | the two blocked actions — duplicates of `completedActions` entries |
|
|
36
|
+
| `availablePools`, `budget`, `executionConstraints`, `closedPhases`, … | ~1 800 | fine |
|
|
37
|
+
|
|
38
|
+
## 2. What is said more than once
|
|
39
|
+
|
|
40
|
+
Reading the prefix as the planner does, the same rules appear two or three
|
|
41
|
+
times in different words:
|
|
42
|
+
|
|
43
|
+
| rule | orchestrator prompt | doctrine | skeletons |
|
|
44
|
+
| --- | --- | --- | --- |
|
|
45
|
+
| propose the whole program, one round trip costs minutes | item 2 | bullets 1, 2 | "all in ONE decision" ×2 |
|
|
46
|
+
| N items → N run + N verify + one suite verify | item 5 | "Per-item chains" | Graph skeleton |
|
|
47
|
+
| unknown items → discovery + `itemsFrom` fan-out | item 5 | "Unknown item count" | Program skeleton + fanout paragraph |
|
|
48
|
+
| every verify gets a `repair` policy | item 5 | "Verification failures" | verify skeleton |
|
|
49
|
+
| `completion: all-actions-ok` on a clean program | item 5 | "Self-completing programs" | — |
|
|
50
|
+
| self-contained worker prompts, file ownership | item 3 | "File ownership", "Self-contained prompts" | — |
|
|
51
|
+
| don't propose pool/addDir/taskFile | closing line | — | final line |
|
|
52
|
+
|
|
53
|
+
Item 5 of the orchestrator prompt alone is 1 050 chars and restates four
|
|
54
|
+
doctrine bullets. The two program skeletons both end in the same
|
|
55
|
+
`verify-items → verify-suite` tail.
|
|
56
|
+
|
|
57
|
+
## 3. Does it matter? Measured
|
|
58
|
+
|
|
59
|
+
- Turn 1 (32.7 k chars) took **637 s**; turn 2 (178 k chars) took **390 s**.
|
|
60
|
+
Latency is therefore dominated by the model's reasoning on the goal, not by
|
|
61
|
+
context size — the 6.5 k-char goal and a 12-action program cost more thinking
|
|
62
|
+
than reading 45 k tokens. Trimming context is a **cost** and **attention**
|
|
63
|
+
lever, not primarily a latency lever.
|
|
64
|
+
- Cost: turn 2 read ~45 k tokens to emit a ~1 k-token decision. At Opus
|
|
65
|
+
prices that is ~$0.25 per boundary; a run with four boundaries (goal 2 on
|
|
66
|
+
0.12.1) spends more on re-reading attempt metadata than on the decisions.
|
|
67
|
+
- Attention: the planner's turn-2 reason correctly diagnosed the blocked
|
|
68
|
+
graph, so quality did not visibly suffer here — but 64 k chars of pricing
|
|
69
|
+
tables and routing candidates are noise it must skip to find the two
|
|
70
|
+
`ok:false` concerns that matter.
|
|
71
|
+
- Behaviour observed in three runs (goal 2, goal 3, dogfood): every rule the
|
|
72
|
+
prefix repeats was followed on the first turn (whole program, per-item
|
|
73
|
+
chains, repair policies, `completion`). No observed decision needed a rule
|
|
74
|
+
to be stated twice.
|
|
75
|
+
|
|
76
|
+
## 4. Recommendation
|
|
77
|
+
|
|
78
|
+
Yes — refactor, in two independent pieces, both measurable:
|
|
79
|
+
|
|
80
|
+
**A. Compact the durable context (the 9× growth).** Planner-facing ledger rows
|
|
81
|
+
instead of raw ledger entries: `{ id, type, phase, status, pool, durationSec,
|
|
82
|
+
attempts, why }` (~150 chars; 19 actions → ~3 k instead of 66.7 k). Keep a
|
|
83
|
+
full `outputExcerpt` only for actions finished **since the last decision** and
|
|
84
|
+
for every `ok:false` verify; older ok actions get a one-line summary (id, ok,
|
|
85
|
+
first 200 chars). Replace `failures` with the ids of failing actions (their
|
|
86
|
+
full entry already sits in the ledger). Expected turn-2 context: ~25 k chars
|
|
87
|
+
instead of 163 k. Pure runtime change; no planner behaviour change intended.
|
|
88
|
+
|
|
89
|
+
**B. One contract instead of three overlapping texts.** Merge
|
|
90
|
+
`AUTONOMOUS_ORCHESTRATOR_PROMPT` and the doctrine bullets into a single ordered
|
|
91
|
+
list of ~10 rules (target ≤ 4 k chars, from 8.1 k), each stated once with its
|
|
92
|
+
reason; keep exactly two JSON examples — the action shapes list and one
|
|
93
|
+
complete program (discovery → data-driven fan-out → per-item verify with
|
|
94
|
+
repair → suite verify, with `completion`) — and delete the second program
|
|
95
|
+
skeleton (target ≤ 3 k, from 6.6 k). Total prefix ≤ 7 k chars, from 15.2 k.
|
|
96
|
+
|
|
97
|
+
Acceptance for both: unit tests on the context builder (row shape, excerpt
|
|
98
|
+
policy by decision sequence) and on the prompt (each rule appears once; the
|
|
99
|
+
skeleton assertions in `tests/workflow-adaptive.test.js` updated); then one
|
|
100
|
+
re-run of goal 3 on the same fixture (baseline 0.13.1: 28 min 42 s, 1 planner
|
|
101
|
+
turn, 294 s) to confirm the decision shape is unchanged and record the new
|
|
102
|
+
per-turn size.
|
|
103
|
+
|
|
104
|
+
Not recommended: cutting the goal text or the scout excerpt from the context —
|
|
105
|
+
both were used verbatim by every first-turn program observed.
|
|
106
|
+
|
|
107
|
+
## 5. Outcome
|
|
108
|
+
|
|
109
|
+
Measurements below were taken after the refactor from the current source and
|
|
110
|
+
from the committed source saved into `/tmp/goal-before.mjs` and
|
|
111
|
+
`/tmp/runtime-before.js`, using the same temporary measurement script. The
|
|
112
|
+
canonical prefix is the complete emitted planner task text counted from its
|
|
113
|
+
first character up to (not including) the durable-context marker, with the
|
|
114
|
+
worktree-isolation suffix included. The committed baseline predates the named
|
|
115
|
+
section exports, so its emitted prefix was reconstructed from the committed
|
|
116
|
+
`runtime.js` task-text assembly and the committed `AUTONOMOUS_ORCHESTRATOR_PROMPT`.
|
|
117
|
+
|
|
118
|
+
- Complete emitted planner task text up to the durable-context marker,
|
|
119
|
+
worktree-isolation suffix included: **OBSERVED**, `16,316` characters before
|
|
120
|
+
and `5,208` characters after. Command: `node /tmp/measure-planner.mjs`.
|
|
121
|
+
- Static planner task-prefix array through the durable-context boundary:
|
|
122
|
+
**OBSERVED**, `16,209` characters before and `5,101` characters after. The
|
|
123
|
+
after value is 107 characters shorter because it excludes the unchanged
|
|
124
|
+
worktree-isolation suffix; this is a secondary source-level measurement, not
|
|
125
|
+
the canonical emitted-prefix headline. Command: `node /tmp/measure-planner.mjs`.
|
|
126
|
+
- `PLANNER_RULES_SECTION`: **OBSERVED**, `2,202` characters after. Command:
|
|
127
|
+
`node /tmp/measure-planner.mjs`.
|
|
128
|
+
- `PLANNER_EXAMPLES_SECTION`: **OBSERVED**, `1,867` characters after. Command:
|
|
129
|
+
`node /tmp/measure-planner.mjs`.
|
|
130
|
+
- `AUTONOMOUS_ORCHESTRATOR_PROMPT`: **OBSERVED**, `4,670` characters after;
|
|
131
|
+
the committed before source had no separately exported rules or examples
|
|
132
|
+
sections, so separate before-section sizes are **NOT AVAILABLE**, not
|
|
133
|
+
inferred. Command: `node /tmp/measure-planner.mjs`.
|
|
134
|
+
- Sample durable context: **OBSERVED** baseline `163,000` characters in the
|
|
135
|
+
audit's rounded turn-2 durable-context total (the detailed table records
|
|
136
|
+
`162,946`; the full turn-2 task was `178,452`), and **COMPUTED** `23,547`
|
|
137
|
+
characters after. The computed sample applies 19
|
|
138
|
+
compact ledger rows at 150 characters each, keeps a 3,000-character scout
|
|
139
|
+
excerpt and two 3,000-character failing-verify excerpts, truncates the
|
|
140
|
+
other 16 action excerpts to 200 characters, represents two failures as
|
|
141
|
+
20-character IDs, and retains the audit's 6,657-character intent and
|
|
142
|
+
1,800-character other-context components. Command: `node /tmp/compute-context.mjs`.
|
|
143
|
+
|
|
144
|
+
Deliverables:
|
|
145
|
+
|
|
146
|
+
- Durable planner context shrank because completed actions are compact ledger
|
|
147
|
+
rows, failures are IDs, and stale successful output is truncated.
|
|
148
|
+
- Planner contract shrank because overlapping prompt/doctrine/skeleton text is
|
|
149
|
+
now one ordered rules section plus exactly two JSON examples, single-sourced
|
|
150
|
+
in `src/workflow/goal.js`.
|
|
151
|
+
- Runtime prompt construction shrank because `src/workflow/runtime.js` imports
|
|
152
|
+
the shared contract instead of carrying a duplicate doctrine and graph
|
|
153
|
+
skeleton.
|
|
154
|
+
- `skill/SKILL.md` was left unchanged: it documents the general durable
|
|
155
|
+
context and the separate run-state/TUI attempt view, but does not document a
|
|
156
|
+
renamed/dropped planner-context field shape such as the old attempt records
|
|
157
|
+
or an old `failures` representation.
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -361,6 +361,11 @@ that expressible without extra turns:
|
|
|
361
361
|
(`source: "program-completion"`, event `decision.auto_completed`) and the run
|
|
362
362
|
ends without another planner turn; a failing action emits
|
|
363
363
|
`decision.completion_predicate_unmet` and the boundary returns to the planner.
|
|
364
|
+
- `outputSchema` on a `run` or fan-out `stepTemplate` — declare it when a
|
|
365
|
+
downstream action needs reliable structured data, such as an object to render
|
|
366
|
+
into a dependent prompt or an `items` array for `fanout.itemsFrom`; leave it
|
|
367
|
+
off for ordinary prose; planner proposals must not put it on `verify`, whose
|
|
368
|
+
verdict shape is fixed.
|
|
364
369
|
|
|
365
370
|
Every fan-out records a summary artifact as `outputs.<id>.outFile` and a
|
|
366
371
|
boolean `ok` (item count in `succeeded`), so a verify may depend on a fan-out
|
package/src/workflow/decision.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// or dispatch any new action.
|
|
4
4
|
|
|
5
5
|
export const DECISION_SCHEMA_VERSION = 'bullswarm.workflow.decision.v1';
|
|
6
|
+
import { isValidOutputSchema } from './schema.js';
|
|
6
7
|
export const DECISIONS = new Set([
|
|
7
8
|
'proceed', 'complete', 'needs_more_work', 'retry', 'escalate',
|
|
8
9
|
'wait_for_approval', 'stop',
|
|
@@ -40,7 +41,7 @@ export function parseDecisionText(text) {
|
|
|
40
41
|
export const REVIEW_PATH_RE = /^outputs\.([A-Za-z0-9_-]+(?:\[\d+\])?)\.outFile$/;
|
|
41
42
|
// Data-driven fan-out source: the artifact of an earlier (or co-proposed)
|
|
42
43
|
// action whose output ends with a JSON array of items.
|
|
43
|
-
export const ITEMS_FROM_RE = /^outputs\.([A-Za-z0-9_-]+)(?:\.outFile)?$/;
|
|
44
|
+
export const ITEMS_FROM_RE = /^outputs\.([A-Za-z0-9_-]+)(?:\.data\.([A-Za-z0-9_-]+)|\.outFile)?$/;
|
|
44
45
|
export const REPAIR_MAX_ROUNDS = 3;
|
|
45
46
|
// Program-level completion predicates a planner may attach to a program so the
|
|
46
47
|
// runtime can record completion itself when every action finishes ok.
|
|
@@ -179,7 +180,15 @@ export function validateDecisionProposal(proposal, {
|
|
|
179
180
|
if (action.type === 'run' && typeof action.prompt !== 'string') {
|
|
180
181
|
issues.push(`${at} needs a prompt`);
|
|
181
182
|
}
|
|
182
|
-
|
|
183
|
+
if (action.outputSchema !== undefined) {
|
|
184
|
+
if (action.type !== 'run') issues.push(`${at}.outputSchema is only allowed on run actions`);
|
|
185
|
+
else {
|
|
186
|
+
const schema = isValidOutputSchema(action.outputSchema);
|
|
187
|
+
if (!schema.ok) issues.push(...schema.issues.map((issue) => `${at}.outputSchema: ${issue}`));
|
|
188
|
+
else if (action.outputSchema.type !== 'object') issues.push(`${at}.outputSchema.type must be "object"`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (action.type === 'fanout') {
|
|
183
192
|
const hasItems = Array.isArray(action.items);
|
|
184
193
|
const hasItemsFrom = action.itemsFrom != null;
|
|
185
194
|
if (!hasItems && !hasItemsFrom) {
|
|
@@ -198,10 +207,15 @@ export function validateDecisionProposal(proposal, {
|
|
|
198
207
|
}
|
|
199
208
|
}
|
|
200
209
|
if (!action.stepTemplate || typeof action.stepTemplate !== 'object') issues.push(`${at}.stepTemplate is required`);
|
|
201
|
-
|
|
210
|
+
for (const runtimeOwned of ['pool', 'addDir', 'taskFile']) {
|
|
202
211
|
if (action.stepTemplate?.[runtimeOwned] != null) {
|
|
203
212
|
issues.push(`${at}.stepTemplate.${runtimeOwned} is runtime-owned and cannot be proposed by a planner`);
|
|
204
|
-
|
|
213
|
+
}
|
|
214
|
+
if (action.stepTemplate?.outputSchema !== undefined) {
|
|
215
|
+
const schema = isValidOutputSchema(action.stepTemplate.outputSchema);
|
|
216
|
+
if (!schema.ok) issues.push(...schema.issues.map((issue) => `${at}.stepTemplate.outputSchema: ${issue}`));
|
|
217
|
+
else if (action.stepTemplate.outputSchema.type !== 'object') issues.push(`${at}.stepTemplate.outputSchema.type must be "object"`);
|
|
218
|
+
}
|
|
205
219
|
}
|
|
206
220
|
}
|
|
207
221
|
if (action.repair != null && action.type !== 'verify') {
|
package/src/workflow/goal.js
CHANGED
|
@@ -7,31 +7,40 @@ import { resolve } from 'node:path';
|
|
|
7
7
|
|
|
8
8
|
const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
9
9
|
|
|
10
|
+
export const PLANNER_RULES_SECTION = [
|
|
11
|
+
'1. Compile the whole program in one decision: the runtime executes all proposed actions and consults you only at a finished-or-blocked boundary, so deferring decidable work costs another round trip.',
|
|
12
|
+
'2. Make every worker prompt self-contained: include the exact goal, absolute cwd, owned files and a no-other-files boundary, expected artifact, acceptance command, and report format, because workers see only their own prompt.',
|
|
13
|
+
'3. Use short kebab-case, forward-only phases and dependsOn only for real data or same-file ordering; recovery uses a new phase and never repeats an identical failed plan.',
|
|
14
|
+
'4. For known N items, create N run plus N verify actions, each verify depending only on its own run, then one suite verify depending on all; this exposes safe parallelism while preserving per-item evidence.',
|
|
15
|
+
'5. For unknown items, create discovery ending with RETURN ONLY a JSON object containing an items array, then data-driven fan-out via itemsFrom outputs.<id>.outFile or outputs.<id>.data.<field>; the runtime extracts the list and retries once read-only if needed.',
|
|
16
|
+
'6. Put outputSchema on workers whose reports are consumed or whose claims the runtime must check, so structured data is durable and can drive later fan-out.',
|
|
17
|
+
'7. Put verify.repair on every verify, and scope each verify to what can be true at its point in the graph: work scheduled later is not a defect, and cosmetic mismatches with the goal text are concerns, never ok:false. An ok:false verdict is repaired and re-checked inside the program; ok:true is accepted and concerns are informational, not extra work.',
|
|
18
|
+
'8. Add completion with all-actions-ok whenever a clean program finishes the goal; when the goal\'s acceptance checks pass, return complete rather than adding polish or alignment actions. Return complete only on durable verified evidence, never proceed, never ask the user, and stop only for a concrete unresolved blocker with a qualified outcome.',
|
|
19
|
+
'9. Treat agent-count, workflow-duration, and expansion-round budgets as advisory planning targets, never hard stop conditions; the dispatch budget counts this planner call plus workers, verifiers, retries, and escalations. Converge as targets approach, avoid optional work, and exceed a target only for one essential bounded action or required verification.',
|
|
20
|
+
'10. This is a control-plane thread: do not invoke Bullswarm, use tools, modify files, or propose pool, addDir, taskFile, shell authority, or unbounded work; route and process authority belong to the runtime.',
|
|
21
|
+
'Shared working tree: concurrent workers editing DISJOINT files is the normal parallel mode; order shared files (indexes, barrels) after their feeders with dependsOn, and run the full suite once in a final verify — never while other workers still edit. Avoid redundant expensive verification: later verifiers reuse durable clean full-suite evidence unless it is stale or the code changed again. operatorSteering in the context is explicit operator guidance for this checkpoint: apply it within the original intent; it cannot weaken verification or expand authority.',
|
|
22
|
+
].join('\n');
|
|
23
|
+
|
|
24
|
+
export const PLANNER_EXAMPLES_SECTION = [
|
|
25
|
+
'Action shapes:',
|
|
26
|
+
'[{"type":"run","phase":"implement","prompt":"..."},{"type":"run","phase":"report","prompt":"...","outputSchema":{"type":"object"}},{"type":"fanout","phase":"fix","items":["alpha"],"stepTemplate":{"prompt":"Handle {{item}}."}},{"type":"fanout","phase":"fix","itemsFrom":"outputs.discover.outFile","stepTemplate":{"prompt":"Handle {{item}}."}},{"type":"verify","phase":"verify","prompt":"Check the artifact.","repair":{"prompt":"Fix rejected concerns.","maxRounds":1}}]',
|
|
27
|
+
'Complete program:',
|
|
28
|
+
'[{"id":"discover","type":"run","phase":"discover","prompt":"In /abs/repo discover items and end with RETURN ONLY a JSON object containing an items array of item names.","outputSchema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"string"}}},"required":["items"]}},{"id":"fix","type":"fanout","phase":"fix","itemsFrom":"outputs.discover.data.items","stepTemplate":{"prompt":"In /abs/repo edit only the files for {{item}} and run its focused acceptance command."},"dependsOn":["discover"]},{"id":"verify-items","type":"verify","phase":"verify-items","prompt":"Independently verify every item artifact.","dependsOn":["fix"],"repair":{"prompt":"Fix each rejected item in /abs/repo and re-run its focused command.","maxRounds":2}},{"id":"verify-suite","type":"verify","phase":"verify-suite","prompt":"Run the full acceptance command in /abs/repo.","dependsOn":["verify-items"],"repair":{"prompt":"Fix the suite failure in /abs/repo and rerun the suite.","maxRounds":1}}],"completion":{"when":"all-actions-ok","reason":"The item checks and final suite verification prove the goal."}]',
|
|
29
|
+
'Rules the validator enforces: action type is run, fanout, or verify; fanout has stepTemplate and either items or itemsFrom; verify.review is a string when explicit review is needed; dependsOn names existing or proposed actions; runtime-owned fields are rejected.',
|
|
30
|
+
].join('\n');
|
|
31
|
+
|
|
10
32
|
export const AUTONOMOUS_ORCHESTRATOR_PROMPT = [
|
|
11
33
|
'You are the autonomous orchestrator for the user goal in the durable workflow context.',
|
|
12
|
-
'
|
|
13
|
-
'
|
|
14
|
-
'
|
|
15
|
-
|
|
34
|
+
'This is a control-plane decision thread. Compile the goal into a complete workflow program, own decomposition through independent verification, and use only the supplied context. Do not invoke Bullswarm, run shell commands, call tools, modify files, or ask the user to steer routine execution.',
|
|
35
|
+
'',
|
|
36
|
+
'Ordered planning contract:',
|
|
37
|
+
PLANNER_RULES_SECTION,
|
|
16
38
|
'',
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
'3. Give workers self-contained prompts with the exact goal, absolute working directory, the files they may edit (and that they must not touch others), the expected artifact, and the exact acceptance command. A worker sees only its own prompt.',
|
|
21
|
-
'4. Assign every action a short kebab-case phase name such as discover, fix, verify-items, or verify-suite. Phases are forward-only: never append new work to a phase that already finished.',
|
|
22
|
-
'5. Use dependsOn only for real data or same-file ordering dependencies. For N known items propose N fix actions and N verify actions (each verify depending only on its own fix) plus one final verify depending on all of them; use fanout with inline items when every item needs the identical prompt. When the item count is unknown, propose a discovery run whose prompt ends with "RETURN ONLY a JSON array of <items>" and a fanout with itemsFrom "outputs.<discovery-id>.outFile", so the runtime fans out the moment discovery finishes.',
|
|
23
|
-
' A verify action with exactly one dependency automatically reviews that dependency artifact (a fan-out artifact summarises every item); you do not need to supply a review path. Give every verify a repair policy {"prompt": "<how to fix what the verifier rejects>", "maxRounds": 1-3} so a rejected verdict is fixed and re-checked inside the program instead of costing another checkpoint. When the program ends with verification that would satisfy the goal, add a top-level "completion": {"when": "all-actions-ok", "reason": "<what a clean run proves>"} so a clean run is recorded as complete without another checkpoint.',
|
|
24
|
-
'6. Recover from a failed action with a new bounded action in a new phase when useful; do not repeat an identical failed plan.',
|
|
25
|
-
'7. Require concrete verification of changed behavior. For code changes, obtain relevant test or inspection evidence before completion.',
|
|
26
|
-
'8. Return complete only when durable outputs prove the original goal and its acceptance checks are satisfied.',
|
|
39
|
+
// Keep the fanout token inert in the authored workflow prompt. The runtime
|
|
40
|
+
// restores it when constructing the planner task, after template validation.
|
|
41
|
+
PLANNER_EXAMPLES_SECTION.replaceAll('{{item}}', '__BULLSWARM_ITEM_TEMPLATE__'),
|
|
27
42
|
'',
|
|
28
|
-
'Do not return proceed: this autonomous workflow has no hidden static work after this gate.',
|
|
29
|
-
'Do not ask the initiating user to construct JSON or choose agents.',
|
|
30
|
-
'Do not propose pool, addDir, taskFile, shell authority, or unbounded work; Bullswarm owns routing and process authority.',
|
|
31
|
-
'Treat maxAgents, maxWorkflowSeconds, and maxExpansionRounds as planning targets, not hard stop conditions.',
|
|
32
|
-
'As those targets approach, converge aggressively: consolidate existing artifacts, avoid optional investigation, and finish with the best useful outcome rather than spending on marginal refinements.',
|
|
33
|
-
'Exceed an advisory target only for a small essential action needed to avoid discarding otherwise-completable work or skipping required verification.',
|
|
34
|
-
'Return stop when unresolved concerns make verified completion disproportionate or when a concrete safety, authority, capability, dependency, or external blocker remains. Stop returns a qualified final outcome; it is not a blanket workflow failure when useful work exists.',
|
|
43
|
+
'Return only the requested decision JSON. Do not return proceed: this autonomous workflow has no hidden static work after this gate.',
|
|
35
44
|
].join('\n');
|
|
36
45
|
|
|
37
46
|
// Read-only survey that runs before the orchestrator's first decision, so the
|
package/src/workflow/runner.js
CHANGED
|
@@ -343,8 +343,9 @@ export async function runWorkflow(opts) {
|
|
|
343
343
|
runtime.persist();
|
|
344
344
|
// R2: skip ok:true on resume for both `run` and `verify`. (Fanout
|
|
345
345
|
// is resumed inside the runtime, per-item by fingerprint.)
|
|
346
|
-
|
|
347
|
-
|
|
346
|
+
if (resuming && state.outputs[step.id]?.ok === true
|
|
347
|
+
&& (step.outputSchema === undefined || state.outputs[step.id]?.schemaOk === true)
|
|
348
|
+
&& (step.type === 'run' || step.type === 'verify')) {
|
|
348
349
|
runtime.emit('step.skipped', { stepId: step.id });
|
|
349
350
|
continue;
|
|
350
351
|
}
|
|
@@ -619,7 +620,15 @@ async function runDecisionLoop({ runtime, gate, phase, state, retryAttempts }) {
|
|
|
619
620
|
const resolveProposedItems = async (action) => {
|
|
620
621
|
const itemsFrom = action.itemsFrom.trim();
|
|
621
622
|
const producerId = ITEMS_FROM_RE.exec(itemsFrom)?.[1] ?? null;
|
|
623
|
+
const dataField = ITEMS_FROM_RE.exec(itemsFrom)?.[2] ?? null;
|
|
622
624
|
const limit = Number(settings.maxItemsPerExpansion ?? 50) || 50;
|
|
625
|
+
if (dataField) {
|
|
626
|
+
const items = state.outputs?.[producerId]?.data?.[dataField];
|
|
627
|
+
if (!Array.isArray(items)) return { ok: false, why: `fanout itemsFrom "${itemsFrom}" did not resolve to an array in producer data` };
|
|
628
|
+
if (items.length > limit) return { ok: false, why: `fanout "${action.id}" resolved ${items.length} items from ${itemsFrom}, exceeding maxItemsPerExpansion=${limit}; propose a narrower discovery or split the fan-out` };
|
|
629
|
+
runtime.emit('action.items_resolved', { actionId: action.id, itemsFrom, count: items.length });
|
|
630
|
+
return { ok: true, items };
|
|
631
|
+
}
|
|
623
632
|
const attempt = (path) => {
|
|
624
633
|
try { return { ok: true, items: extractItems(state, path) }; } catch (err) { return { ok: false, why: err.message }; }
|
|
625
634
|
};
|
|
@@ -738,7 +747,8 @@ async function runDecisionLoop({ runtime, gate, phase, state, retryAttempts }) {
|
|
|
738
747
|
const pending = new Map(actions.map((action) => [action.id, action]));
|
|
739
748
|
const running = new Map();
|
|
740
749
|
const runOne = async (action) => {
|
|
741
|
-
|
|
750
|
+
if (state.outputs[action.id]?.ok === true
|
|
751
|
+
&& (action.outputSchema === undefined || state.outputs[action.id]?.schemaOk === true)) {
|
|
742
752
|
runtime.emit('action.resumed', { actionId: action.id, status: 'succeeded' });
|
|
743
753
|
return;
|
|
744
754
|
}
|
package/src/workflow/runtime.js
CHANGED
|
@@ -36,6 +36,8 @@ import { classifyAgentProgress, recordAgentAction } from '../lib/agent-events.js
|
|
|
36
36
|
import { deliverSteering } from './steering.js';
|
|
37
37
|
import { resolveDispatchModel } from '../lib/strategy.js';
|
|
38
38
|
import { getMeterReading } from '../meters/registry.js';
|
|
39
|
+
import { validateAgainstSchema } from './schema.js';
|
|
40
|
+
import { AUTONOMOUS_ORCHESTRATOR_PROMPT } from './goal.js';
|
|
39
41
|
|
|
40
42
|
// Cap how much of each step's output we keep inline in state.json.
|
|
41
43
|
// Persisting full transcripts bloat state.json on long workflows. The
|
|
@@ -296,6 +298,7 @@ export class WorkflowRuntime {
|
|
|
296
298
|
meta: { exitCode: null },
|
|
297
299
|
};
|
|
298
300
|
action.status = 'failed_terminal';
|
|
301
|
+
action.why = refused.why;
|
|
299
302
|
action.finishedAt = new Date().toISOString();
|
|
300
303
|
this.emit('action.failed', { actionId, status: action.status, why: refused.why });
|
|
301
304
|
return refused;
|
|
@@ -469,6 +472,7 @@ export class WorkflowRuntime {
|
|
|
469
472
|
attemptRecord.finishedAt = new Date().toISOString();
|
|
470
473
|
attemptRecord.why = refused.why;
|
|
471
474
|
action.status = 'failed_terminal';
|
|
475
|
+
action.why = refused.why;
|
|
472
476
|
action.finishedAt = attemptRecord.finishedAt;
|
|
473
477
|
this.state.activeAgents[activeKey].status = 'failed';
|
|
474
478
|
this.state.activeAgents[activeKey].finishedAt = attemptRecord.finishedAt;
|
|
@@ -599,6 +603,7 @@ export class WorkflowRuntime {
|
|
|
599
603
|
});
|
|
600
604
|
}
|
|
601
605
|
action.status = attemptRecord.status;
|
|
606
|
+
if (!verdict.ok) action.why = verdict.why ?? null;
|
|
602
607
|
action.finishedAt = attemptRecord.finishedAt;
|
|
603
608
|
this.emit('attempt.completed', {
|
|
604
609
|
actionId, attemptNumber, status: attemptRecord.status, ok: verdict.ok, why: verdict.why ?? null,
|
|
@@ -885,6 +890,93 @@ export class WorkflowRuntime {
|
|
|
885
890
|
};
|
|
886
891
|
}
|
|
887
892
|
|
|
893
|
+
schemaTaskText(taskText, schema, retry = null) {
|
|
894
|
+
return [
|
|
895
|
+
taskText,
|
|
896
|
+
'',
|
|
897
|
+
retry
|
|
898
|
+
? `Your previous answer did not match the required schema: ${retry.errors.join('; ')}. Previous output tail: ${retry.tail}. Return the full answer again and END with a JSON object that matches.`
|
|
899
|
+
: 'END YOUR OUTPUT with exactly one JSON object matching this schema. No prose or markdown fences may appear after it.',
|
|
900
|
+
JSON.stringify(schema),
|
|
901
|
+
].join('\n');
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* The last JSON object in the output, validated against the schema. Found
|
|
906
|
+
* the way hasStructuredAnswer finds a trailing array: from the final "}"
|
|
907
|
+
* walk back over each "{" and try to parse — no hand-rolled brace/quote
|
|
908
|
+
* scanner (a reverse scan mis-handles \" inside strings and would waste
|
|
909
|
+
* the single schema retry on a valid answer).
|
|
910
|
+
*/
|
|
911
|
+
readTrailingObject(path, schema) {
|
|
912
|
+
let text;
|
|
913
|
+
try { text = readFileSync(path, 'utf8'); } catch (err) { return { ok: false, errors: [`output file could not be read: ${err.message}`] }; }
|
|
914
|
+
const trimmed = text.trimEnd();
|
|
915
|
+
if (!trimmed.endsWith('}')) return { ok: false, errors: ['output did not end with a JSON object'] };
|
|
916
|
+
const close = trimmed.length - 1;
|
|
917
|
+
// Walk "{" positions from the right. Inner braces of the trailing object
|
|
918
|
+
// parse (as nested objects), then its own "{" parses (the widest), then
|
|
919
|
+
// every brace further left belongs to prose and fails. The trailing object
|
|
920
|
+
// is therefore the LAST successful parse before the first failure — never
|
|
921
|
+
// the first success, which would be its innermost nested object.
|
|
922
|
+
// (Adversarial review 2026-08-29 caught the first-success version
|
|
923
|
+
// recording {"ok":"inner"} out of {"wrapper":{"ok":"inner"}}.)
|
|
924
|
+
let best = null;
|
|
925
|
+
let parseError = null;
|
|
926
|
+
// lastIndexOf clamps a negative fromIndex to 0, so the walk must stop
|
|
927
|
+
// explicitly at position 0 or it spins forever on output that starts with "{".
|
|
928
|
+
for (let start = trimmed.lastIndexOf('{', close); start !== -1; start = start > 0 ? trimmed.lastIndexOf('{', start - 1) : -1) {
|
|
929
|
+
let value;
|
|
930
|
+
try { value = JSON.parse(trimmed.slice(start, close + 1)); } catch (err) {
|
|
931
|
+
if (best) break;
|
|
932
|
+
parseError = err.message;
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) best = value;
|
|
936
|
+
else if (best) break;
|
|
937
|
+
}
|
|
938
|
+
if (!best) return { ok: false, errors: [parseError ? `trailing JSON object could not be parsed: ${parseError}` : 'output did not contain a balanced trailing JSON object'] };
|
|
939
|
+
const checked = validateAgainstSchema(best, schema);
|
|
940
|
+
return checked.ok ? { ok: true, data: best } : checked;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
async dispatchWithOutputSchema(step, taskText, targetDir, paths, opts = {}) {
|
|
944
|
+
const schema = step.outputSchema;
|
|
945
|
+
// Schema validation owns the retry: generic dispatch retries would make
|
|
946
|
+
// the promised single schema retry depend on workflow settings. Pool
|
|
947
|
+
// escalation is orthogonal — it only follows a dispatch that FAILED
|
|
948
|
+
// (exit/content gate); a schema-invalid answer is a successful dispatch,
|
|
949
|
+
// so the schema retry count stays exactly one.
|
|
950
|
+
const first = await this.dispatch(step, this.schemaTaskText(taskText, schema), targetDir, paths, {
|
|
951
|
+
...opts,
|
|
952
|
+
retryAttempts: 0,
|
|
953
|
+
});
|
|
954
|
+
if (!first.ok) return { verdict: first, schema: null };
|
|
955
|
+
let checked = this.readTrailingObject(first.outFile ?? paths.outFile, schema);
|
|
956
|
+
if (checked.ok) {
|
|
957
|
+
this.emit('action.output_validated', { actionId: this.actionId(step, opts), keys: Object.keys(checked.data) });
|
|
958
|
+
return { verdict: first, schema: checked };
|
|
959
|
+
}
|
|
960
|
+
const firstSchemaErrors = checked.errors;
|
|
961
|
+
this.emit('action.output_schema_retry', { actionId: this.actionId(step, opts), errors: checked.errors });
|
|
962
|
+
const retry = await this.dispatch(step, this.schemaTaskText(taskText, schema, {
|
|
963
|
+
errors: checked.errors,
|
|
964
|
+
tail: (() => { try { return readFileSync(first.outFile ?? paths.outFile, 'utf8').slice(-2000); } catch { return ''; } })(),
|
|
965
|
+
}), targetDir, paths, { ...opts, retryAttempts: 0 });
|
|
966
|
+
if (retry.ok) checked = this.readTrailingObject(retry.outFile ?? paths.outFile, schema);
|
|
967
|
+
if (retry.ok && checked.ok) {
|
|
968
|
+
this.emit('action.output_validated', { actionId: this.actionId(step, opts), keys: Object.keys(checked.data) });
|
|
969
|
+
return { verdict: retry, schema: checked };
|
|
970
|
+
}
|
|
971
|
+
// Report the latest evidence: the retry's schema errors when it ran, the
|
|
972
|
+
// first attempt's when the retry dispatch itself failed.
|
|
973
|
+
const errors = retry.ok ? checked.errors : firstSchemaErrors;
|
|
974
|
+
return {
|
|
975
|
+
verdict: { ...retry, ok: false, why: `output did not match outputSchema: ${errors[0]}` },
|
|
976
|
+
schema: { ok: false, errors },
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
|
|
888
980
|
async runSingle(step, scope, opts = {}) {
|
|
889
981
|
this.enforceRequiredInputs(step.id);
|
|
890
982
|
const rendered = renderDeep(
|
|
@@ -907,14 +999,25 @@ export class WorkflowRuntime {
|
|
|
907
999
|
outFile: join(this.runDir, `out-${stamp}.md`),
|
|
908
1000
|
};
|
|
909
1001
|
|
|
910
|
-
const
|
|
1002
|
+
const dispatched = step.outputSchema
|
|
1003
|
+
? await this.dispatchWithOutputSchema(step, taskText, targetDir, paths, {
|
|
1004
|
+
escalate: this.state.settings.escalateOnFail !== false,
|
|
1005
|
+
retryAttempts: opts.retryAttempts,
|
|
1006
|
+
phase: opts.phase,
|
|
1007
|
+
})
|
|
1008
|
+
: { verdict: await this.dispatch(step, taskText, targetDir, paths, {
|
|
911
1009
|
escalate: this.state.settings.escalateOnFail !== false,
|
|
912
1010
|
retryAttempts: opts.retryAttempts,
|
|
913
1011
|
phase: opts.phase,
|
|
914
|
-
|
|
1012
|
+
}) };
|
|
1013
|
+
const verdict = dispatched.verdict;
|
|
915
1014
|
delete this.state.activeAgents?.[step.id];
|
|
916
1015
|
const finalPaths = { taskFile: verdict.taskFile ?? paths.taskFile, outFile: verdict.outFile ?? paths.outFile };
|
|
917
|
-
this.recordOutput(step.id, verdict, finalPaths
|
|
1016
|
+
this.recordOutput(step.id, verdict, finalPaths, step.outputSchema ? {
|
|
1017
|
+
data: dispatched.schema?.data,
|
|
1018
|
+
schemaOk: dispatched.schema?.ok === true,
|
|
1019
|
+
...(dispatched.schema?.ok === false ? { schemaErrors: dispatched.schema.errors } : {}),
|
|
1020
|
+
} : {});
|
|
918
1021
|
if (verdict.ok) this.emit('artifact.published', { actionId: step.id, outFile: finalPaths.outFile });
|
|
919
1022
|
return verdict;
|
|
920
1023
|
}
|
|
@@ -992,7 +1095,7 @@ export class WorkflowRuntime {
|
|
|
992
1095
|
outFile: join(this.runDir, `out-${stamp}.md`),
|
|
993
1096
|
};
|
|
994
1097
|
|
|
995
|
-
|
|
1098
|
+
let verdict = await this.dispatch(step, taskText, targetDir, paths, {
|
|
996
1099
|
escalate: this.state.settings.escalateOnFail !== false,
|
|
997
1100
|
retryAttempts: opts.retryAttempts,
|
|
998
1101
|
phase: opts.phase,
|
|
@@ -1001,18 +1104,45 @@ export class WorkflowRuntime {
|
|
|
1001
1104
|
|
|
1002
1105
|
const finalPaths = { taskFile: verdict.taskFile ?? paths.taskFile, outFile: verdict.outFile ?? paths.outFile };
|
|
1003
1106
|
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1107
|
+
const parseVerdictFile = (path) => {
|
|
1108
|
+
try {
|
|
1109
|
+
const out = readFileSync(path, 'utf8');
|
|
1110
|
+
const start = out.indexOf('{');
|
|
1111
|
+
const end = out.lastIndexOf('}');
|
|
1112
|
+
if (start >= 0 && end > start) {
|
|
1113
|
+
const j = JSON.parse(out.slice(start, end + 1));
|
|
1114
|
+
if (j && typeof j === 'object') return { parsed: j, parseError: null };
|
|
1115
|
+
}
|
|
1116
|
+
return { parsed: null, parseError: null };
|
|
1117
|
+
} catch (err) {
|
|
1118
|
+
return { parsed: null, parseError: err.message };
|
|
1119
|
+
}
|
|
1120
|
+
};
|
|
1121
|
+
let { parsed, parseError } = parseVerdictFile(finalPaths.outFile);
|
|
1122
|
+
|
|
1123
|
+
// An unparseable verdict gets ONE bounded re-ask before it can cost a
|
|
1124
|
+
// planner boundary. Observed 2026-08-29 (run ejk9w2): final-check returned
|
|
1125
|
+
// an unparseable reply, the boundary consulted the planner, and the
|
|
1126
|
+
// follow-up program spent ~8 minutes re-proving a passing state — the
|
|
1127
|
+
// same economics that give outputSchema its single retry.
|
|
1128
|
+
if (verdict.ok && !parsed) {
|
|
1129
|
+
this.emit('verify.verdict_retry', { actionId: step.id, why: parseError ?? 'no JSON object in verdict' });
|
|
1130
|
+
const retryVerdict = await this.dispatch(step, [
|
|
1131
|
+
taskText,
|
|
1132
|
+
'',
|
|
1133
|
+
`Your previous reply could not be used: ${parseError ?? 'it did not contain a parseable JSON object'}.`,
|
|
1134
|
+
'Do the review again if needed, then RETURN ONLY the single JSON object {"ok": <true|false>, "concerns": [...], "summary": "..."} — no prose, no markdown fences, nothing before "{" or after "}".',
|
|
1135
|
+
].join('\n'), targetDir, paths, {
|
|
1136
|
+
escalate: this.state.settings.escalateOnFail !== false,
|
|
1137
|
+
retryAttempts: 0,
|
|
1138
|
+
phase: opts.phase,
|
|
1139
|
+
});
|
|
1140
|
+
if (retryVerdict.ok) {
|
|
1141
|
+
verdict = retryVerdict;
|
|
1142
|
+
finalPaths.taskFile = retryVerdict.taskFile ?? finalPaths.taskFile;
|
|
1143
|
+
finalPaths.outFile = retryVerdict.outFile ?? finalPaths.outFile;
|
|
1144
|
+
({ parsed, parseError } = parseVerdictFile(finalPaths.outFile));
|
|
1013
1145
|
}
|
|
1014
|
-
} catch (err) {
|
|
1015
|
-
parseError = err.message;
|
|
1016
1146
|
}
|
|
1017
1147
|
|
|
1018
1148
|
const ok = verdict.ok && !!parsed && parsed.ok === true;
|
|
@@ -1056,9 +1186,7 @@ export class WorkflowRuntime {
|
|
|
1056
1186
|
decisionSequence: steering.decisionSequence,
|
|
1057
1187
|
});
|
|
1058
1188
|
}
|
|
1059
|
-
|
|
1060
|
-
// excerpt of every output, newest first, under a total character budget so
|
|
1061
|
-
// long runs stay within the planner's context.
|
|
1189
|
+
const previousDecisionAt = this.state.decisions?.at?.(-1)?.createdAt ?? null;
|
|
1062
1190
|
let excerptBudget = PLANNER_EXCERPT_TOTAL_CHARS;
|
|
1063
1191
|
const excerptFor = (output) => {
|
|
1064
1192
|
const text = typeof output?.outputText === 'string' ? output.outputText.trim() : '';
|
|
@@ -1069,33 +1197,56 @@ export class WorkflowRuntime {
|
|
|
1069
1197
|
excerptBudget -= excerpt.length;
|
|
1070
1198
|
return { outputExcerpt: excerpt, outputChars: text.length };
|
|
1071
1199
|
};
|
|
1200
|
+
// "Full" excerpts still obey the per-excerpt/total budget: a turn with
|
|
1201
|
+
// many fresh actions must not rebuild the 163 k contexts this replaced.
|
|
1072
1202
|
const outputEntries = Object.entries(this.state.outputs ?? {});
|
|
1073
|
-
const
|
|
1203
|
+
const ledgerById = new Map((this.state.actionLedger ?? []).map((action) => [action.id, action]));
|
|
1204
|
+
const isNewOutput = (id) => {
|
|
1205
|
+
const finishedAt = ledgerById.get(id)?.finishedAt;
|
|
1206
|
+
return Boolean(previousDecisionAt && finishedAt && Date.parse(finishedAt) > Date.parse(previousDecisionAt));
|
|
1207
|
+
};
|
|
1208
|
+
const excerpts = new Map(outputEntries.slice().reverse().map(([id, output]) => {
|
|
1209
|
+
const full = id === 'scout' || isNewOutput(id) || output?.verify?.ok === false;
|
|
1210
|
+
if (full) return [id, excerptFor(output)];
|
|
1211
|
+
if (output?.ok !== true) return [id, excerptFor(output)];
|
|
1212
|
+
const text = typeof output?.outputText === 'string' ? output.outputText.trim() : '';
|
|
1213
|
+
return [id, { outputExcerpt: text ? text.slice(0, 200) : null, outputChars: text.length }];
|
|
1214
|
+
}));
|
|
1074
1215
|
const outputs = Object.fromEntries(outputEntries.map(([id, output]) => [id, {
|
|
1075
1216
|
ok: output?.ok,
|
|
1076
1217
|
why: output?.why ?? null,
|
|
1077
1218
|
pool: output?.pool ?? null,
|
|
1078
|
-
|
|
1079
|
-
|
|
1219
|
+
outFile: output?.outFile ?? null,
|
|
1220
|
+
data: output?.data ?? null,
|
|
1221
|
+
schemaOk: output?.schemaOk,
|
|
1222
|
+
schemaErrors: output?.schemaErrors ?? null,
|
|
1223
|
+
verify: output?.verify ?? null,
|
|
1080
1224
|
total: output?.total,
|
|
1081
1225
|
succeeded: output?.succeeded,
|
|
1082
1226
|
failed: output?.failed,
|
|
1083
1227
|
itemsFrom: output?.itemsFrom,
|
|
1084
1228
|
...excerpts.get(id),
|
|
1085
1229
|
}]));
|
|
1086
|
-
const actionForPlanner = (action) =>
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1230
|
+
const actionForPlanner = (action) => {
|
|
1231
|
+
const attempts = (action.attempts ?? []).map((index) => this.state.attempts?.[index]).filter(Boolean);
|
|
1232
|
+
const lastAttempt = attempts.at(-1);
|
|
1233
|
+
const startedAt = Date.parse(action.startedAt ?? '');
|
|
1234
|
+
const finishedAt = Date.parse(action.finishedAt ?? '');
|
|
1235
|
+
const row = {
|
|
1236
|
+
id: action.id,
|
|
1237
|
+
type: action.kind,
|
|
1238
|
+
phase: action.phase ?? null,
|
|
1239
|
+
status: action.status,
|
|
1240
|
+
pool: lastAttempt?.pool ?? null,
|
|
1241
|
+
model: lastAttempt?.model ?? null,
|
|
1242
|
+
durationSec: Number.isFinite(startedAt) && Number.isFinite(finishedAt)
|
|
1243
|
+
? Math.round((finishedAt - startedAt) / 100) / 10 : null,
|
|
1244
|
+
attempts: attempts.length,
|
|
1245
|
+
why: action.why ?? null,
|
|
1246
|
+
};
|
|
1247
|
+
if (action.item !== undefined) row.item = action.item;
|
|
1248
|
+
return row;
|
|
1249
|
+
};
|
|
1099
1250
|
const workflowElapsedSec = Math.max(0,
|
|
1100
1251
|
(Date.now() - Date.parse(this.state.startedAt)) / 1000);
|
|
1101
1252
|
const workflowTargetSec = this.state.settings?.maxWorkflowSeconds == null
|
|
@@ -1121,7 +1272,7 @@ export class WorkflowRuntime {
|
|
|
1121
1272
|
['succeeded', 'failed_terminal', 'cancelled', 'abandoned'].includes(action.status)).map(actionForPlanner),
|
|
1122
1273
|
outputs,
|
|
1123
1274
|
failures: (this.state.actionLedger ?? []).filter((action) =>
|
|
1124
|
-
['failed_terminal', 'abandoned'].includes(action.status)).map(
|
|
1275
|
+
['failed_terminal', 'abandoned'].includes(action.status)).map((action) => action.id),
|
|
1125
1276
|
closedPhases: [...new Set((this.state.plan?.actions ?? [])
|
|
1126
1277
|
.filter((action) => action.source === 'planner')
|
|
1127
1278
|
.map((action) => action.definition?.phase)
|
|
@@ -1130,7 +1281,6 @@ export class WorkflowRuntime {
|
|
|
1130
1281
|
executionConstraints: {
|
|
1131
1282
|
concurrency: Number(this.state.settings?.concurrency ?? 1) || 1,
|
|
1132
1283
|
readySiblingsRunConcurrently: true,
|
|
1133
|
-
programFeatures: ['itemsFrom', 'repair', 'completion'],
|
|
1134
1284
|
plannerConsultedOnlyAtProgramBoundary: true,
|
|
1135
1285
|
actionTimeoutSec: Number(step.actionDefaults?.timeoutSec ?? step.timeoutSec) || null,
|
|
1136
1286
|
actionTimeoutIsExplicitOptIn: step.actionDefaults?.timeoutSec != null || step.timeoutSec != null,
|
|
@@ -1163,14 +1313,20 @@ export class WorkflowRuntime {
|
|
|
1163
1313
|
lanes: pool.lanes ?? pool.connector?.lanes ?? [],
|
|
1164
1314
|
capabilities: pool.capabilities ?? pool.connector?.capabilities ?? [],
|
|
1165
1315
|
pace: pool.pace ?? null,
|
|
1166
|
-
|
|
1167
|
-
|
|
1316
|
+
})),
|
|
1317
|
+
};
|
|
1318
|
+
const contextJson = JSON.stringify(plannerContext);
|
|
1319
|
+
this.emit('decision.context_built', {
|
|
1320
|
+
sequence: (this.state.decisions?.length ?? 0) + 1,
|
|
1321
|
+
chars: contextJson.length,
|
|
1322
|
+
keys: Object.fromEntries(Object.entries(plannerContext).map(([key, value]) => [key, JSON.stringify(value).length])),
|
|
1323
|
+
});
|
|
1168
1324
|
const rendered = renderDeep({
|
|
1169
1325
|
prompt: step.prompt ?? 'Judge whether the workflow has enough evidence to finish.',
|
|
1170
1326
|
addDir: step.addDir,
|
|
1171
1327
|
}, scope, this.renderOpts(step.id));
|
|
1172
1328
|
const taskText = [
|
|
1173
|
-
rendered.prompt,
|
|
1329
|
+
rendered.prompt.replaceAll('__BULLSWARM_ITEM_TEMPLATE__', '{{item}}'),
|
|
1174
1330
|
'',
|
|
1175
1331
|
...(opts.correction ? [
|
|
1176
1332
|
`CORRECTION REQUIRED (attempt ${opts.correction.attempt} of ${opts.correction.maxAttempts}): the runtime rejected your previous decision. validationFeedback in the durable context lists the exact issues. Fix only those issues and return the corrected JSON decision. No prose, no markdown fences.`,
|
|
@@ -1181,44 +1337,7 @@ export class WorkflowRuntime {
|
|
|
1181
1337
|
'Every proposed action MUST use the field "type" (never "kind").',
|
|
1182
1338
|
'Every action MUST include a forward-only kebab-case "phase". Never reuse a name listed in closedPhases.',
|
|
1183
1339
|
'',
|
|
1184
|
-
|
|
1185
|
-
'- The runtime executes your whole decision to completion without consulting you: a ready-set scheduler starts every action whose dependsOn have all succeeded, concurrently up to executionConstraints.concurrency, and starts each dependent the moment its own dependencies finish. You are consulted again only at the program boundary, when every action has finished or the graph is blocked. Each consultation is a separate process round trip (typically 1-2 minutes), so anything decidable by data must be encoded in the program, never deferred to a later decision.',
|
|
1186
|
-
'- Propose the COMPLETE dependency graph you can see now: discovery, per-item work, per-item verification, and the final whole-system verification, all in ONE decision. A decision carrying a single action when several are obvious wastes a round trip.',
|
|
1187
|
-
'- Unknown item count: never spend a decision to learn how many items there are. Propose a discovery run action whose prompt ends with "RETURN ONLY a JSON array of <items>", plus a fanout with "itemsFrom":"outputs.<discovery-id>.outFile" whose stepTemplate.prompt uses {{item}}. The runtime resolves the list when discovery finishes (with one bounded read-only extraction retry if the output is not a clean array) and fans out immediately.',
|
|
1188
|
-
'- Verification failures: give each verify a "repair" policy {"prompt":"<how to fix what the verifier rejects>","maxRounds":1-3}. When the verifier returns ok:false, the runtime runs a fix action carrying the verifier concerns verbatim and re-runs the same verify, inside the program. Only verifies still failing after their rounds come back to you.',
|
|
1189
|
-
'- A verify that returned ok:true is accepted. Its concerns are informational (overlaps, wording nits, "non-blocking" notes): do not spend a program round polishing them unless the goal text itself demands it. Only ok:false verifies are work.',
|
|
1190
|
-
'- Self-completing programs: when the program you propose ends with verification that would satisfy the goal, add a top-level "completion": {"when":"all-actions-ok","reason":"<what a clean run proves>"}. If every action of the program (repairs included) finishes ok and the completion policy is met, the runtime records the completion itself and does not consult you again; anything failing brings the boundary back to you. Use it on every program whose clean run would be the finished goal.',
|
|
1191
|
-
'- Per-item chains: for N known items propose N focused run actions plus N verify actions, each verify depending only on its own run, so verifying one item overlaps with fixing another; add one final verify depending on all of them. For items discovered at run time use the discovery → fanout → verify shape above.',
|
|
1192
|
-
'- File ownership: every action prompt must name exactly which files it may edit and state that it must not touch any other file. Two actions that must edit the same file MUST be ordered with dependsOn; never let concurrent actions write the same file.',
|
|
1193
|
-
'- Self-contained prompts: a worker sees only its own prompt, never this context. Each prompt must state the absolute working directory, what to read, what to change, the exact command that proves success, and what to report back. Prefer many small parallel actions over one large serial one.',
|
|
1194
|
-
'- Read before you compile: outputs.<id>.outputExcerpt is what each finished action actually reported (outputs.scout, when present, is a read-only survey of the repository: tree, manifest, test status, units of work, shared files, risks). Name real files, modules, and commands from it in your program instead of guessing.',
|
|
1195
|
-
'',
|
|
1196
|
-
'Action skeletons (copy the shape exactly; every field shown is required unless marked optional):',
|
|
1197
|
-
' run: {"id":"bounded-action","type":"run","phase":"implement","prompt":"Do bounded work.","dependsOn":["prior-action"]}',
|
|
1198
|
-
' fanout: {"id":"per-item-check","type":"fanout","phase":"inspect","items":["alpha","beta"],"stepTemplate":{"prompt":"Inspect {{item}} and report concrete evidence."},"dependsOn":["prior-action"]}',
|
|
1199
|
-
' fanout (data-driven): {"id":"per-module-fix","type":"fanout","phase":"fix","itemsFrom":"outputs.discover-modules.outFile","stepTemplate":{"prompt":"In /abs/repo fix only the module {{item}}; run its focused test; report the diff summary."}}',
|
|
1200
|
-
' verify: {"id":"independent-check","type":"verify","phase":"verify","prompt":"Independently re-run the tests and report pass/fail with evidence.","dependsOn":["bounded-action"],"repair":{"prompt":"In /abs/repo fix the failing behaviour the verifier reports, editing only the files named in the concerns, then re-run the tests.","maxRounds":1}}',
|
|
1201
|
-
'verify semantics: the reviewer receives the artifact of the action named in review, which the runtime infers as outputs.<the single dependsOn>.outFile; put the reviewer INSTRUCTIONS in prompt. A verify with several dependsOn must set review explicitly to "outputs.<actionId>.outFile". review is never instructions or a filesystem path.',
|
|
1202
|
-
'Program skeleton (discovery → data-driven fan-out → verify with repair → final whole-suite check, all in ONE decision; the runtime runs it to the end without you):',
|
|
1203
|
-
' [{"id":"discover-modules","type":"run","phase":"discover","prompt":"In /abs/repo list every module under src/ whose test in tests/ fails. Do not edit anything. RETURN ONLY a JSON array of module names, e.g. [\\"alpha\\",\\"beta\\"]."},',
|
|
1204
|
-
' {"id":"fix-module","type":"fanout","phase":"fix","itemsFrom":"outputs.discover-modules.outFile","stepTemplate":{"prompt":"In /abs/repo edit only src/{{item}}.js so tests/{{item}}.test.js passes; run node --test tests/{{item}}.test.js; report the diff summary."}},',
|
|
1205
|
-
' {"id":"verify-modules","type":"verify","phase":"verify-items","prompt":"For every module in the reviewed fan-out summary re-run node --test tests/<module>.test.js in /abs/repo and confirm tests/ is unchanged.","dependsOn":["fix-module"],"repair":{"prompt":"In /abs/repo fix the modules the verifier lists, editing only their src files, and re-run their tests.","maxRounds":2}},',
|
|
1206
|
-
' {"id":"verify-suite","type":"verify","phase":"verify-suite","prompt":"Run the full npm test in /abs/repo and report pass/fail counts.","dependsOn":["verify-modules"]}]',
|
|
1207
|
-
'Graph skeleton (two parallel fix→verify chains plus a final whole-suite check, all in ONE decision):',
|
|
1208
|
-
' [{"id":"fix-alpha","type":"run","phase":"fix","prompt":"In /abs/repo edit only src/alpha.js so tests/alpha.test.js passes; run node --test tests/alpha.test.js; report the diff summary."},',
|
|
1209
|
-
' {"id":"fix-beta","type":"run","phase":"fix","prompt":"In /abs/repo edit only src/beta.js so tests/beta.test.js passes; run node --test tests/beta.test.js; report the diff summary."},',
|
|
1210
|
-
' {"id":"verify-alpha","type":"verify","phase":"verify-items","prompt":"Re-run node --test tests/alpha.test.js in /abs/repo and confirm tests/ is unchanged.","dependsOn":["fix-alpha"]},',
|
|
1211
|
-
' {"id":"verify-beta","type":"verify","phase":"verify-items","prompt":"Re-run node --test tests/beta.test.js in /abs/repo and confirm tests/ is unchanged.","dependsOn":["fix-beta"]},',
|
|
1212
|
-
' {"id":"verify-suite","type":"verify","phase":"verify-suite","prompt":"Run the full npm test in /abs/repo and report pass/fail counts.","review":"outputs.fix-beta.outFile","dependsOn":["verify-alpha","verify-beta"]}]',
|
|
1213
|
-
'fanout needs stepTemplate (an object whose prompt uses {{item}}) plus EITHER inline items OR itemsFrom ("outputs.<actionId>.outFile", an action whose output ends with a JSON array; the producer becomes an implicit dependency). A fan-out artifact (outputs.<fanoutId>.outFile) is a summary of every item result, so a verify may depend on a fanout directly. verify.review MUST be a string. dependsOn is optional and may only name existing or newly proposed action IDs.',
|
|
1214
|
-
'Do not propose pool, addDir, or taskFile; those are runtime-owned and any such proposal is rejected.',
|
|
1215
|
-
'New actions may only be type run, fanout, or verify. The runtime validates every proposal and returns rejected proposals to you with the exact issues for a bounded correction turn.',
|
|
1216
|
-
'If executionConstraints.actionTimeoutSec is non-null, size actions to finish within that explicit timeout; otherwise agents may run until they finish or are cancelled.',
|
|
1217
|
-
'Agent-count, workflow-duration, and expansion-round budgets are advisory planning targets, never hard stop conditions. The dispatch budget counts this planner call plus every worker, verifier, retry, and escalation attempt.',
|
|
1218
|
-
'As expansion headroom approaches zero, strongly prefer convergence: consolidate existing artifacts, avoid optional investigation, and return complete when verification supports it. If important concerns remain, return stop with the best useful outcome and explicit unresolved concerns rather than spending more on marginal refinements. Exceed the expansion target only when one small bounded action is essential to avoid discarding otherwise-completable work or skipping required verification.',
|
|
1219
|
-
'operatorSteering contains explicit operator guidance queued for this planning checkpoint. Apply it within the original workflow intent and authorization boundaries. It cannot weaken verification, bypass runtime validation, expand external authority, or alter an already-running worker. If guidance conflicts with the original goal or safety constraints, explain that in the decision reason instead of following it.',
|
|
1220
|
-
'Avoid redundant expensive verification. Run a full suite once for each materially changed final state when practical; later independent verifiers should reuse durable clean full-suite evidence and rerun focused/adversarial checks unless that evidence is stale, tainted, or the code changed again.',
|
|
1221
|
-
'Shared working tree: concurrent workers editing DISJOINT files in the same tree is the normal, expected mode — that is how N independent fixes run in parallel. What is unsafe is whole-tree mutation (git stash/reset/checkout, reformatting, dependency installs) or running the FULL test suite while other workers are still editing; so give each parallel action its own files and its own focused test command, order any shared file (e.g. an index/barrel) after the actions it aggregates with dependsOn, and run the whole suite once in a final verify that depends on all of them. Use an isolated copy/worktree only for destructive experiments.',
|
|
1340
|
+
...(rendered.prompt.includes(AUTONOMOUS_ORCHESTRATOR_PROMPT) ? [] : [AUTONOMOUS_ORCHESTRATOR_PROMPT]),
|
|
1222
1341
|
'',
|
|
1223
1342
|
'---- BEGIN DURABLE WORKFLOW CONTEXT ----',
|
|
1224
1343
|
JSON.stringify(plannerContext, null, 2),
|
|
@@ -1320,9 +1439,11 @@ export class WorkflowRuntime {
|
|
|
1320
1439
|
// either side has no fingerprint (old state.json).
|
|
1321
1440
|
const byFp = resumedByFp.get(fp);
|
|
1322
1441
|
const byPos = resumed[i];
|
|
1323
|
-
const
|
|
1442
|
+
const schemaResumeSafe = !step.stepTemplate?.outputSchema || byFp?.schemaOk === true;
|
|
1443
|
+
const positionalSchemaResumeSafe = !step.stepTemplate?.outputSchema || byPos?.schemaOk === true;
|
|
1444
|
+
const prev = (byFp && byFp.verdict?.ok === true && schemaResumeSafe)
|
|
1324
1445
|
? byFp
|
|
1325
|
-
: ((!byFp && byPos?.verdict?.ok === true) ? byPos : null);
|
|
1446
|
+
: ((!byFp && byPos?.verdict?.ok === true && positionalSchemaResumeSafe) ? byPos : null);
|
|
1326
1447
|
|
|
1327
1448
|
if (prev) {
|
|
1328
1449
|
results[i] = { ...prev, item, fingerprint: fp };
|
|
@@ -1362,12 +1483,15 @@ export class WorkflowRuntime {
|
|
|
1362
1483
|
?? readFileSync(String(template.taskFile), 'utf8');
|
|
1363
1484
|
|
|
1364
1485
|
this.emit('item.started', { stepId: step.id, index: i, total: items.length, item });
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1486
|
+
const itemStep = { ...step, ...template, id: step.id, ...(template.outputSchema ? { outputSchema: template.outputSchema } : {}) };
|
|
1487
|
+
const dispatched = itemStep.outputSchema
|
|
1488
|
+
? await this.dispatchWithOutputSchema(itemStep, taskText, targetDir, paths,
|
|
1489
|
+
{ item, itemIndex: i, phase: opts.phase, escalate: this.state.settings.escalateOnFail !== false, retryAttempts: opts.retryAttempts })
|
|
1490
|
+
: { verdict: await this.dispatch(itemStep, taskText, targetDir, paths,
|
|
1491
|
+
{ item, itemIndex: i, phase: opts.phase, escalate: this.state.settings.escalateOnFail !== false, retryAttempts: opts.retryAttempts }) };
|
|
1492
|
+
const verdict = dispatched.verdict;
|
|
1493
|
+
results[i] = { item, verdict, outFile: verdict.outFile ?? paths.outFile, fingerprint: fp,
|
|
1494
|
+
...(itemStep.outputSchema ? { data: dispatched.schema?.data, schemaOk: dispatched.schema?.ok === true, ...(dispatched.schema?.ok === false ? { schemaErrors: dispatched.schema.errors } : {}) } : {}) };
|
|
1371
1495
|
if (verdict.ok) {
|
|
1372
1496
|
this.emit('item.completed', { stepId: step.id, index: i, pool: verdict.pick?.pool, wall: verdict.meta?.wallSec });
|
|
1373
1497
|
} else {
|
|
@@ -1447,7 +1571,7 @@ export class WorkflowRuntime {
|
|
|
1447
1571
|
return { outFile, outputText: truncated ? full.slice(0, OUTPUT_TEXT_CAP_BYTES) : full, truncated };
|
|
1448
1572
|
}
|
|
1449
1573
|
|
|
1450
|
-
recordOutput(stepId, verdict, paths) {
|
|
1574
|
+
recordOutput(stepId, verdict, paths, extra = {}) {
|
|
1451
1575
|
let outputText = null;
|
|
1452
1576
|
let truncated = false;
|
|
1453
1577
|
try {
|
|
@@ -1469,6 +1593,7 @@ export class WorkflowRuntime {
|
|
|
1469
1593
|
wallSec: verdict.meta?.wallSec,
|
|
1470
1594
|
outputText,
|
|
1471
1595
|
outputTruncated: truncated || undefined,
|
|
1596
|
+
...extra,
|
|
1472
1597
|
};
|
|
1473
1598
|
this.persist();
|
|
1474
1599
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const TYPES = new Set(['object', 'array', 'string', 'number', 'integer', 'boolean', 'null']);
|
|
2
|
+
const KEYWORDS = new Set(['type', 'properties', 'required', 'additionalProperties', 'items', 'minItems', 'maxItems', 'enum', 'minimum', 'maximum', 'minLength', 'pattern', 'description']);
|
|
3
|
+
|
|
4
|
+
function matches(value, type) {
|
|
5
|
+
if (type === 'null') return value === null;
|
|
6
|
+
if (type === 'object') return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
7
|
+
if (type === 'array') return Array.isArray(value);
|
|
8
|
+
if (type === 'integer') return Number.isInteger(value);
|
|
9
|
+
if (type === 'number') return typeof value === 'number' && Number.isFinite(value);
|
|
10
|
+
return typeof value === type;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function validateNode(value, schema, path, errors) {
|
|
14
|
+
if (schema.type !== undefined) {
|
|
15
|
+
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
16
|
+
if (!types.some((type) => matches(value, type))) {
|
|
17
|
+
errors.push(`${path || 'value'} must be ${types.join('|')}`);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (schema.enum !== undefined && !schema.enum.some((candidate) => Object.is(candidate, value))) errors.push(`${path || 'value'} must be one of ${JSON.stringify(schema.enum)}`);
|
|
22
|
+
if (typeof value === 'string') {
|
|
23
|
+
if (schema.minLength !== undefined && value.length < schema.minLength) errors.push(`${path || 'value'} must have at least ${schema.minLength} characters`);
|
|
24
|
+
if (schema.pattern !== undefined && !(new RegExp(schema.pattern)).test(value)) errors.push(`${path || 'value'} must match pattern ${JSON.stringify(schema.pattern)}`);
|
|
25
|
+
}
|
|
26
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
27
|
+
if (schema.minimum !== undefined && value < schema.minimum) errors.push(`${path || 'value'} must be at least ${schema.minimum}`);
|
|
28
|
+
if (schema.maximum !== undefined && value > schema.maximum) errors.push(`${path || 'value'} must be at most ${schema.maximum}`);
|
|
29
|
+
}
|
|
30
|
+
if (Array.isArray(value)) {
|
|
31
|
+
if (schema.minItems !== undefined && value.length < schema.minItems) errors.push(`${path || 'value'} must contain at least ${schema.minItems} items`);
|
|
32
|
+
if (schema.maxItems !== undefined && value.length > schema.maxItems) errors.push(`${path || 'value'} must contain at most ${schema.maxItems} items`);
|
|
33
|
+
if (schema.items) value.forEach((item, index) => validateNode(item, schema.items, `${path}[${index}]`, errors));
|
|
34
|
+
}
|
|
35
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
36
|
+
const properties = schema.properties ?? {};
|
|
37
|
+
// Own properties only: `in` would count inherited names (toString,
|
|
38
|
+
// constructor, __proto__) as present or as declared.
|
|
39
|
+
for (const key of schema.required ?? []) if (!Object.hasOwn(value, key)) errors.push(`${path ? `${path}.` : ''}${key} is required`);
|
|
40
|
+
for (const [key, child] of Object.entries(properties)) if (Object.hasOwn(value, key)) validateNode(value[key], child, `${path ? `${path}.` : ''}${key}`, errors);
|
|
41
|
+
if (schema.additionalProperties === false) for (const key of Object.keys(value)) if (!Object.hasOwn(properties, key)) errors.push(`${path ? `${path}.` : ''}${key} is not allowed`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function schemaIssues(schema, path = 'schema') {
|
|
46
|
+
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return [`${path} must be an object`];
|
|
47
|
+
const issues = [];
|
|
48
|
+
for (const key of Object.keys(schema)) if (!KEYWORDS.has(key)) issues.push(`${path}.${key} is an unknown keyword`);
|
|
49
|
+
if (schema.type !== undefined) {
|
|
50
|
+
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
51
|
+
if (!types.length || types.some((type) => !TYPES.has(type))) issues.push(`${path}.type must be a supported type or array of supported types`);
|
|
52
|
+
}
|
|
53
|
+
if (schema.properties !== undefined && (!schema.properties || typeof schema.properties !== 'object' || Array.isArray(schema.properties))) issues.push(`${path}.properties must be an object`);
|
|
54
|
+
else for (const [key, child] of Object.entries(schema.properties ?? {})) issues.push(...schemaIssues(child, `${path}.properties.${key}`));
|
|
55
|
+
if (schema.required !== undefined && (!Array.isArray(schema.required) || schema.required.some((key) => typeof key !== 'string'))) issues.push(`${path}.required must be an array of strings`);
|
|
56
|
+
if (schema.additionalProperties !== undefined && typeof schema.additionalProperties !== 'boolean') issues.push(`${path}.additionalProperties must be a boolean`);
|
|
57
|
+
if (schema.items !== undefined) issues.push(...schemaIssues(schema.items, `${path}.items`));
|
|
58
|
+
if (schema.enum !== undefined && !Array.isArray(schema.enum)) issues.push(`${path}.enum must be an array`);
|
|
59
|
+
for (const key of ['minItems', 'maxItems', 'minLength']) if (schema[key] !== undefined && (!Number.isInteger(schema[key]) || schema[key] < 0)) issues.push(`${path}.${key} must be a non-negative integer`);
|
|
60
|
+
for (const key of ['minimum', 'maximum']) if (schema[key] !== undefined && (typeof schema[key] !== 'number' || !Number.isFinite(schema[key]))) issues.push(`${path}.${key} must be a finite number`);
|
|
61
|
+
if (schema.pattern !== undefined) {
|
|
62
|
+
if (typeof schema.pattern !== 'string') issues.push(`${path}.pattern must be a string`);
|
|
63
|
+
else try { new RegExp(schema.pattern); } catch (err) { issues.push(`${path}.pattern is not a valid regex: ${err.message}`); }
|
|
64
|
+
}
|
|
65
|
+
if (schema.description !== undefined && typeof schema.description !== 'string') issues.push(`${path}.description must be a string`);
|
|
66
|
+
return issues;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function isValidOutputSchema(schema) {
|
|
70
|
+
const issues = schemaIssues(schema);
|
|
71
|
+
return { ok: issues.length === 0, issues };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function validateAgainstSchema(value, schema) {
|
|
75
|
+
const validity = isValidOutputSchema(schema);
|
|
76
|
+
if (!validity.ok) return { ok: false, errors: validity.issues };
|
|
77
|
+
const errors = [];
|
|
78
|
+
validateNode(value, schema, '', errors);
|
|
79
|
+
return { ok: errors.length === 0, errors };
|
|
80
|
+
}
|
package/src/workflow/template.js
CHANGED
|
@@ -50,7 +50,7 @@ export function renderTemplate(str, scope, opts = {}) {
|
|
|
50
50
|
if (typeof opts.onUnresolved === 'function') opts.onUnresolved(ref.trim());
|
|
51
51
|
return match;
|
|
52
52
|
}
|
|
53
|
-
return typeof v === 'string' ? v : JSON.stringify(v);
|
|
53
|
+
return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? String(v) : JSON.stringify(v);
|
|
54
54
|
});
|
|
55
55
|
}
|
|
56
56
|
|
|
@@ -79,6 +79,9 @@ export function extractItems(state, itemsFrom) {
|
|
|
79
79
|
throw new Error(`fanout itemsFrom "${itemsFrom}" not found in workflow state`);
|
|
80
80
|
}
|
|
81
81
|
if (Array.isArray(v)) return v;
|
|
82
|
+
if (/^outputs\.[A-Za-z0-9_-]+\.data\.[A-Za-z0-9_-]+$/.test(itemsFrom)) {
|
|
83
|
+
throw new Error(`fanout itemsFrom "${itemsFrom}" must resolve to an array (got ${typeof v})`);
|
|
84
|
+
}
|
|
82
85
|
|
|
83
86
|
// outputs.<stepId> envelope (state.outputs.<id> is the full record
|
|
84
87
|
// with ok/pool/outFile/outputText): use the recorded outputText,
|
package/src/workflow/validate.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// typo never burns quota discovering itself mid-run.
|
|
9
9
|
|
|
10
10
|
import { TEMPLATE_TOKEN_RE, isTemplateRef } from './template.js';
|
|
11
|
+
import { isValidOutputSchema } from './schema.js';
|
|
11
12
|
|
|
12
13
|
const LANES = ['analyze', 'build', 'chore'];
|
|
13
14
|
const ON_ERROR = ['continue', 'fail', 'skip-phase'];
|
|
@@ -154,10 +155,10 @@ export function validateWorkflow(wf, { lanes = LANES, poolNames = [] } = {}) {
|
|
|
154
155
|
if (typeof step.itemsFrom === 'string' && step.itemsFrom.includes('.')) {
|
|
155
156
|
const [root, target] = step.itemsFrom.split('.');
|
|
156
157
|
collect(issues, root === 'inputs' || (root === 'outputs' && outputs.has(target)),
|
|
157
|
-
`${sat}.itemsFrom "${step.itemsFrom}" cannot resolve (use inputs.<name> or outputs.<priorStepId>)`);
|
|
158
|
+
`${sat}.itemsFrom "${step.itemsFrom}" cannot resolve (use inputs.<name> or outputs.<priorStepId>[.data.<field>])`);
|
|
158
159
|
} else if (typeof step.itemsFrom === 'string') {
|
|
159
160
|
collect(issues, false,
|
|
160
|
-
`${sat}.itemsFrom "${step.itemsFrom}" must be a dotted path (inputs.<name> or outputs.<priorStepId>)`);
|
|
161
|
+
`${sat}.itemsFrom "${step.itemsFrom}" must be a dotted path (inputs.<name> or outputs.<priorStepId>[.data.<field>])`);
|
|
161
162
|
}
|
|
162
163
|
collect(issues, step.stepTemplate && typeof step.stepTemplate === 'object',
|
|
163
164
|
`${sat}.stepTemplate is required for fanout steps`);
|
|
@@ -168,10 +169,20 @@ export function validateWorkflow(wf, { lanes = LANES, poolNames = [] } = {}) {
|
|
|
168
169
|
if (step.items != null) {
|
|
169
170
|
collect(issues, Array.isArray(step.items), `${sat}.items must be an array`);
|
|
170
171
|
}
|
|
172
|
+
if (step.stepTemplate?.outputSchema !== undefined) {
|
|
173
|
+
const schema = isValidOutputSchema(step.stepTemplate.outputSchema);
|
|
174
|
+
collect(issues, schema.ok, `${sat}.stepTemplate.outputSchema is invalid: ${schema.issues.join('; ')}`);
|
|
175
|
+
if (schema.ok) collect(issues, step.stepTemplate.outputSchema.type === 'object', `${sat}.stepTemplate.outputSchema.type must be "object"`);
|
|
176
|
+
}
|
|
171
177
|
} else if (step.type === 'run') {
|
|
172
178
|
collect(issues,
|
|
173
179
|
typeof step.taskFile === 'string' || typeof step.prompt === 'string',
|
|
174
180
|
`${sat} needs taskFile or prompt`);
|
|
181
|
+
if (step.outputSchema !== undefined) {
|
|
182
|
+
const schema = isValidOutputSchema(step.outputSchema);
|
|
183
|
+
collect(issues, schema.ok, `${sat}.outputSchema is invalid: ${schema.issues.join('; ')}`);
|
|
184
|
+
if (schema.ok) collect(issues, step.outputSchema.type === 'object', `${sat}.outputSchema.type must be "object"`);
|
|
185
|
+
}
|
|
175
186
|
} else if (step.type === 'verify') {
|
|
176
187
|
collect(issues, typeof step.review === 'string' && step.review.length > 0,
|
|
177
188
|
`${sat}.review is required for verify steps (path to a prior outFile, e.g. outputs.<prior>.outFile)`);
|