@signalridge/pi-workflows 1.8.0 → 1.9.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
@@ -123,6 +123,11 @@ export interface WorkflowAgentRunner {
123
123
 
124
124
  export interface AgentRunOptions {
125
125
  label?: string;
126
+ /** Observation-only scope; never part of call hashing or dispatch policy. */
127
+ graphId?: string;
128
+ taskKey?: string;
129
+ taskId?: string;
130
+ phase?: string;
126
131
  sessionName?: string;
127
132
  /** Plain JSON Schema the result must satisfy; the runtime parses+validates client-side. */
128
133
  schema?: unknown;
@@ -212,6 +217,10 @@ export interface CheckpointOptions {
212
217
 
213
218
  export interface WorkflowRuntimeEvent {
214
219
  type: "phase" | "workflow" | "quality" | "control-attempt" | "task";
220
+ /** Resolver-proven source classification for nested workflow observations. */
221
+ sourceKind?: "saved" | "inline";
222
+ /** Observation-only frame-scoped hash of the exact attributed phase title. */
223
+ phaseKey?: string;
215
224
  [key: string]: unknown;
216
225
  }
217
226
 
@@ -291,6 +300,8 @@ export interface WorkflowRunOptions {
291
300
  agentTimeoutMs?: number | null;
292
301
  runId?: string;
293
302
  resumeJournal?: Map<string, JournalEntry>;
303
+ /** Accepted parent-frame nested generation watermarks, independent of replay eligibility. */
304
+ resumeWorkflowGenerations?: ReadonlyMap<number, number>;
294
305
  resumeFromRunId?: string;
295
306
  toolset?: string;
296
307
  excludeTools?: string[];
@@ -304,14 +315,23 @@ export interface WorkflowRunOptions {
304
315
  /** Resolve a nested `workflow(name)` reference to the script it names. */
305
316
  loadSavedWorkflow?: (name: string) => string | undefined;
306
317
  confirm?: (promptText: string, options: CheckpointOptions, signal?: AbortSignal) => Promise<unknown>;
307
- onLog?: (message: string) => void;
318
+ onLog?: (message: string, scope?: { frameId: string }) => void;
308
319
  onPhase?: (title: string) => void;
309
320
  onRuntimeEvent?: (event: WorkflowRuntimeEvent) => void;
310
321
  onAgentStart?: (event: {
311
322
  id: string;
312
323
  label: string;
313
324
  phase?: string;
325
+ /** Observation-only exact title identity, scoped to this call's frame. */
326
+ phaseKey?: string;
314
327
  prompt: string;
328
+ frameId?: string;
329
+ callIndex?: number;
330
+ agentType?: string;
331
+ replayed?: boolean;
332
+ graphId?: string;
333
+ taskKey?: string;
334
+ taskId?: string;
315
335
  model?: string;
316
336
  tier?: string;
317
337
  }) => void;
@@ -319,6 +339,7 @@ export interface WorkflowRunOptions {
319
339
  id: string;
320
340
  label: string;
321
341
  phase?: string;
342
+ phaseKey?: string;
322
343
  tier?: string;
323
344
  result: unknown;
324
345
  tokens?: number;
@@ -392,6 +413,45 @@ export const MAX_AGENT_RETRIES = 3;
392
413
  */
393
414
  export const DEFAULT_AGENT_TIMEOUT_MS: number | null = 1_800_000;
394
415
  export const MAX_SCRIPT_SYNC_MS = 1_000;
416
+ /** Identities are retained verbatim, never clipped like display text. */
417
+ export const MAX_RUNTIME_IDENTITY_LENGTH = 2_048;
418
+ const MAX_ROOT_FRAME_LENGTH = 1_024;
419
+ const MAX_EXECUTION_NONCE_LENGTH = 512;
420
+ const MAX_WORKFLOW_GENERATION = 1_000_000;
421
+
422
+ function validateRuntimeIdentity(value: string, label: string, limit: number): void {
423
+ if (typeof value !== "string" || value.length === 0 || value.length > limit) {
424
+ throw new WorkflowError(
425
+ `${label} must be a non-empty identity of at most ${limit} characters`,
426
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
427
+ { recoverable: false },
428
+ );
429
+ }
430
+ }
431
+
432
+ function copyWorkflowGenerations(values?: ReadonlyMap<number, number>): Map<number, number> {
433
+ const generations = new Map<number, number>();
434
+ for (const [index, generation] of values ?? []) {
435
+ if (
436
+ !Number.isInteger(index) ||
437
+ index < 0 ||
438
+ index >= MAX_CALLS_PER_WORKFLOW ||
439
+ !Number.isInteger(generation) ||
440
+ generation < 1 ||
441
+ generation > MAX_WORKFLOW_GENERATION
442
+ ) {
443
+ throw new WorkflowError(
444
+ "Invalid nested workflow generation watermark",
445
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
446
+ {
447
+ recoverable: false,
448
+ },
449
+ );
450
+ }
451
+ generations.set(index, generation);
452
+ }
453
+ return generations;
454
+ }
395
455
 
396
456
  // ── Determinism guardrail ────────────────────────────────────────────────────
397
457
 
@@ -1030,7 +1090,10 @@ function validateValue(value: unknown, schema: SchemaNode, path: string): string
1030
1090
  if (type !== undefined) {
1031
1091
  const types = Array.isArray(type) ? type : [type];
1032
1092
  const typeOf = (v: unknown): string => (v === null ? "null" : Array.isArray(v) ? "array" : typeof v);
1033
- if (!types.includes(typeOf(value))) {
1093
+ const matches = types.some((expected) =>
1094
+ expected === "integer" ? typeof value === "number" && Number.isInteger(value) : expected === typeOf(value),
1095
+ );
1096
+ if (!matches) {
1034
1097
  errors.push(`${path}: expected type ${types.join("/")}, got ${typeOf(value)}`);
1035
1098
  return errors;
1036
1099
  }
@@ -1230,7 +1293,13 @@ export interface WorkflowRuntimeBindings {
1230
1293
  /** Fan-out cancellation scope: parallel()/pipeline() batches mark themselves. */
1231
1294
  const fanoutScope = new AsyncLocalStorage<{ cancelled: boolean }>();
1232
1295
  /** Task-local phase attribution for named orchestration callbacks. */
1233
- const orchestrationScope = new AsyncLocalStorage<{ taskId: string; phase?: string }>();
1296
+ const orchestrationScope = new AsyncLocalStorage<{
1297
+ frameId: string;
1298
+ graphId: string;
1299
+ taskKey: string;
1300
+ taskId: string;
1301
+ phase?: string;
1302
+ }>();
1234
1303
 
1235
1304
  // ── The run ──────────────────────────────────────────────────────────────────
1236
1305
 
@@ -1249,6 +1318,14 @@ export async function runWorkflow<T = unknown>(
1249
1318
  const maxAgents = normalizeMaxAgents(options.maxAgents ?? MAX_AGENTS_PER_RUN);
1250
1319
  const agentTimeoutMs = normalizeAgentTimeout(options.agentTimeoutMs);
1251
1320
  const runId = options.runId ?? `run-${started.toString(36)}`;
1321
+ // Reserve room for the one supported child level, nonce and graph/call suffixes.
1322
+ validateRuntimeIdentity(
1323
+ runId,
1324
+ "runId",
1325
+ options.workflowDepth ? MAX_RUNTIME_IDENTITY_LENGTH - 64 : MAX_ROOT_FRAME_LENGTH,
1326
+ );
1327
+ if (options.executionNonce !== undefined)
1328
+ validateRuntimeIdentity(options.executionNonce, "executionNonce", MAX_EXECUTION_NONCE_LENGTH);
1252
1329
  const baseCwd = options.cwd ?? process.cwd();
1253
1330
  // The host's catalogue, known before the first call. A script cannot name a
1254
1331
  // tier, so this is not a typo check: it is what the strength table is resolved
@@ -1268,7 +1345,7 @@ export async function runWorkflow<T = unknown>(
1268
1345
  currentPhase: meta.phases?.[0]?.title,
1269
1346
  phaseBudgets: new Map(),
1270
1347
  callSeq: 0,
1271
- workflowGenerations: new Map(),
1348
+ workflowGenerations: copyWorkflowGenerations(options.resumeWorkflowGenerations),
1272
1349
  firstMiss: Number.POSITIVE_INFINITY,
1273
1350
  };
1274
1351
 
@@ -1302,7 +1379,7 @@ export async function runWorkflow<T = unknown>(
1302
1379
  const log = (message: unknown) => {
1303
1380
  const text = String(message);
1304
1381
  state.logs.push(text);
1305
- options.onLog?.(text);
1382
+ options.onLog?.(text, { frameId: runId });
1306
1383
  };
1307
1384
  if (concurrencyClampNotice !== undefined) log(concurrencyClampNotice);
1308
1385
 
@@ -1392,14 +1469,39 @@ export async function runWorkflow<T = unknown>(
1392
1469
  );
1393
1470
  };
1394
1471
 
1472
+ let graphSequence = 0;
1473
+ let helperSequence = 0;
1395
1474
  const emitRuntimeEvent = (event: WorkflowRuntimeEvent): void => {
1396
1475
  try {
1397
- options.onRuntimeEvent?.(event);
1476
+ const scope = orchestrationScope.getStore();
1477
+ options.onRuntimeEvent?.(
1478
+ structuredClone({
1479
+ // Origin task scope can belong to the parent; emission frame cannot.
1480
+ ...(scope ? { graphId: scope.graphId, taskKey: scope.taskKey, taskId: scope.taskId } : {}),
1481
+ ...event,
1482
+ // A child's forwarded events must never look like its parent's phase.
1483
+ frameId: runId,
1484
+ runId,
1485
+ }),
1486
+ );
1398
1487
  } catch {
1399
1488
  // Runtime observers are diagnostic surfaces and must not change execution.
1400
1489
  }
1401
1490
  };
1402
1491
 
1492
+ // Observation identity is independent of bounded display text and call hashing.
1493
+ const phaseObservation = (title: string | undefined) => ({
1494
+ phase: title,
1495
+ phaseKey:
1496
+ title === undefined
1497
+ ? undefined
1498
+ : createHash("sha256")
1499
+ .update(JSON.stringify([runId, title]))
1500
+ .digest("hex"),
1501
+ });
1502
+ const taskObservation = (scope: ReturnType<typeof orchestrationScope.getStore>) =>
1503
+ scope ? { graphId: scope.graphId, taskKey: scope.taskKey, taskId: scope.taskId } : {};
1504
+
1403
1505
  const phase = (title: string, phaseOptions?: { budget?: number }) => {
1404
1506
  state.currentPhase = title;
1405
1507
  if (!state.phases.includes(title)) state.phases.push(title);
@@ -1412,6 +1514,7 @@ export async function runWorkflow<T = unknown>(
1412
1514
  emitRuntimeEvent({
1413
1515
  type: "phase",
1414
1516
  title,
1517
+ phaseKey: phaseObservation(title).phaseKey,
1415
1518
  budget: typeof phaseOptions?.budget === "number" && phaseOptions.budget > 0 ? phaseOptions.budget : null,
1416
1519
  });
1417
1520
  };
@@ -1497,6 +1600,8 @@ export async function runWorkflow<T = unknown>(
1497
1600
  // synchronously, before suspending on the limiter below.
1498
1601
  const batch = fanoutScope.getStore();
1499
1602
 
1603
+ // Preserve inherited phase attribution (and therefore existing call hashes)
1604
+ // across nested frames; a task key names its origin graph, not the call's frame.
1500
1605
  const taskScope = orchestrationScope.getStore();
1501
1606
  const assignedPhase = agentOptions.phase ?? taskScope?.phase ?? state.currentPhase;
1502
1607
 
@@ -1546,6 +1651,13 @@ export async function runWorkflow<T = unknown>(
1546
1651
  const deltaKey = `${runId}:${callIndex}`;
1547
1652
 
1548
1653
  const label = requestedLabel || defaultAgentLabel(assignedPhase, shared.agentCount + 1);
1654
+ const observation = {
1655
+ frameId: runId,
1656
+ callIndex,
1657
+ agentType: agentOptions.agentType ?? "general-purpose",
1658
+ ...phaseObservation(assignedPhase),
1659
+ ...taskObservation(taskScope),
1660
+ };
1549
1661
 
1550
1662
  // Longest-unchanged-prefix resume: replay a cached result only while the
1551
1663
  // prefix is still intact. Once any call misses, it AND everything after it
@@ -1564,6 +1676,8 @@ export async function runWorkflow<T = unknown>(
1564
1676
  const replayTier = cached.tier ?? effectiveTier;
1565
1677
  options.onAgentStart?.({
1566
1678
  id: deltaKey,
1679
+ ...observation,
1680
+ replayed: true,
1567
1681
  label,
1568
1682
  phase: assignedPhase,
1569
1683
  prompt: promptText,
@@ -1572,7 +1686,7 @@ export async function runWorkflow<T = unknown>(
1572
1686
  options.onAgentEnd?.({
1573
1687
  id: deltaKey,
1574
1688
  label,
1575
- phase: assignedPhase,
1689
+ ...phaseObservation(assignedPhase),
1576
1690
  tier: replayTier,
1577
1691
  result: cached.result,
1578
1692
  tokens: 0,
@@ -1631,6 +1745,8 @@ export async function runWorkflow<T = unknown>(
1631
1745
 
1632
1746
  options.onAgentStart?.({
1633
1747
  id: deltaKey,
1748
+ ...observation,
1749
+ replayed: false,
1634
1750
  label,
1635
1751
  phase: assignedPhase,
1636
1752
  prompt: promptText,
@@ -1646,7 +1762,7 @@ export async function runWorkflow<T = unknown>(
1646
1762
  // failed attempt does not double-count the next one.
1647
1763
  let usage: AgentUsage | undefined;
1648
1764
  const recordTokens = (result: unknown): number => {
1649
- const tokens = usage && usage.total > 0 ? usage.total : estimateTokens(result) + estimateTokens(promptText);
1765
+ const tokens = usage === undefined ? estimateTokens(result) + estimateTokens(promptText) : usage.total;
1650
1766
  if (usage) {
1651
1767
  shared.tokenUsage.input += usage.input;
1652
1768
  shared.tokenUsage.output += usage.output;
@@ -1708,6 +1824,10 @@ export async function runWorkflow<T = unknown>(
1708
1824
  let restartTimeout: (() => void) | undefined;
1709
1825
  const runPromise = agentRunner.run(promptText + schemaInstruction + repairFeedback, {
1710
1826
  label,
1827
+ phase: assignedPhase,
1828
+ ...(taskScope
1829
+ ? { graphId: taskScope.graphId, taskKey: taskScope.taskKey, taskId: taskScope.taskId }
1830
+ : {}),
1711
1831
  sessionName: agentOptions.thread
1712
1832
  ? `workflow:${runId} thread:${agentOptions.thread}`
1713
1833
  : `workflow:${runId} ${label}`,
@@ -1812,7 +1932,7 @@ export async function runWorkflow<T = unknown>(
1812
1932
  options.onAgentEnd?.({
1813
1933
  id: deltaKey,
1814
1934
  label,
1815
- phase: assignedPhase,
1935
+ ...phaseObservation(assignedPhase),
1816
1936
  result,
1817
1937
  tier: resolvedTier,
1818
1938
  tokens,
@@ -1854,7 +1974,7 @@ export async function runWorkflow<T = unknown>(
1854
1974
  options.onAgentEnd?.({
1855
1975
  id: deltaKey,
1856
1976
  label,
1857
- phase: assignedPhase,
1977
+ ...phaseObservation(assignedPhase),
1858
1978
  result: null,
1859
1979
  tier: resolvedTier,
1860
1980
  tokens,
@@ -1985,11 +2105,20 @@ export async function runWorkflow<T = unknown>(
1985
2105
  const orchestrate = async (rawTasks: unknown, rawOptions?: unknown): Promise<OrchestrationResult> => {
1986
2106
  throwIfAborted();
1987
2107
  const normalized = normalizeOrchestration(rawTasks, rawOptions);
2108
+ const graphId = `${runId}:graph-${++graphSequence}`;
2109
+ const taskKeys = new Map(normalized.tasks.map((task, index) => [task.id, `${graphId}:task-${index}`]));
1988
2110
  const tasks = normalized.tasks.map((task) => ({
1989
2111
  ...task,
1990
2112
  run: (context: OrchestrationTaskContext) =>
1991
- orchestrationScope.run({ taskId: task.id, ...(task.phase === undefined ? {} : { phase: task.phase }) }, () =>
1992
- task.run(context),
2113
+ orchestrationScope.run(
2114
+ {
2115
+ frameId: runId,
2116
+ graphId,
2117
+ taskKey: taskKeys.get(task.id) ?? "",
2118
+ taskId: task.id,
2119
+ ...(task.phase === undefined ? {} : { phase: task.phase }),
2120
+ },
2121
+ () => task.run(context),
1993
2122
  ),
1994
2123
  }));
1995
2124
  return executeOrchestration(tasks, normalized.options, {
@@ -2000,7 +2129,15 @@ export async function runWorkflow<T = unknown>(
2000
2129
  return !workflowError.recoverable || workflowError.code === WorkflowErrorCode.PROVIDER_USAGE_LIMIT;
2001
2130
  },
2002
2131
  formatError: (error: unknown) => wrapError(error).message,
2003
- onEvent: emitRuntimeEvent,
2132
+ onEvent: (event) => {
2133
+ const task = normalized.tasks.find((candidate) => candidate.id === event.taskId);
2134
+ emitRuntimeEvent({
2135
+ ...event,
2136
+ graphId,
2137
+ taskKey: taskKeys.get(event.taskId),
2138
+ ...(task?.description === undefined ? {} : { description: task.description }),
2139
+ });
2140
+ },
2004
2141
  });
2005
2142
  };
2006
2143
 
@@ -2017,8 +2154,18 @@ export async function runWorkflow<T = unknown>(
2017
2154
  });
2018
2155
  }
2019
2156
  const callIndex = nextCallIndex();
2020
- const childScript = options.loadSavedWorkflow?.(String(nameOrScript)) ?? String(nameOrScript);
2157
+ const taskScope = orchestrationScope.getStore();
2158
+ const workflowPhase = taskScope?.phase ?? state.currentPhase;
2021
2159
  const workflowName = String(nameOrScript);
2160
+ const savedScript = options.loadSavedWorkflow?.(workflowName);
2161
+ const childScript = savedScript ?? workflowName;
2162
+ const observation = {
2163
+ ...phaseObservation(workflowPhase),
2164
+ ...taskObservation(taskScope),
2165
+ // Classify the resolver result, never source syntax or display text.
2166
+ sourceKind: savedScript === undefined ? ("inline" as const) : ("saved" as const),
2167
+ name: savedScript === undefined ? "nested workflow" : workflowName,
2168
+ };
2022
2169
  const argsPresent = childArgs !== undefined;
2023
2170
  const callHash = hashIdentity({
2024
2171
  kind: "workflow",
@@ -2038,17 +2185,41 @@ export async function runWorkflow<T = unknown>(
2038
2185
  });
2039
2186
  const cached = options.resumeJournal?.get(`${runId}:${callIndex}`);
2040
2187
  if (cached != null && cached.hash === callHash && callIndex < state.firstMiss) {
2041
- state.workflowGenerations.set(callIndex, cached.generation ?? 1);
2188
+ state.workflowGenerations.set(
2189
+ callIndex,
2190
+ Math.max(state.workflowGenerations.get(callIndex) ?? 0, cached.generation ?? 1),
2191
+ );
2042
2192
  if (cached.storeDelta) store.applyDelta(cached.storeDelta);
2043
2193
  const replayedAgentCount = cached.agentCount ?? 0;
2044
2194
  shared.agentCount += replayedAgentCount;
2045
2195
  frameAgentCount += replayedAgentCount;
2196
+ emitRuntimeEvent({
2197
+ type: "workflow",
2198
+ stage: "replay",
2199
+ ...observation,
2200
+ callIndex,
2201
+ replayedAgentCount,
2202
+ });
2046
2203
  return cached.result;
2047
2204
  }
2048
2205
  if (cached == null || cached.hash !== callHash) state.firstMiss = Math.min(state.firstMiss, callIndex);
2049
- const generation = (state.workflowGenerations.get(callIndex) ?? cached?.generation ?? 0) + 1;
2206
+ const generation = Math.max(state.workflowGenerations.get(callIndex) ?? 0, cached?.generation ?? 0) + 1;
2207
+ if (generation > MAX_WORKFLOW_GENERATION) {
2208
+ throw new WorkflowError("Nested workflow generation limit exceeded", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
2209
+ recoverable: false,
2210
+ });
2211
+ }
2050
2212
  state.workflowGenerations.set(callIndex, generation);
2051
- emitRuntimeEvent({ type: "workflow", stage: "start", name: workflowName, args: childArgs });
2213
+ const childFrameId = `${runId}-nested-call-${callIndex}-g${generation}${options.executionNonce ? `-${options.executionNonce}` : ""}`;
2214
+ emitRuntimeEvent({
2215
+ type: "workflow",
2216
+ stage: "start",
2217
+ ...observation,
2218
+ args: childArgs,
2219
+ callIndex,
2220
+ childFrameId,
2221
+ });
2222
+ let outcome: "completed" | "failed" | "cancelled" = "failed";
2052
2223
  try {
2053
2224
  const childDeltas = new Map<number, Record<string, unknown>>();
2054
2225
  const child = await runWorkflow(childScript, {
@@ -2061,6 +2232,8 @@ export async function runWorkflow<T = unknown>(
2061
2232
  // call results here would duplicate nested calls and make the synthetic
2062
2233
  // parent journal entry ambiguous.
2063
2234
  resumeJournal: undefined,
2235
+ // These watermarks belong to the parent lexical namespace only.
2236
+ resumeWorkflowGenerations: undefined,
2064
2237
  resumeFromRunId: undefined,
2065
2238
  onWorkflowJournal: undefined,
2066
2239
  onAgentJournal: (entry) => {
@@ -2072,7 +2245,7 @@ export async function runWorkflow<T = unknown>(
2072
2245
  // The call index + generation keeps changed nested calls distinct across
2073
2246
  // resume. The engine nonce additionally fences an interrupted child whose
2074
2247
  // result was never durable before a process/branch restart.
2075
- runId: `${runId}-nested-call-${callIndex}-g${generation}${options.executionNonce ? `-${options.executionNonce}` : ""}`,
2248
+ runId: childFrameId,
2076
2249
  });
2077
2250
  const normalizedChildResult = child.result === undefined ? null : child.result;
2078
2251
  const storeDelta: Record<string, unknown> = {};
@@ -2091,14 +2264,51 @@ export async function runWorkflow<T = unknown>(
2091
2264
  agentCount: child.agentCount,
2092
2265
  ...(Object.keys(storeDelta).length > 0 ? { storeDelta } : {}),
2093
2266
  });
2267
+ outcome = "completed";
2094
2268
  return normalizedChildResult;
2095
2269
  } finally {
2096
- emitRuntimeEvent({ type: "workflow", stage: "end", name: workflowName, args: childArgs });
2270
+ if (isAborted()) outcome = "cancelled";
2271
+ emitRuntimeEvent({
2272
+ type: "workflow",
2273
+ stage: "end",
2274
+ ...observation,
2275
+ args: childArgs,
2276
+ callIndex,
2277
+ childFrameId,
2278
+ outcome,
2279
+ });
2097
2280
  }
2098
2281
  };
2099
2282
 
2100
2283
  // ── Quality-pattern stdlib ──────────────────────────────────────────────────
2101
2284
 
2285
+ const observeQuality = async <T>(helper: string, run: () => Promise<T>): Promise<T> => {
2286
+ const helperId = `${runId}:helper-${++helperSequence}`;
2287
+ emitRuntimeEvent({ type: "quality", stage: "start", helper, helperId });
2288
+ let outcome: "completed" | "failed" | "cancelled" = "failed";
2289
+ let accepted: boolean | undefined;
2290
+ try {
2291
+ const value = await run();
2292
+ outcome = "completed";
2293
+ if (value && typeof value === "object") {
2294
+ const verdict = value as Record<string, unknown>;
2295
+ if (typeof verdict.real === "boolean") accepted = verdict.real;
2296
+ if (typeof verdict.complete === "boolean") accepted = verdict.complete;
2297
+ }
2298
+ return value;
2299
+ } finally {
2300
+ if (isAborted()) outcome = "cancelled";
2301
+ emitRuntimeEvent({
2302
+ type: "quality",
2303
+ stage: "end",
2304
+ helper,
2305
+ helperId,
2306
+ outcome,
2307
+ ...(accepted === undefined ? {} : { accepted }),
2308
+ });
2309
+ }
2310
+ };
2311
+
2102
2312
  const VERIFY_SCHEMA = {
2103
2313
  type: "object",
2104
2314
  properties: { real: { type: "boolean" }, reason: { type: "string" } },
@@ -2113,33 +2323,34 @@ export async function runWorkflow<T = unknown>(
2113
2323
  total: number;
2114
2324
  votes: Array<{ real: boolean; reason?: string }>;
2115
2325
  }> => {
2116
- emitRuntimeEvent({ type: "quality", stage: "start", helper: "verify" });
2117
- const reviewers = Math.max(1, opts.reviewers ?? 2);
2118
- const threshold = opts.threshold ?? 0.5;
2119
- const lenses = opts.lens ? (Array.isArray(opts.lens) ? opts.lens : [opts.lens]) : [];
2120
- const claim = typeof item === "string" ? item : JSON.stringify(item);
2121
- const votes = (
2122
- await parallel(
2123
- Array.from(
2124
- { length: reviewers },
2125
- (_v, i) => () =>
2126
- agent(
2127
- `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}`,
2128
- // Defaults low: numerous, narrow, and aggregated by the vote
2129
- // below, which is what carries the gate rather than any single
2130
- // reviewer's depth. A caller who wants a deeper gate says so.
2131
- { label: `verify ${i + 1}`, strength: opts.strength ?? "low", schema: VERIFY_SCHEMA },
2132
- ),
2133
- ),
2134
- )
2135
- ).filter(Boolean) as Array<{ real?: boolean; reason?: string }>;
2136
- const realCount = votes.filter((v) => v?.real).length;
2137
- return {
2138
- real: votes.length > 0 && realCount / votes.length >= threshold,
2139
- realCount,
2140
- total: votes.length,
2141
- votes: votes.map((v) => ({ real: Boolean(v?.real), reason: v?.reason })),
2142
- };
2326
+ return observeQuality("verify", async () => {
2327
+ const reviewers = Math.max(1, opts.reviewers ?? 2);
2328
+ const threshold = opts.threshold ?? 0.5;
2329
+ const lenses = opts.lens ? (Array.isArray(opts.lens) ? opts.lens : [opts.lens]) : [];
2330
+ const claim = typeof item === "string" ? item : JSON.stringify(item);
2331
+ const votes = (
2332
+ await parallel(
2333
+ Array.from(
2334
+ { length: reviewers },
2335
+ (_v, i) => () =>
2336
+ agent(
2337
+ `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}`,
2338
+ // Defaults low: numerous, narrow, and aggregated by the vote
2339
+ // below, which is what carries the gate rather than any single
2340
+ // reviewer's depth. A caller who wants a deeper gate says so.
2341
+ { label: `verify ${i + 1}`, strength: opts.strength ?? "low", schema: VERIFY_SCHEMA },
2342
+ ),
2343
+ ),
2344
+ )
2345
+ ).filter(Boolean) as Array<{ real?: boolean; reason?: string }>;
2346
+ const realCount = votes.filter((v) => v?.real).length;
2347
+ return {
2348
+ real: votes.length > 0 && realCount / votes.length >= threshold,
2349
+ realCount,
2350
+ total: votes.length,
2351
+ votes: votes.map((v) => ({ real: Boolean(v?.real), reason: v?.reason })),
2352
+ };
2353
+ });
2143
2354
  };
2144
2355
 
2145
2356
  const JUDGE_SCHEMA = {
@@ -2153,58 +2364,53 @@ export async function runWorkflow<T = unknown>(
2153
2364
  ): Promise<
2154
2365
  { index: number; attempt: unknown; score: number; judgments: Array<{ score: number; reason?: string }> } | undefined
2155
2366
  > => {
2156
- emitRuntimeEvent({ type: "quality", stage: "start", helper: "judgePanel" });
2157
- if (!Array.isArray(attempts) || attempts.length === 0) {
2158
- emitRuntimeEvent({ type: "quality", stage: "end", helper: "judgePanel" });
2159
- return undefined;
2160
- }
2161
- const judges = Math.min(16, Math.max(1, Math.floor(opts.judges ?? 3)));
2162
- const rubric = opts.rubric ?? "overall quality and correctness";
2163
- const scored = (
2164
- await parallel(
2165
- (Array.isArray(attempts) ? attempts : []).map((att, idx) => async () => {
2166
- const text = typeof att === "string" ? att : JSON.stringify(att);
2167
- const js = (
2168
- await parallel(
2169
- Array.from(
2170
- { length: judges },
2171
- (_v, j) => () =>
2172
- agent(
2173
- `Score this candidate from 0 to 1 on: ${rubric}. Reply with the score.\n\nCandidate:\n${text}`,
2174
- {
2175
- label: `judge ${idx + 1}.${j + 1}`,
2176
- // Same shape as verify: many narrow scores, aggregated.
2177
- strength: opts.strength ?? "low",
2178
- schema: JUDGE_SCHEMA,
2179
- },
2180
- ),
2181
- ),
2182
- )
2183
- ).filter(Boolean) as Array<{ score?: number }>;
2184
- const score = js.length ? js.reduce((s, v) => s + (Number(v?.score) || 0), 0) / js.length : 0;
2185
- return { index: idx, attempt: att, score, judgments: js };
2186
- }),
2187
- )
2188
- ).filter(Boolean) as Array<{
2189
- index: number;
2190
- attempt: unknown;
2191
- score: number;
2192
- judgments: Array<{ score?: number; reason?: string }>;
2193
- }>;
2194
- const normalized = scored.map((s) => ({
2195
- index: s.index,
2196
- attempt: s.attempt,
2197
- score: s.score,
2198
- judgments: s.judgments.map((j) => ({ score: Number(j?.score) || 0, reason: j?.reason })),
2199
- }));
2200
- if (normalized.length === 0) {
2201
- emitRuntimeEvent({ type: "quality", stage: "end", helper: "judgePanel" });
2202
- return undefined;
2203
- }
2204
- let best = normalized[0];
2205
- for (const s of normalized) if (s.score > best.score || (s.score === best.score && s.index < best.index)) best = s;
2206
- emitRuntimeEvent({ type: "quality", stage: "end", helper: "judgePanel" });
2207
- return best;
2367
+ return observeQuality("judgePanel", async () => {
2368
+ if (!Array.isArray(attempts) || attempts.length === 0) return undefined;
2369
+ const judges = Math.min(16, Math.max(1, Math.floor(opts.judges ?? 3)));
2370
+ const rubric = opts.rubric ?? "overall quality and correctness";
2371
+ const scored = (
2372
+ await parallel(
2373
+ (Array.isArray(attempts) ? attempts : []).map((att, idx) => async () => {
2374
+ const text = typeof att === "string" ? att : JSON.stringify(att);
2375
+ const js = (
2376
+ await parallel(
2377
+ Array.from(
2378
+ { length: judges },
2379
+ (_v, j) => () =>
2380
+ agent(
2381
+ `Score this candidate from 0 to 1 on: ${rubric}. Reply with the score.\n\nCandidate:\n${text}`,
2382
+ {
2383
+ label: `judge ${idx + 1}.${j + 1}`,
2384
+ // Same shape as verify: many narrow scores, aggregated.
2385
+ strength: opts.strength ?? "low",
2386
+ schema: JUDGE_SCHEMA,
2387
+ },
2388
+ ),
2389
+ ),
2390
+ )
2391
+ ).filter(Boolean) as Array<{ score?: number }>;
2392
+ const score = js.length ? js.reduce((s, v) => s + (Number(v?.score) || 0), 0) / js.length : 0;
2393
+ return { index: idx, attempt: att, score, judgments: js };
2394
+ }),
2395
+ )
2396
+ ).filter(Boolean) as Array<{
2397
+ index: number;
2398
+ attempt: unknown;
2399
+ score: number;
2400
+ judgments: Array<{ score?: number; reason?: string }>;
2401
+ }>;
2402
+ const normalized = scored.map((s) => ({
2403
+ index: s.index,
2404
+ attempt: s.attempt,
2405
+ score: s.score,
2406
+ judgments: s.judgments.map((j) => ({ score: Number(j?.score) || 0, reason: j?.reason })),
2407
+ }));
2408
+ if (normalized.length === 0) return undefined;
2409
+ let best = normalized[0];
2410
+ for (const s of normalized)
2411
+ if (s.score > best.score || (s.score === best.score && s.index < best.index)) best = s;
2412
+ return best;
2413
+ });
2208
2414
  };
2209
2415
 
2210
2416
  const loopUntilDry = async (opts: {
@@ -2261,16 +2467,16 @@ export async function runWorkflow<T = unknown>(
2261
2467
  results: unknown,
2262
2468
  opts: { strength?: WorkflowStrength } = {},
2263
2469
  ): Promise<{ complete: boolean; missing?: string[] } | null> => {
2264
- emitRuntimeEvent({ type: "quality", stage: "start", helper: "completenessCheck" });
2265
- const verdict = await agent(
2266
- `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)}`,
2267
- // Defaults medium, not low like the other two gates: one call, no vote to
2268
- // average over, and an open-ended read of the whole task rather than one
2269
- // claim, so there is no redundancy here to carry a shallow verdict.
2270
- { label: "completeness critic", strength: opts.strength ?? "medium", schema: COMPLETENESS_SCHEMA },
2271
- );
2272
- emitRuntimeEvent({ type: "quality", stage: "end", helper: "completenessCheck" });
2273
- return verdict as { complete: boolean; missing?: string[] } | null;
2470
+ return observeQuality("completenessCheck", async () => {
2471
+ const verdict = await agent(
2472
+ `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)}`,
2473
+ // Defaults medium, not low like the other two gates: one call, no vote to
2474
+ // average over, and an open-ended read of the whole task rather than one
2475
+ // claim, so there is no redundancy here to carry a shallow verdict.
2476
+ { label: "completeness critic", strength: opts.strength ?? "medium", schema: COMPLETENESS_SCHEMA },
2477
+ );
2478
+ return verdict as { complete: boolean; missing?: string[] } | null;
2479
+ });
2274
2480
  };
2275
2481
 
2276
2482
  const retry = async (
@@ -2319,10 +2525,22 @@ export async function runWorkflow<T = unknown>(
2319
2525
  const callIndex = nextCallIndex();
2320
2526
  const callHash = hashCheckpoint(promptText, checkpointOptions);
2321
2527
  const journalKey = `${runId}:${callIndex}`;
2528
+ const taskScope = orchestrationScope.getStore();
2529
+ const observation = {
2530
+ ...phaseObservation(taskScope?.phase ?? state.currentPhase),
2531
+ ...taskObservation(taskScope),
2532
+ };
2322
2533
  const cached = options.resumeJournal?.get(journalKey);
2323
2534
  if (cached != null && cached.hash === callHash && callIndex < state.firstMiss) {
2324
2535
  shared.agentCount++;
2325
2536
  frameAgentCount++;
2537
+ emitRuntimeEvent({
2538
+ type: "control-attempt",
2539
+ helper: "checkpoint",
2540
+ stage: "replay",
2541
+ callIndex,
2542
+ ...observation,
2543
+ });
2326
2544
  return cached.result; // replay the journaled human reply without spending a slot
2327
2545
  }
2328
2546
  if (cached == null || cached.hash !== callHash) state.firstMiss = Math.min(state.firstMiss, callIndex);
@@ -2331,48 +2549,69 @@ export async function runWorkflow<T = unknown>(
2331
2549
  frameAgentCount++;
2332
2550
  shared.physicalAgentCount++;
2333
2551
 
2334
- let reply: unknown;
2335
- if (options.confirm) {
2336
- // Host-only cancellation must not affect author options or replay hashes.
2337
- const signal = options.signal
2338
- ? AbortSignal.any([options.signal, shared.runFatalController.signal])
2339
- : shared.runFatalController.signal;
2340
- const confirm = options.confirm;
2341
- let onAbort: (() => void) | undefined;
2342
- try {
2343
- const cancelled = new Promise<never>((_resolve, reject) => {
2344
- onAbort = () => {
2345
- try {
2346
- throwIfAborted();
2347
- } catch (error) {
2348
- reject(error);
2349
- }
2350
- };
2351
- signal.addEventListener("abort", onAbort, { once: true });
2352
- if (signal.aborted) onAbort();
2353
- });
2354
- // Older host callbacks may ignore the optional signal. Still release
2355
- // the runtime wait, and observe any eventual callback rejection.
2356
- const answer = Promise.resolve().then(() => {
2357
- throwIfAborted();
2358
- return confirm(promptText, checkpointOptions, signal);
2359
- });
2360
- reply = await Promise.race([answer, cancelled]);
2361
- } finally {
2362
- if (onAbort) signal.removeEventListener("abort", onAbort);
2552
+ emitRuntimeEvent({
2553
+ type: "control-attempt",
2554
+ helper: "checkpoint",
2555
+ stage: "start",
2556
+ callIndex,
2557
+ ...observation,
2558
+ });
2559
+ let outcome: "completed" | "failed" | "cancelled" = "failed";
2560
+ try {
2561
+ let reply: unknown;
2562
+ if (options.confirm) {
2563
+ // Host-only cancellation must not affect author options or replay hashes.
2564
+ const signal = options.signal
2565
+ ? AbortSignal.any([options.signal, shared.runFatalController.signal])
2566
+ : shared.runFatalController.signal;
2567
+ const confirm = options.confirm;
2568
+ let onAbort: (() => void) | undefined;
2569
+ try {
2570
+ const cancelled = new Promise<never>((_resolve, reject) => {
2571
+ onAbort = () => {
2572
+ try {
2573
+ throwIfAborted();
2574
+ } catch (error) {
2575
+ reject(error);
2576
+ }
2577
+ };
2578
+ signal.addEventListener("abort", onAbort, { once: true });
2579
+ if (signal.aborted) onAbort();
2580
+ });
2581
+ // Older host callbacks may ignore the optional signal. Still release
2582
+ // the runtime wait, and observe any eventual callback rejection.
2583
+ const answer = Promise.resolve().then(() => {
2584
+ throwIfAborted();
2585
+ return confirm(promptText, checkpointOptions, signal);
2586
+ });
2587
+ reply = await Promise.race([answer, cancelled]);
2588
+ } finally {
2589
+ if (onAbort) signal.removeEventListener("abort", onAbort);
2590
+ }
2591
+ } else if (checkpointOptions.headless === "abort") {
2592
+ throw new WorkflowError(
2593
+ `checkpoint "${promptText}" needs human input but none is available (headless run)`,
2594
+ WorkflowErrorCode.WORKFLOW_ABORTED,
2595
+ { recoverable: false },
2596
+ );
2597
+ } else {
2598
+ reply = checkpointOptions.default ?? true;
2363
2599
  }
2364
- } else if (checkpointOptions.headless === "abort") {
2365
- throw new WorkflowError(
2366
- `checkpoint "${promptText}" needs human input but none is available (headless run)`,
2367
- WorkflowErrorCode.WORKFLOW_ABORTED,
2368
- { recoverable: false },
2369
- );
2370
- } else {
2371
- reply = checkpointOptions.default ?? true;
2600
+ throwIfAborted();
2601
+ options.onAgentJournal?.({ index: callIndex, runId, hash: callHash, result: reply });
2602
+ outcome = "completed";
2603
+ return reply;
2604
+ } finally {
2605
+ if (isAborted()) outcome = "cancelled";
2606
+ emitRuntimeEvent({
2607
+ type: "control-attempt",
2608
+ helper: "checkpoint",
2609
+ stage: "end",
2610
+ callIndex,
2611
+ ...observation,
2612
+ outcome,
2613
+ });
2372
2614
  }
2373
- throwIfAborted();
2374
- options.onAgentJournal?.({ index: callIndex, runId, hash: callHash, result: reply });
2375
- return reply;
2376
2615
  };
2377
2616
 
2378
2617
  const runtimeBindings: WorkflowRuntimeBindings & { console: Record<string, (m: unknown) => void> } = {