@signalridge/pi-workflows 1.5.0 → 1.7.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.
Files changed (29) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/README.md +207 -48
  3. package/package.json +2 -2
  4. package/skills/workflow-authoring/SKILL.md +44 -14
  5. package/skills/workflow-authoring/examples/defensive-json-parsing.js +1 -1
  6. package/skills/workflow-authoring/examples/phased-budgets.js +1 -1
  7. package/skills/workflow-authoring/examples/saved-nested-workflows.js +1 -1
  8. package/skills/workflow-authoring/examples/structured-output.js +1 -1
  9. package/skills/workflow-authoring/references/capabilities.md +7 -7
  10. package/skills/workflow-authoring/references/capability-details.md +24 -10
  11. package/skills/workflow-authoring/references/debugging.md +1 -1
  12. package/skills/workflow-authoring/references/lifecycle.md +5 -1
  13. package/skills/workflow-authoring/references/quality-helpers.md +2 -2
  14. package/skills/workflow-authoring/references/registry-ownership.md +4 -4
  15. package/skills/workflow-authoring/references/review.md +8 -4
  16. package/skills/workflow-authoring/references/runtime.md +2 -2
  17. package/skills/workflow-authoring/references/specialized-helpers.md +1 -1
  18. package/skills/workflow-authoring/references/versions.md +1 -1
  19. package/skills/workflow-patterns/SKILL.md +1 -1
  20. package/skills/workflow-review/SKILL.md +6 -4
  21. package/src/builtins.ts +23 -23
  22. package/src/capabilities.ts +37 -12
  23. package/src/engine.ts +70 -26
  24. package/src/index.ts +243 -70
  25. package/src/journal.ts +0 -14
  26. package/src/rpc-client.ts +14 -1
  27. package/src/runtime.ts +452 -103
  28. package/src/strengths.ts +93 -0
  29. package/src/workflow-settings.ts +95 -1
@@ -34,6 +34,14 @@ export const WORKFLOW_CAPABILITY_CONTRACT_VERSION = "2.0.0";
34
34
  /**
35
35
  * The runtime globals a script can rely on. `options` documents each helper's
36
36
  * accepted inputs with defaults; `constraints` states the behavioral contract.
37
+ *
38
+ * The strength option's type is spelled out on each of the four helpers that
39
+ * take it, rather than derived from `WORKFLOW_STRENGTHS`, because this array
40
+ * must stay literal-only: `check:capabilities` evaluates it in an empty VM
41
+ * realm so the published contract can be read without executing this package.
42
+ * `strengths.test.ts` pins the four copies against the vocabulary the runtime
43
+ * enforces, which is the drift protection a shared constant would otherwise
44
+ * have provided.
37
45
  */
38
46
  export const WORKFLOW_CAPABILITIES: readonly CapabilityEntry[] = [
39
47
  {
@@ -45,17 +53,17 @@ export const WORKFLOW_CAPABILITIES: readonly CapabilityEntry[] = [
45
53
  { name: "phase", kind: "string", optional: true, default: "current phase" },
46
54
  { name: "schema", kind: "plain JSON Schema", optional: true, default: "none" },
47
55
  {
48
- name: "tier",
49
- kind: "agent tier key",
56
+ name: "strength",
57
+ kind: '"low" | "medium" | "high"',
50
58
  optional: true,
51
- default: "the agent's own tier, else agentTiers.defaultTier",
59
+ default: "no tier is sent; the agent's own tier, else agentTiers.defaultTier",
52
60
  },
53
61
  { name: "isolation", kind: '"worktree"', optional: true, default: "pi-subagents worktree policy" },
54
62
  { name: "thread", kind: "string", optional: true, default: "fresh session per call" },
55
63
  { name: "toolset", kind: "string", optional: true, default: "configured host/agent toolset hint" },
56
64
  { name: "excludeTools", kind: "string[]", optional: true, default: "none" },
57
65
  { name: "agentType", kind: "string", optional: true, default: "general-purpose" },
58
- { name: "timeoutMs", kind: "number | null", optional: true, default: "300000 (5min); null disables" },
66
+ { name: "timeoutMs", kind: "number | null", optional: true, default: "1800000 (30min); null disables" },
59
67
  { name: "retries", kind: "number", optional: true, default: "run retry count" },
60
68
  ],
61
69
  constraints: [
@@ -64,11 +72,15 @@ export const WORKFLOW_CAPABILITIES: readonly CapabilityEntry[] = [
64
72
  "resume replays only the longest unchanged prefix; the first miss and every later call execute live",
65
73
  "replayed calls do not consume the real-dispatch maxAgents or token/phase budgets",
66
74
  "spawnKeys rotate by generation on resume so pi-subagents never raises a fingerprint conflict",
67
- "tier names a key in the host's agentTiers catalogue; a key it does not define is rejected before dispatch",
75
+ "strength is this package's own vocabulary, not an agentTiers key; a word outside low/medium/high is rejected before dispatch",
76
+ "a strengths table alone binds a strength to an agent tier; an unmapped strength dispatches with no tier and takes the agent's own default",
77
+ "a call that names no strength also dispatches with no tier; there is no default strength, so an unlabelled call never outranks the agent type's own tier",
78
+ "an unconfigured host uses a shipped default table: each strength on the catalogue tier of the same name, where the host defines one",
68
79
  "resolveAgentTier in pi-subagents owns model, thinking, clamping, availability, and the resolution snapshot",
69
- "there is no per-call model or thinking: a tier is the only model policy a workflow can express",
80
+ "there is no per-call model, thinking, or tier: a strength is the only model policy a workflow can express",
70
81
  "same-thread calls must be sequential and preserve a stable workflow thread name",
71
82
  "threads cannot be combined with worktree isolation",
83
+ "an option name outside this list is rejected before dispatch, not ignored; tier, model, and thinking name their replacement",
72
84
  ],
73
85
  },
74
86
  {
@@ -136,6 +148,7 @@ export const WORKFLOW_CAPABILITIES: readonly CapabilityEntry[] = [
136
148
  { name: "reviewers", kind: "number", optional: true, default: "2" },
137
149
  { name: "threshold", kind: "number", optional: true, default: "0.5" },
138
150
  { name: "lens", kind: "string | string[]", optional: true, default: "none" },
151
+ { name: "strength", kind: '"low" | "medium" | "high"', optional: true, default: '"low"' },
139
152
  ],
140
153
  constraints: ["threshold comparison is inclusive; real is false when no reviewer succeeds"],
141
154
  },
@@ -146,6 +159,7 @@ export const WORKFLOW_CAPABILITIES: readonly CapabilityEntry[] = [
146
159
  options: [
147
160
  { name: "judges", kind: "number", optional: true, default: "3" },
148
161
  { name: "rubric", kind: "string", optional: true, default: "overall quality and correctness" },
162
+ { name: "strength", kind: '"low" | "medium" | "high"', optional: true, default: '"low"' },
149
163
  ],
150
164
  constraints: ["highest mean score wins with stable input index as the tie-break; empty input returns undefined"],
151
165
  },
@@ -164,8 +178,8 @@ export const WORKFLOW_CAPABILITIES: readonly CapabilityEntry[] = [
164
178
  {
165
179
  name: "completenessCheck",
166
180
  classification: "runtime-global",
167
- signature: "completenessCheck(taskArgs, results) => Promise<{ complete, missing? } | null>",
168
- options: [],
181
+ signature: "completenessCheck(taskArgs, results, options?) => Promise<{ complete, missing? } | null>",
182
+ options: [{ name: "strength", kind: '"low" | "medium" | "high"', optional: true, default: '"medium"' }],
169
183
  constraints: ["only the first 4,000 characters of serialized result evidence are sent to the critic"],
170
184
  },
171
185
  {
@@ -199,6 +213,7 @@ export const WORKFLOW_CAPABILITIES: readonly CapabilityEntry[] = [
199
213
  constraints: [
200
214
  "consumes one agent slot and no tokens",
201
215
  "journaled answers replay only within an unchanged resume prefix",
216
+ "an option name outside this list is rejected rather than ignored, since a dropped headless silently auto-approves",
202
217
  ],
203
218
  },
204
219
  { name: "log", classification: "runtime-global", signature: "log(message) => void", options: [], constraints: [] },
@@ -269,9 +284,15 @@ export const WORKFLOW_CAPABILITIES: readonly CapabilityEntry[] = [
269
284
  {
270
285
  name: "concurrency",
271
286
  classification: "workflow-tool-input",
272
- signature: "concurrency?: number",
287
+ signature: "concurrency?: number = the host's pi-subagents maxConcurrent",
273
288
  options: [],
274
- constraints: ["runtime clamps to 1..16"],
289
+ constraints: [
290
+ "runtime clamps to 1..64",
291
+ "read live from the peer at each start and resume, and also the ceiling: a request above the pool is clamped to it, and the run log records the clamp",
292
+ "16 is only the fallback for a peer that publishes no pool size",
293
+ "pi-subagents maxConcurrent stays the authoritative throttle; one setting governs the whole fleet",
294
+ "that pool is typically small (4 slots by default), so a wide fan-out runs in waves rather than all at once",
295
+ ],
275
296
  },
276
297
  {
277
298
  name: "agentRetries",
@@ -283,9 +304,13 @@ export const WORKFLOW_CAPABILITIES: readonly CapabilityEntry[] = [
283
304
  {
284
305
  name: "agentTimeoutMs",
285
306
  classification: "workflow-tool-input",
286
- signature: "agentTimeoutMs?: number = 300000",
307
+ signature: "agentTimeoutMs?: number = 1800000",
287
308
  options: [],
288
- constraints: ["null disables the hard timeout; default 300000ms (5min)"],
309
+ constraints: [
310
+ "null disables the hard timeout; default 1800000ms (30min)",
311
+ "it bounds a hang only; an agent's real budget is the host's turn, tool and token ceiling",
312
+ "the clock restarts when the host reports the agent left its queue, so waiting for a slot is not charged as work",
313
+ ],
289
314
  },
290
315
  {
291
316
  name: "tokenBudget",
package/src/engine.ts CHANGED
@@ -61,6 +61,7 @@ import {
61
61
  type WorkflowRunResult,
62
62
  type WorkflowRuntimeEvent,
63
63
  } from "./runtime.js";
64
+ import type { WorkflowStrengthTable } from "./strengths.js";
64
65
 
65
66
  export const MAX_ATTEMPTS_PER_NODE = 3;
66
67
 
@@ -88,8 +89,19 @@ export interface ScriptStartOptions {
88
89
  concurrency?: number;
89
90
  agentRetries?: number;
90
91
  tokenBudget?: number | null;
91
- /** True for a script this package ships; see WorkflowRunOptions.shippedScript. */
92
- shippedScript?: boolean;
92
+ /**
93
+ * The user's strength → Agent tier table; see WorkflowRunOptions.strengths.
94
+ *
95
+ * Deliberately absent from `frozenOptions`. The frozen set is the run's
96
+ * inputs — budget, scale, toolset — which a resume must not silently
97
+ * renegotiate. Routing is not an input: a resume already re-reads the host's
98
+ * tier catalogue, and a table frozen against a catalogue that has since moved
99
+ * would be the one thing in the run still pointing at the old world.
100
+ *
101
+ * Being unfrozen makes supplying it every resume's job, including the resumes
102
+ * the engine starts itself — see {@link WorkflowEngine.strengths}.
103
+ */
104
+ strengths?: WorkflowStrengthTable;
93
105
  agentTimeoutMs?: number | null;
94
106
  toolset?: string;
95
107
  excludeTools?: string[];
@@ -105,13 +117,8 @@ export interface ScriptStartOptions {
105
117
  timeoutMs?: number;
106
118
  },
107
119
  ) => Promise<unknown>;
108
- /**
109
- * Resolve a nested `workflow(name)` reference. The result reports whether the
110
- * script it returned ships with this package, because that decides how the
111
- * child frame treats a tier name the host does not define — see
112
- * `WorkflowRunOptions.shippedScript`.
113
- */
114
- loadSavedWorkflow?: (name: string) => { script: string; shippedScript?: boolean } | undefined;
120
+ /** Resolve a nested `workflow(name)` reference to the script it names. */
121
+ loadSavedWorkflow?: (name: string) => string | undefined;
115
122
  mainModel?: string;
116
123
  }
117
124
 
@@ -129,12 +136,32 @@ interface AgentLifecycleWaiter {
129
136
  generation: number;
130
137
  onUsage?: (usage: AgentUsage) => void;
131
138
  onHistory?: (history: unknown[]) => void;
139
+ /** Told once when the host reports this agent left the queue and started. */
140
+ onRunning?: () => void;
141
+ startedNotified: boolean;
132
142
  executionGeneration: number;
133
143
  resolve: (response: ManagedSpawnResponse) => void;
134
144
  reject: (error: unknown) => void;
135
145
  settled: boolean;
136
146
  }
137
147
 
148
+ /**
149
+ * Tell a waiter once that its agent started.
150
+ *
151
+ * Once, because the wall clock is restarted from this point: a repeated report
152
+ * — a duplicate event, or a reply that raced its own lifecycle event — would
153
+ * keep pushing the deadline out and quietly turn the timeout off.
154
+ */
155
+ function notifyAgentRunning(waiter: AgentLifecycleWaiter): void {
156
+ if (waiter.startedNotified || waiter.settled) return;
157
+ waiter.startedNotified = true;
158
+ try {
159
+ waiter.onRunning?.();
160
+ } catch {
161
+ /* the clock is best effort; never fail a live agent over it */
162
+ }
163
+ }
164
+
138
165
  interface AgentTerminalDelivery {
139
166
  response: ManagedSpawnResponse;
140
167
  usage?: AgentUsage;
@@ -151,14 +178,7 @@ export interface ScriptRunState {
151
178
  /** Frozen run parameters — resume reuses the original values, ignoring new budget/toolset overrides. */
152
179
  frozenOptions: Pick<
153
180
  ScriptStartOptions,
154
- | "tokenBudget"
155
- | "maxAgents"
156
- | "concurrency"
157
- | "agentRetries"
158
- | "agentTimeoutMs"
159
- | "toolset"
160
- | "shippedScript"
161
- | "excludeTools"
181
+ "tokenBudget" | "maxAgents" | "concurrency" | "agentRetries" | "agentTimeoutMs" | "toolset" | "excludeTools"
162
182
  >;
163
183
  /** Abort controller for this run's script execution. */
164
184
  controller: AbortController;
@@ -289,6 +309,18 @@ export class WorkflowEngine {
289
309
  onRunSettled: (() => void) | undefined;
290
310
  /** Optional in-process observer for phase/task/quality runtime events. */
291
311
  onRuntimeEvent: ((runId: string, event: WorkflowRuntimeEvent) => void) | undefined;
312
+ /**
313
+ * The user's current strength → Agent tier table, read fresh per execution.
314
+ *
315
+ * A callback rather than a value because the table is deliberately unfrozen:
316
+ * "read fresh at each start and resume" has to hold for the resumes the
317
+ * engine starts on its own — `control("resume")` and the provider-limit
318
+ * retry — which have no caller to pass options for them. Without it those
319
+ * resume with no table at all, and since a call's identity keys on the tier
320
+ * it requested, every already-finished call would miss its journal entry and
321
+ * re-run untabled: the exact re-spend this table exists to prevent.
322
+ */
323
+ strengths: (() => WorkflowStrengthTable | undefined) | undefined;
292
324
  private readonly waiters = new Map<
293
325
  string,
294
326
  Set<{ resolve: (run: ScriptRun) => void; reject: (e: unknown) => void; settled: boolean; cleanup: () => void }>
@@ -420,6 +452,10 @@ export class WorkflowEngine {
420
452
  if (updateCallTierIdentity(state, callNodeId, data.tier)) {
421
453
  this.persistCallTierIdentity(state, callNodeId);
422
454
  }
455
+ // A start is the moment the host admitted this agent from its queue. It is
456
+ // the only signal that separates waiting for a slot from doing the work, so
457
+ // the call's wall clock is restarted here rather than at dispatch.
458
+ if (eventName === "subagents:started" && waiterMatches && waiter) notifyAgentRunning(waiter);
423
459
  // Created/started lifecycle facts carry managed identity before the RPC
424
460
  // reply is necessarily available. They are observation-only here; terminal
425
461
  // ownership is handled below once a completion/failure fact arrives.
@@ -628,7 +664,6 @@ export class WorkflowEngine {
628
664
  const runId = randomUUID();
629
665
  const scriptHash = hashScript(script);
630
666
  const toolset = options.toolset;
631
- const shippedScript = options.shippedScript === true ? true : undefined;
632
667
  const durableArgs = cloneWorkflowArgs(options.args);
633
668
  const runtimeArgs = cloneWorkflowArgs(durableArgs);
634
669
  const journalArgs = cloneWorkflowArgs(durableArgs);
@@ -657,7 +692,6 @@ export class WorkflowEngine {
657
692
  meta,
658
693
  ...(durableArgs === undefined ? {} : { args: durableArgs }),
659
694
  frozenArgsPresent,
660
- ...(shippedScript === undefined ? {} : { shippedScript }),
661
695
  ...(toolset ? { toolset } : {}),
662
696
  ...(frozenMaxAgents !== undefined ? { frozenMaxAgents } : {}),
663
697
  ...(frozenConcurrency !== undefined ? { frozenConcurrency } : {}),
@@ -686,7 +720,6 @@ export class WorkflowEngine {
686
720
  meta,
687
721
  ...(journalArgs === undefined ? {} : { args: journalArgs }),
688
722
  frozenArgsPresent,
689
- ...(shippedScript === undefined ? {} : { shippedScript }),
690
723
  ...(toolset ? { toolset } : {}),
691
724
  ...(frozenMaxAgents !== undefined ? { frozenMaxAgents } : {}),
692
725
  ...(frozenConcurrency !== undefined ? { frozenConcurrency } : {}),
@@ -705,7 +738,6 @@ export class WorkflowEngine {
705
738
  ...(frozenAgentRetries !== undefined ? { agentRetries: frozenAgentRetries } : {}),
706
739
  ...(frozenAgentTimeoutMs !== undefined ? { agentTimeoutMs: frozenAgentTimeoutMs } : {}),
707
740
  ...(options.toolset ? { toolset: options.toolset } : {}),
708
- ...(shippedScript === undefined ? {} : { shippedScript }),
709
741
  ...(options.excludeTools ? { excludeTools: [...options.excludeTools] } : {}),
710
742
  },
711
743
  controller: new AbortController(),
@@ -802,11 +834,15 @@ export class WorkflowEngine {
802
834
  this.setWorkflowStatus(run, "running");
803
835
  state.controller = new AbortController();
804
836
  // Freeze semantics: original run parameters and args win over new options.
805
- // Only signal/confirm/background/loadSavedWorkflow/mainModel are per-resume context;
806
- // input/budget/scale/toolset are frozen from the original start.
837
+ // Only signal/confirm/background/loadSavedWorkflow/mainModel/strengths are
838
+ // per-resume context; input/budget/scale/toolset are frozen from the
839
+ // original start.
807
840
  const frozen = state.frozenOptions;
808
841
  const resumeOptions: ScriptStartOptions = {
809
842
  ...options,
843
+ // `??`, not an overwrite: a caller that passed a table meant it, and only
844
+ // a caller that passed none is asking the engine to read the current one.
845
+ strengths: options.strengths ?? this.strengths?.(),
810
846
  args: resumeArgs,
811
847
  tokenBudget: frozen.tokenBudget,
812
848
  maxAgents: frozen.maxAgents,
@@ -814,7 +850,6 @@ export class WorkflowEngine {
814
850
  agentRetries: frozen.agentRetries,
815
851
  agentTimeoutMs: frozen.agentTimeoutMs,
816
852
  toolset: frozen.toolset,
817
- shippedScript: frozen.shippedScript,
818
853
  excludeTools: frozen.excludeTools ? [...frozen.excludeTools] : undefined,
819
854
  };
820
855
  state.bufferedTerminals.clear();
@@ -999,6 +1034,10 @@ export class WorkflowEngine {
999
1034
  : {
1000
1035
  routingPolicy: protocol.routingPolicy,
1001
1036
  routingPolicyFingerprint: protocol.routingPolicyFingerprint,
1037
+ // Read live at each start and resume, never frozen onto the run:
1038
+ // the pool is a host setting the user can change between them, and
1039
+ // a run that outlived a narrowing would dispatch into a queue.
1040
+ ...(protocol.maxConcurrent === undefined ? {} : { hostMaxConcurrent: protocol.maxConcurrent }),
1002
1041
  }),
1003
1042
  concurrency: options.concurrency,
1004
1043
  agentRetries: options.agentRetries,
@@ -1010,7 +1049,7 @@ export class WorkflowEngine {
1010
1049
  executionNonce: `${generation}-${randomUUID()}`,
1011
1050
  resumeJournal,
1012
1051
  toolset: options.toolset,
1013
- shippedScript: options.shippedScript,
1052
+ strengths: options.strengths,
1014
1053
  excludeTools: options.excludeTools,
1015
1054
  confirm: options.confirm,
1016
1055
  loadSavedWorkflow: options.loadSavedWorkflow,
@@ -1276,11 +1315,17 @@ export class WorkflowEngine {
1276
1315
  executionGeneration,
1277
1316
  onUsage: runOptions.onUsage,
1278
1317
  onHistory: runOptions.onHistory,
1318
+ onRunning: runOptions.onRunning,
1319
+ startedNotified: false,
1279
1320
  settled: false,
1280
1321
  resolve,
1281
1322
  reject,
1282
1323
  };
1283
1324
  state.agentWaiters.set(pendingId, waiter);
1325
+ // A spawn that was admitted straight away is already running, and its
1326
+ // `subagents:started` event fired before this waiter existed. Only a
1327
+ // reply that says "queued" still has a start to wait for.
1328
+ if (typeof response !== "string" && response.state !== "queued") notifyAgentRunning(waiter);
1284
1329
  externalSignal.addEventListener("abort", waitAbort, { once: true });
1285
1330
  if (externalSignal.aborted) {
1286
1331
  waitAbort();
@@ -1750,7 +1795,6 @@ export class WorkflowEngine {
1750
1795
  ...(run.frozenAgentRetries !== undefined ? { agentRetries: run.frozenAgentRetries } : {}),
1751
1796
  ...(run.frozenAgentTimeoutMs !== undefined ? { agentTimeoutMs: run.frozenAgentTimeoutMs } : {}),
1752
1797
  ...(run.toolset ? { toolset: run.toolset } : {}),
1753
- ...(run.shippedScript === undefined ? {} : { shippedScript: run.shippedScript }),
1754
1798
  ...(run.frozenExcludeTools ? { excludeTools: [...run.frozenExcludeTools] } : {}),
1755
1799
  },
1756
1800
  controller: new AbortController(),