@signalridge/pi-workflows 1.5.0 → 1.6.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/src/runtime.ts CHANGED
@@ -33,6 +33,13 @@ import {
33
33
  type OrchestrationTaskContext,
34
34
  } from "./orchestrator.js";
35
35
  import { SharedStore } from "./shared-store.js";
36
+ import {
37
+ defaultStrengthTable,
38
+ isWorkflowStrength,
39
+ WORKFLOW_STRENGTH_LIST,
40
+ type WorkflowStrength,
41
+ type WorkflowStrengthTable,
42
+ } from "./strengths.js";
36
43
 
37
44
  // ── Meta contract ────────────────────────────────────────────────────────────
38
45
 
@@ -83,6 +90,24 @@ export interface SharedRuntime {
83
90
  runFatalController: AbortController;
84
91
  inFlight: Set<Promise<unknown>>;
85
92
  activeThreads: Set<string>;
93
+ /**
94
+ * Strengths whose routing this run has already reported.
95
+ *
96
+ * Shared with nested frames rather than kept per frame: the answer is the
97
+ * same in a child, and a run that says "reported once" has to mean once for
98
+ * the run and not once per `workflow()` boundary.
99
+ */
100
+ routedStrengths: Set<string>;
101
+ /**
102
+ * The strength → tier table this run honors, resolved once at the top frame.
103
+ *
104
+ * Shared rather than recomputed per frame for the same reason the set above
105
+ * is: a nested frame inherits the same table and the same catalogue, so it
106
+ * would reach an identical answer and log an identical complaint about any
107
+ * stale entry — once per `workflow()` boundary. Resolving once also makes it
108
+ * structural, rather than merely likely, that one run honors one table.
109
+ */
110
+ strengths: ReadonlyMap<string, string>;
86
111
  }
87
112
 
88
113
  /** Counting semaphore with a FIFO resolve queue. */
@@ -137,12 +162,24 @@ export interface AgentOptions {
137
162
  phase?: string;
138
163
  schema?: unknown;
139
164
  /**
140
- * Agent tier: a key from the host's `agentTiers` catalogue. This is the only
141
- * model control a workflow has — there is no per-call model or thinking, so
142
- * a script cannot pin a vendor the host did not choose. Omitting it uses the
143
- * agent's own tier, else the host's configured default.
165
+ * Workflow strength: how much effort this step deserves, `low`/`medium`/`high`.
166
+ *
167
+ * The only model control a workflow has and the only routing word it speaks.
168
+ * There is no per-call model, thinking, or Agent tier, so a script cannot pin
169
+ * a vendor or a catalogue key the host did not choose. Which Agent tier a
170
+ * strength runs on is the user's `strengths` table and nothing else — a
171
+ * strength that table does not define dispatches with no tier at all and
172
+ * takes the agent's ordinary default.
173
+ *
174
+ * There is no default. Omitting it dispatches with no tier at all, the same
175
+ * as an unmapped strength and the same as any ordinary Agent spawn: a script
176
+ * that named no strength stated no opinion, and the host's own precedence —
177
+ * the agent type's frontmatter tier before the configured default — is the
178
+ * right answer to that. A default would have outranked the agent's own tier
179
+ * on every unlabelled call, which is the one thing this indirection exists to
180
+ * avoid doing.
144
181
  */
145
- tier?: string;
182
+ strength?: WorkflowStrength;
146
183
  isolation?: "worktree";
147
184
  agentType?: string;
148
185
  timeoutMs?: number | null;
@@ -173,16 +210,42 @@ export interface WorkflowRunOptions {
173
210
  /** The host's Agent-tier catalogue, captured fresh for this start/resume. */
174
211
  routingPolicy?: ManagedRoutingPolicy;
175
212
  /**
176
- * This script ships with the package rather than being written here.
213
+ * The user's strength → Agent tier table: the whole of workflow model routing.
177
214
  *
178
- * The tier catalogue belongs to the user, names included. A script we ship
179
- * cannot assert that `low` exists on someone whose tiers are called
180
- * `cheap`/`deep`, so its tier is a preference: an undefined one falls back to
181
- * the host's default with a warning. A script the user wrote names tiers from
182
- * the user's own catalogue, so an undefined one there is a typo and fails
183
- * closed rather than quietly running a different model.
215
+ * A script names a strength and nothing else. This table is the only thing
216
+ * that binds one to an Agent tier, so a strength it does not define reaches
217
+ * dispatch carrying no tier and takes the agent's ordinary default — the
218
+ * agent's own frontmatter tier, then the host's configured default.
219
+ *
220
+ * Omitted entirely is not "no mappings": it means {@link defaultStrengthTable},
221
+ * which starts each strength on the catalogue tier of the same name wherever
222
+ * this host defines one. That is a default *table* and not a fallback rule,
223
+ * and the difference is what keeps re-pricing possible. A rule would be
224
+ * unremovable, so making a 26-agent fan-out cheaper would still mean editing
225
+ * the tier itself — dragging the Explore agent and everything else that names
226
+ * it. A table is replaced by writing one, and a strength the written table
227
+ * omits stays unmapped even on a host that defines a tier of the same name.
228
+ *
229
+ * Values are keys in the host's one catalogue and nothing else: pi-subagents
230
+ * still owns every model, every thinking level, and the only
231
+ * `resolveAgentTier()`. No entry may ever grow a `model` or `thinking` field
232
+ * — that is the line between this and the retired `workflow.tiers` key, which
233
+ * carried both and so became a second model policy behind a second resolver.
234
+ *
235
+ * One hop: a strength resolves to a tier, and that tier is never looked up as
236
+ * a strength in turn.
237
+ *
238
+ * Read fresh for each start and resume, never frozen onto the run, so editing
239
+ * the table and resuming re-routes the calls that have not run.
240
+ *
241
+ * That is a re-spend, not a free redirect. A call's resume identity keys on
242
+ * the tier it requests, so every *finished* call whose strength moved misses
243
+ * its journal entry — and resume replays only the longest unchanged prefix, so
244
+ * the first such miss takes everything after it live as well. Re-routing a run
245
+ * that is mostly done costs roughly the run. Start a new one instead when the
246
+ * point of the edit was to spend less.
184
247
  */
185
- shippedScript?: boolean;
248
+ strengths?: WorkflowStrengthTable;
186
249
  /**
187
250
  * Whole-catalogue identity, used wherever a per-tier one would be dishonest:
188
251
  * a nested `workflow()` boundary, where the parent caches one value for a
@@ -214,12 +277,8 @@ export interface WorkflowRunOptions {
214
277
  sharedRuntime?: SharedRuntime;
215
278
  initialTokenUsage?: AgentUsage;
216
279
  sharedStore?: SharedStore;
217
- /**
218
- * Resolve a nested `workflow(name)` reference, reporting whether the script
219
- * it returned ships with this package. Shipped-ness travels with the script,
220
- * not with the frame that called it — see `shippedScript`.
221
- */
222
- loadSavedWorkflow?: (name: string) => { script: string; shippedScript?: boolean } | undefined;
280
+ /** Resolve a nested `workflow(name)` reference to the script it names. */
281
+ loadSavedWorkflow?: (name: string) => string | undefined;
223
282
  confirm?: (promptText: string, options: CheckpointOptions) => Promise<unknown>;
224
283
  onLog?: (message: string) => void;
225
284
  onPhase?: (title: string) => void;
@@ -516,7 +575,7 @@ function validateMeta(meta: unknown): asserts meta is WorkflowMeta {
516
575
  if (typeof value.description !== "string" || !value.description.trim() || value.description.length > 100_000)
517
576
  throw new Error("meta.description must be a non-empty string of at most 100000 characters");
518
577
  if (Object.hasOwn(value, "model") || Object.hasOwn(value, "thinking")) {
519
- throw new Error("workflow meta model/thinking fields are unsupported; use agent({ tier })");
578
+ throw new Error("workflow meta model/thinking fields are unsupported; use agent({ strength })");
520
579
  }
521
580
  if (value.phases !== undefined) {
522
581
  if (!Array.isArray(value.phases)) throw new Error("meta.phases must be an array");
@@ -532,7 +591,7 @@ function validateMeta(meta: unknown): asserts meta is WorkflowMeta {
532
591
  }
533
592
  const phaseRecord = phase as unknown as Record<string, unknown>;
534
593
  if (Object.hasOwn(phaseRecord, "model") || Object.hasOwn(phaseRecord, "thinking")) {
535
- throw new Error("workflow phase model/thinking fields are unsupported; use agent({ tier })");
594
+ throw new Error("workflow phase model/thinking fields are unsupported; use agent({ strength })");
536
595
  }
537
596
  }
538
597
  }
@@ -564,6 +623,19 @@ function hashIdentity(value: unknown): string {
564
623
  return createHash("sha256").update(JSON.stringify(value)).digest("hex");
565
624
  }
566
625
 
626
+ /**
627
+ * The strength vocabulary as a stable value, for hashing.
628
+ *
629
+ * Sorted with a code-unit comparison rather than `localeCompare` for the reason
630
+ * `getRoutingPolicySnapshot` gives about `blockedProfiles`: this reaches an
631
+ * identity hash, and a locale-dependent order would let one table hash two ways
632
+ * on two machines, or on one machine after its locale changed.
633
+ */
634
+ function canonicalStrengths(strengths: ReadonlyMap<string, string>): [string, string][] | null {
635
+ if (strengths.size === 0) return null;
636
+ return [...strengths].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
637
+ }
638
+
567
639
  /**
568
640
  * Stable identity hash for an agent() call. Must cover every input that can
569
641
  * change the outcome so a cache-miss is never served on resume. The agent
@@ -575,30 +647,34 @@ function hashAgentCall(
575
647
  prompt: string,
576
648
  phase: string | undefined,
577
649
  options: AgentOptions,
650
+ tier: string | undefined,
578
651
  routingPolicy: ManagedRoutingPolicy | undefined,
579
652
  routingPolicyFingerprint: string | undefined,
580
653
  ): string {
581
- // A call that names its tier is keyed on that tier's policy alone, so
654
+ // Keyed on the tier this call will actually request, never on the strength
655
+ // that chose it. The strength is a name for a routing decision; the tier is
656
+ // the decision, so two runs whose tables differ but land on the same tier
657
+ // must replay, and one whose table moved this strength elsewhere must not.
658
+ //
659
+ // A call that resolved to a tier is keyed on that tier's policy alone, so
582
660
  // defining or editing an unrelated tier cannot invalidate it.
583
661
  //
584
- // A call that names none cannot be keyed that way, and keying it on
585
- // `defaultTier` would be worse than coarse — it would be wrong. The host
586
- // resolves an unnamed tier against the agent's own frontmatter tier first and
587
- // only then the default, and frontmatter is invisible here: the agent
588
- // definition lives in pi-subagents and reaches this side as a name. Keyed on
589
- // the default, editing the tier the agent actually declares would replay
590
- // stale work, while editing the default would invalidate work that never
591
- // touched it. The whole-catalogue fingerprint is the coarse answer that is at
592
- // least sound, and it is stable across the original run and its resume, which
593
- // the tier the host selected is not — that one is only known after dispatch,
594
- // and this hash is computed before it.
662
+ // A call whose strength is unmapped resolves to no tier and cannot be keyed
663
+ // that way, and keying it on `defaultTier` would be worse than coarse — it
664
+ // would be wrong. The host resolves an untiered call against the agent's own
665
+ // frontmatter tier first and only then the default, and frontmatter is
666
+ // invisible here: the agent definition lives in pi-subagents and reaches this
667
+ // side as a name. Keyed on the default, editing the tier the agent actually
668
+ // declares would replay stale work, while editing the default would
669
+ // invalidate work that never touched it. The whole-catalogue fingerprint is
670
+ // the coarse answer that is at least sound, and it is stable across the
671
+ // original run and its resume, which the tier the host selected is not — that
672
+ // one is only known after dispatch, and this hash is computed before it.
595
673
  const tierPolicy =
596
- options.tier === undefined
597
- ? (routingPolicyFingerprint ?? null)
598
- : agentTierPolicyIdentity(routingPolicy, options.tier);
674
+ tier === undefined ? (routingPolicyFingerprint ?? null) : agentTierPolicyIdentity(routingPolicy, tier);
599
675
  return hashIdentity({
600
676
  prompt,
601
- tier: options.tier ?? null,
677
+ tier: tier ?? null,
602
678
  tierPolicy,
603
679
  phase: phase ?? null,
604
680
  agentType: options.agentType ?? null,
@@ -631,6 +707,104 @@ function hashCheckpoint(promptText: string, options: CheckpointOptions): string
631
707
 
632
708
  // ── Small helpers ────────────────────────────────────────────────────────────
633
709
 
710
+ /**
711
+ * Every key {@link AgentOptions} accepts, and every key {@link CheckpointOptions}
712
+ * does.
713
+ *
714
+ * Spelled out because an interface leaves no runtime value to enumerate, and a
715
+ * script is JavaScript evaluated in a VM, so the compiler never sees the object
716
+ * these guard. The `satisfies` clause is what keeps the list honest anyway: a
717
+ * `Record` of the interface's keys is missing-key-checked, and the excess
718
+ * property check on the literal catches the other direction — so adding an
719
+ * option without listing it here, or listing one that no longer exists, is a
720
+ * type error rather than a silently unreachable option.
721
+ */
722
+ const AGENT_OPTION_KEYS: ReadonlySet<string> = new Set(
723
+ Object.keys({
724
+ label: 0,
725
+ phase: 0,
726
+ schema: 0,
727
+ strength: 0,
728
+ isolation: 0,
729
+ agentType: 0,
730
+ timeoutMs: 0,
731
+ retries: 0,
732
+ thread: 0,
733
+ toolset: 0,
734
+ excludeTools: 0,
735
+ } satisfies Record<keyof AgentOptions, unknown>),
736
+ );
737
+
738
+ const CHECKPOINT_OPTION_KEYS: ReadonlySet<string> = new Set(
739
+ Object.keys({
740
+ default: 0,
741
+ headless: 0,
742
+ kind: 0,
743
+ choices: 0,
744
+ timeoutMs: 0,
745
+ } satisfies Record<keyof CheckpointOptions, unknown>),
746
+ );
747
+
748
+ /**
749
+ * Keys that used to mean something, or that a script author reaches for out of
750
+ * habit, answered by name instead of by the generic list.
751
+ *
752
+ * `tier` was this package's own option before a strength stood between a script
753
+ * and the host catalogue. `model`/`thinking` never were — `meta` and phase
754
+ * profiles have always rejected them — but an author who tries them on `meta`
755
+ * and gets a clear refusal will try them here next, and the two surfaces should
756
+ * answer the same way.
757
+ */
758
+ const RETIRED_AGENT_OPTIONS: Readonly<Record<string, string>> = {
759
+ tier: `name a strength instead: one of ${WORKFLOW_STRENGTH_LIST}`,
760
+ model: "a strength is the only model policy a workflow can express",
761
+ thinking: "a strength is the only model policy a workflow can express",
762
+ };
763
+
764
+ /**
765
+ * Refuse an options object carrying a key this runtime does not read.
766
+ *
767
+ * Fails closed because the alternative is not "harmless": an unknown key is
768
+ * dropped before the call hash is built, so it changes neither the dispatch nor
769
+ * the resume identity — the run simply spends at a policy nobody chose, and a
770
+ * resume replays it without ever noticing. `agent({ strenght: "low" })` would
771
+ * quietly run every step untiered; `checkpoint(p, { headles: "abort" })` would
772
+ * quietly auto-approve in headless mode instead of aborting. Neither shows up
773
+ * in a log.
774
+ *
775
+ * A script is authored against this contract and evaluated as plain JavaScript,
776
+ * so a typo has no other gate to catch it. The closed vocabulary made a
777
+ * misspelled *strength* fail; this is the same argument one level up, for the
778
+ * option name itself.
779
+ */
780
+ function validateCallOptions(
781
+ callee: "agent" | "checkpoint",
782
+ options: unknown,
783
+ allowed: ReadonlySet<string>,
784
+ retired: Readonly<Record<string, string>> = {},
785
+ ): void {
786
+ if (typeof options !== "object" || options === null || Array.isArray(options)) {
787
+ throw new WorkflowError(
788
+ `${callee}() options must be a plain object (received ${
789
+ Array.isArray(options) ? "an array" : JSON.stringify(options)
790
+ })`,
791
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
792
+ { recoverable: false },
793
+ );
794
+ }
795
+ for (const key of Object.keys(options)) {
796
+ if (allowed.has(key)) continue;
797
+ const hint = retired[key];
798
+ throw new WorkflowError(
799
+ `${callee}() received an unknown option ${JSON.stringify(key)}; ${
800
+ hint ?? `valid options are: ${[...allowed].join(", ")}`
801
+ }`,
802
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
803
+ { recoverable: false },
804
+ );
805
+ }
806
+ }
807
+
634
808
  function defaultAgentLabel(phase: string | undefined, index: number): string {
635
809
  return phase ? `${phase} agent ${index}` : `agent ${index}`;
636
810
  }
@@ -988,10 +1162,13 @@ export async function runWorkflow<T = unknown>(
988
1162
  const agentTimeoutMs = normalizeAgentTimeout(options.agentTimeoutMs);
989
1163
  const runId = options.runId ?? `run-${started.toString(36)}`;
990
1164
  const baseCwd = options.cwd ?? process.cwd();
991
- // The host's catalogue, known before the first call. A tier name that is not
992
- // in it is a typo, and catching it here beats discovering it on dispatch.
993
- // Undefined when no policy was supplied (in-process tests, nested frames that
994
- // inherit the parent's checks) — then only the key shape is enforced.
1165
+ // The host's catalogue, known before the first call. A script cannot name a
1166
+ // tier, so this is not a typo check: it is what the strength table is resolved
1167
+ // against at run start — an entry pointing at a tier this host does not define
1168
+ // is dropped there, and an absent table becomes the same-named default only
1169
+ // for the tiers listed here. Undefined when no policy was supplied (in-process
1170
+ // tests), which leaves a configured table unchecked and the default empty.
1171
+ // Only the top frame reads it; a nested one inherits the resolved table.
995
1172
  const knownTiers = options.routingPolicy ? new Set(Object.keys(options.routingPolicy.profiles)) : undefined;
996
1173
 
997
1174
  const state: RuntimeState = {
@@ -1016,6 +1193,58 @@ export async function runWorkflow<T = unknown>(
1016
1193
  ) - 2,
1017
1194
  );
1018
1195
 
1196
+ const log = (message: unknown) => {
1197
+ const text = String(message);
1198
+ state.logs.push(text);
1199
+ options.onLog?.(text);
1200
+ };
1201
+
1202
+ /**
1203
+ * The strength table this run will honor, target-checked once at start.
1204
+ *
1205
+ * Checked here rather than per call so a stale entry is reported once, at the
1206
+ * point where the fix is obvious, instead of once per dispatch. An entry whose
1207
+ * tier this host does not define is dropped rather than fatal: the table is a
1208
+ * routing preference, and a name that went stale should cost the redirect, not
1209
+ * every workflow on the machine. A dropped entry leaves its strength unmapped,
1210
+ * which is the ordinary unconfigured case — the call takes the agent's own
1211
+ * default, exactly as it would on a machine that configured nothing.
1212
+ *
1213
+ * An entry keyed on a word outside the vocabulary is dropped in silence. The
1214
+ * settings layer already refuses to store one, so reaching here means a
1215
+ * hand-edited file, and the run is the wrong place to teach the vocabulary.
1216
+ *
1217
+ * No configured table at all is not "no mappings": it takes
1218
+ * {@link defaultStrengthTable}, whose entries are drawn from this host's own
1219
+ * catalogue and so can never be stale — which is why only a configured table
1220
+ * ever reaches the complaint above. An explicit `{}` is the way to say "no
1221
+ * mappings"; the settings layer stores that one and drops only a table whose
1222
+ * every entry was invalid, so a broken file cannot pass for that statement.
1223
+ *
1224
+ * Resolved once at the top frame and shared, so a nested `workflow()` cannot
1225
+ * re-derive the same answer and repeat the same complaint at its boundary.
1226
+ */
1227
+ const resolveStrengths = (): ReadonlyMap<string, string> => {
1228
+ const configured = options.strengths;
1229
+ if (configured === undefined) return new Map(Object.entries(defaultStrengthTable(knownTiers)));
1230
+ const active = new Map<string, string>();
1231
+ // Re-checked despite the parameter type: this is a public entry point, and
1232
+ // a JS caller reaches it without one. The settings layer runs the same two
1233
+ // guards at read, so a table coming from there passes both twice.
1234
+ for (const [strength, tier] of Object.entries(configured)) {
1235
+ if (!isWorkflowStrength(strength) || !isManagedAgentTier(tier)) continue;
1236
+ if (knownTiers && !knownTiers.has(tier)) {
1237
+ log(
1238
+ `workflow strength ${JSON.stringify(strength)} → tier ${JSON.stringify(tier)} ignored; ` +
1239
+ `this host defines: ${[...knownTiers].sort().join(", ") || "(none)"}`,
1240
+ );
1241
+ continue;
1242
+ }
1243
+ active.set(strength, tier);
1244
+ }
1245
+ return active;
1246
+ };
1247
+
1019
1248
  // Global caps + budget shared with any nested workflow() so they hold across nesting.
1020
1249
  const shared: SharedRuntime = options.sharedRuntime ?? {
1021
1250
  limiter: createLimiter(concurrency),
@@ -1028,6 +1257,8 @@ export async function runWorkflow<T = unknown>(
1028
1257
  runFatalController: new AbortController(),
1029
1258
  inFlight: new Set<Promise<unknown>>(),
1030
1259
  activeThreads: new Set<string>(),
1260
+ routedStrengths: new Set<string>(),
1261
+ strengths: resolveStrengths(),
1031
1262
  };
1032
1263
  const limiter = shared.limiter;
1033
1264
  const isTopLevelRun = !options.sharedRuntime;
@@ -1036,10 +1267,22 @@ export async function runWorkflow<T = unknown>(
1036
1267
  /** agent() promises owned by this runWorkflow frame only. */
1037
1268
  const frameInFlight = new Set<Promise<unknown>>();
1038
1269
 
1039
- const log = (message: unknown) => {
1040
- const text = String(message);
1041
- state.logs.push(text);
1042
- options.onLog?.(text);
1270
+ const strengths = shared.strengths;
1271
+
1272
+ /**
1273
+ * Report each strength's routing once per run.
1274
+ *
1275
+ * A fan-out asks for the same strength dozens of times and the answer is the
1276
+ * same every time; a line per dispatch would bury the run's own log under it.
1277
+ */
1278
+ const logStrengthRouting = (strength: WorkflowStrength, tier: string | undefined): void => {
1279
+ if (shared.routedStrengths.has(strength)) return;
1280
+ shared.routedStrengths.add(strength);
1281
+ log(
1282
+ tier === undefined
1283
+ ? `workflow strength ${JSON.stringify(strength)} is unmapped; using the agent's own default`
1284
+ : `workflow strength ${JSON.stringify(strength)} runs on agent tier ${JSON.stringify(tier)}`,
1285
+ );
1043
1286
  };
1044
1287
 
1045
1288
  const emitRuntimeEvent = (event: WorkflowRuntimeEvent): void => {
@@ -1099,6 +1342,9 @@ export async function runWorkflow<T = unknown>(
1099
1342
  };
1100
1343
 
1101
1344
  const agent = (promptText: string, agentOptions: AgentOptions = {}): Promise<unknown> => {
1345
+ // First, ahead of the thread bookkeeping below: a malformed options object
1346
+ // must not be able to register a thread it will never release.
1347
+ validateCallOptions("agent", agentOptions, AGENT_OPTION_KEYS, RETIRED_AGENT_OPTIONS);
1102
1348
  const rawThread = agentOptions.thread;
1103
1349
  const thread = rawThread === undefined ? undefined : typeof rawThread === "string" ? rawThread.trim() : "";
1104
1350
  if (rawThread !== undefined && !thread) {
@@ -1153,49 +1399,38 @@ export async function runWorkflow<T = unknown>(
1153
1399
  log(`agentType "${agentOptions.agentType}" resolves in pi-subagents at dispatch`);
1154
1400
  }
1155
1401
 
1156
- // A tier is a key in the host's catalogue, so the only shape check this
1157
- // side can make is the key shape. An unknown key is the host's call to
1158
- // refuse — it is the one that knows what is defined, and its error names
1159
- // the available tiers.
1160
- if (agentOptions.tier !== undefined && !isManagedAgentTier(agentOptions.tier)) {
1402
+ // The vocabulary is closed, so a word outside it is a typo rather than a
1403
+ // strength nobody configured, and saying so here beats dispatching in
1404
+ // silence. This is the whole of the check: unlike a tier key, a strength is
1405
+ // ours, so we can answer for it without asking the host anything.
1406
+ if (agentOptions.strength !== undefined && !isWorkflowStrength(agentOptions.strength)) {
1161
1407
  throw new WorkflowError(
1162
- `agent tier must be a non-empty, whitespace-free key (received ${JSON.stringify(agentOptions.tier)})`,
1408
+ `agent strength must be one of ${WORKFLOW_STRENGTH_LIST} (received ${JSON.stringify(agentOptions.strength)})`,
1163
1409
  WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
1164
1410
  { recoverable: false },
1165
1411
  );
1166
1412
  }
1167
- let effectiveTier = agentOptions.tier;
1168
- if (effectiveTier !== undefined && knownTiers && !knownTiers.has(effectiveTier)) {
1169
- if (options.shippedScript) {
1170
- // Our own script naming a tier this host does not define. Drop it and
1171
- // let the host's default decide, which is the user's policy either way.
1172
- log(`agent tier ${JSON.stringify(effectiveTier)} is not defined here; using the host default`);
1173
- effectiveTier = undefined;
1174
- } else {
1175
- // A typo would otherwise surface only when this call reaches dispatch,
1176
- // possibly deep into a run. The catalogue is known at start; check now.
1177
- throw new WorkflowError(
1178
- `unknown agent tier ${JSON.stringify(effectiveTier)}; this host defines: ${
1179
- [...knownTiers].sort().join(", ") || "(none)"
1180
- }`,
1181
- WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
1182
- { recoverable: false },
1183
- );
1184
- }
1185
- }
1186
- // Everything downstream — the resume hash, the dispatch, the journal —
1187
- // works from the tier that will actually be requested, not the one the
1188
- // script wrote, so a dropped name cannot make a cached call look different
1189
- // from the live one.
1190
- const tieredOptions: AgentOptions =
1191
- effectiveTier === agentOptions.tier ? agentOptions : { ...agentOptions, tier: effectiveTier };
1413
+ // The strength becomes an Agent tier here and only here, through the user's
1414
+ // table and nothing else. Unmapped resolves to no tier — the call takes the
1415
+ // agent's own default, and no catalogue key is ever inferred from a matching
1416
+ // spelling. One hop: the tier this yields is never looked up as a strength
1417
+ // in turn. There is no catalogue check left to do, because the only tier
1418
+ // that can appear here already passed one at run start.
1419
+ //
1420
+ // A call that named no strength takes that same untiered path rather than a
1421
+ // default rung: see the note in strengths.ts on why a default would have
1422
+ // outranked the agent type's own tier on every unlabelled dispatch.
1423
+ const strength = agentOptions.strength;
1424
+ const effectiveTier = strength === undefined ? undefined : strengths.get(strength);
1425
+ if (strength !== undefined) logStrengthRouting(strength, effectiveTier);
1192
1426
 
1193
1427
  // Deterministic resume key: assigned at lexical call time, before the limiter.
1194
1428
  const callIndex = nextCallIndex();
1195
1429
  const callHash = hashAgentCall(
1196
1430
  promptText,
1197
1431
  assignedPhase,
1198
- tieredOptions,
1432
+ agentOptions,
1433
+ effectiveTier,
1199
1434
  options.routingPolicy,
1200
1435
  options.routingPolicyFingerprint,
1201
1436
  );
@@ -1633,13 +1868,7 @@ export async function runWorkflow<T = unknown>(
1633
1868
  });
1634
1869
  }
1635
1870
  const callIndex = nextCallIndex();
1636
- const resolved = options.loadSavedWorkflow?.(String(nameOrScript));
1637
- const childScript = resolved?.script ?? String(nameOrScript);
1638
- // A named script carries its own shipped-ness; a raw script literal is the
1639
- // caller's own text and shares the caller's. Inheriting the frame's flag for
1640
- // a resolved script would let a user script run a built-in under the strict
1641
- // tier check — a hard failure on any host that renamed the shipped tiers.
1642
- const childShippedScript = resolved ? resolved.shippedScript === true : options.shippedScript === true;
1871
+ const childScript = options.loadSavedWorkflow?.(String(nameOrScript)) ?? String(nameOrScript);
1643
1872
  const workflowName = String(nameOrScript);
1644
1873
  const argsPresent = childArgs !== undefined;
1645
1874
  const callHash = hashIdentity({
@@ -1648,9 +1877,14 @@ export async function runWorkflow<T = unknown>(
1648
1877
  script: childScript,
1649
1878
  argsPresent,
1650
1879
  args: argsPresent ? childArgs : null,
1651
- // Part of the identity because it decides how the child treats a tier
1652
- // name the host does not define: dropped, or fatal.
1653
- shippedScript: childShippedScript,
1880
+ // The table decides which tier the child's calls actually request. A
1881
+ // strength resolved inside *this* frame is already covered per call,
1882
+ // because the agent hash keys on the tier that will be requested. The
1883
+ // child's calls are not: the parent caches one value for the whole child
1884
+ // frame, keyed on the whole-catalogue fingerprint — which does not move
1885
+ // when only this table changed. Without it, editing the table and
1886
+ // resuming would replay a child that had been routed the old way.
1887
+ strengths: canonicalStrengths(strengths),
1654
1888
  routingPolicyFingerprint: options.routingPolicyFingerprint ?? null,
1655
1889
  });
1656
1890
  const cached = options.resumeJournal?.get(`${runId}:${callIndex}`);
@@ -1671,7 +1905,6 @@ export async function runWorkflow<T = unknown>(
1671
1905
  const child = await runWorkflow(childScript, {
1672
1906
  ...options,
1673
1907
  args: childArgs,
1674
- shippedScript: childShippedScript,
1675
1908
  sharedRuntime: shared,
1676
1909
  sharedStore: store,
1677
1910
  workflowDepth: workflowDepth + 1,
@@ -1724,7 +1957,7 @@ export async function runWorkflow<T = unknown>(
1724
1957
  };
1725
1958
  const verify = async (
1726
1959
  item: unknown,
1727
- opts: { reviewers?: number; threshold?: number; lens?: string | string[] } = {},
1960
+ opts: { reviewers?: number; threshold?: number; lens?: string | string[]; strength?: WorkflowStrength } = {},
1728
1961
  ): Promise<{
1729
1962
  real: boolean;
1730
1963
  realCount: number;
@@ -1743,7 +1976,10 @@ export async function runWorkflow<T = unknown>(
1743
1976
  (_v, i) => () =>
1744
1977
  agent(
1745
1978
  `Adversarially review whether the following is REAL/correct. Try to refute it; default to real=false if unsure.${lenses.length ? ` Focus lens: ${lenses[i % lenses.length]}.` : ""}\n\n${claim}`,
1746
- { label: `verify ${i + 1}`, schema: VERIFY_SCHEMA },
1979
+ // Defaults low: numerous, narrow, and aggregated by the vote
1980
+ // below, which is what carries the gate rather than any single
1981
+ // reviewer's depth. A caller who wants a deeper gate says so.
1982
+ { label: `verify ${i + 1}`, strength: opts.strength ?? "low", schema: VERIFY_SCHEMA },
1747
1983
  ),
1748
1984
  ),
1749
1985
  )
@@ -1764,7 +2000,7 @@ export async function runWorkflow<T = unknown>(
1764
2000
  };
1765
2001
  const judgePanel = async (
1766
2002
  attempts: unknown[],
1767
- opts: { judges?: number; rubric?: string } = {},
2003
+ opts: { judges?: number; rubric?: string; strength?: WorkflowStrength } = {},
1768
2004
  ): Promise<
1769
2005
  { index: number; attempt: unknown; score: number; judgments: Array<{ score: number; reason?: string }> } | undefined
1770
2006
  > => {
@@ -1788,6 +2024,8 @@ export async function runWorkflow<T = unknown>(
1788
2024
  `Score this candidate from 0 to 1 on: ${rubric}. Reply with the score.\n\nCandidate:\n${text}`,
1789
2025
  {
1790
2026
  label: `judge ${idx + 1}.${j + 1}`,
2027
+ // Same shape as verify: many narrow scores, aggregated.
2028
+ strength: opts.strength ?? "low",
1791
2029
  schema: JUDGE_SCHEMA,
1792
2030
  },
1793
2031
  ),
@@ -1867,11 +2105,15 @@ export async function runWorkflow<T = unknown>(
1867
2105
  const completenessCheck = async (
1868
2106
  taskArgs: unknown,
1869
2107
  results: unknown,
2108
+ opts: { strength?: WorkflowStrength } = {},
1870
2109
  ): Promise<{ complete: boolean; missing?: string[] } | null> => {
1871
2110
  emitRuntimeEvent({ type: "quality", stage: "start", helper: "completenessCheck" });
1872
2111
  const verdict = await agent(
1873
2112
  `Given the task and the results gathered so far, list what is still MISSING (modalities not covered, claims unverified, gaps). Be specific and concise.\n\nTask:\n${JSON.stringify(taskArgs)}\n\nResults so far:\n${JSON.stringify(results).slice(0, 4000)}`,
1874
- { label: "completeness critic", schema: COMPLETENESS_SCHEMA },
2113
+ // Defaults medium, not low like the other two gates: one call, no vote to
2114
+ // average over, and an open-ended read of the whole task rather than one
2115
+ // claim, so there is no redundancy here to carry a shallow verdict.
2116
+ { label: "completeness critic", strength: opts.strength ?? "medium", schema: COMPLETENESS_SCHEMA },
1875
2117
  );
1876
2118
  emitRuntimeEvent({ type: "quality", stage: "end", helper: "completenessCheck" });
1877
2119
  return verdict as { complete: boolean; missing?: string[] } | null;
@@ -1917,6 +2159,7 @@ export async function runWorkflow<T = unknown>(
1917
2159
  // it is gated on the agent counter + abort (not budget). On resume the
1918
2160
  // human's reply replays by callIndex exactly like a cached agent().
1919
2161
  const checkpoint = async (promptText: string, checkpointOptions: CheckpointOptions = {}) => {
2162
+ validateCallOptions("checkpoint", checkpointOptions, CHECKPOINT_OPTION_KEYS);
1920
2163
  throwIfAborted();
1921
2164
  if (typeof promptText !== "string") throw new TypeError("checkpoint(promptText, options?) needs a prompt string");
1922
2165
  const callIndex = nextCallIndex();