taskflow-core 0.1.6 → 0.1.8

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 (53) hide show
  1. package/README.md +10 -8
  2. package/dist/agents.js.map +1 -1
  3. package/dist/cache.d.ts.map +1 -1
  4. package/dist/compile.js.map +1 -1
  5. package/dist/context-store.js.map +1 -1
  6. package/dist/deterministic.d.ts +33 -0
  7. package/dist/deterministic.d.ts.map +1 -0
  8. package/dist/deterministic.js +56 -0
  9. package/dist/deterministic.js.map +1 -0
  10. package/dist/flowir/translate.d.ts.map +1 -1
  11. package/dist/flowir/translate.js +25 -7
  12. package/dist/flowir/translate.js.map +1 -1
  13. package/dist/frontmatter.d.ts.map +1 -1
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +3 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/interpolate.d.ts +18 -0
  19. package/dist/interpolate.d.ts.map +1 -1
  20. package/dist/interpolate.js +45 -0
  21. package/dist/interpolate.js.map +1 -1
  22. package/dist/library/search.d.ts +3 -2
  23. package/dist/library/search.d.ts.map +1 -1
  24. package/dist/library/search.js +2 -1
  25. package/dist/library/search.js.map +1 -1
  26. package/dist/peek.js.map +1 -1
  27. package/dist/reflexion.js.map +1 -1
  28. package/dist/replay.d.ts +75 -0
  29. package/dist/replay.d.ts.map +1 -0
  30. package/dist/replay.js +25 -0
  31. package/dist/replay.js.map +1 -0
  32. package/dist/runtime.d.ts +8 -11
  33. package/dist/runtime.d.ts.map +1 -1
  34. package/dist/runtime.js +170 -43
  35. package/dist/runtime.js.map +1 -1
  36. package/dist/schema.d.ts +10 -10
  37. package/dist/schema.d.ts.map +1 -1
  38. package/dist/schema.js +18 -0
  39. package/dist/schema.js.map +1 -1
  40. package/dist/scorers.d.ts +11 -3
  41. package/dist/scorers.d.ts.map +1 -1
  42. package/dist/scorers.js +38 -7
  43. package/dist/scorers.js.map +1 -1
  44. package/dist/store.d.ts +57 -9
  45. package/dist/store.d.ts.map +1 -1
  46. package/dist/store.js +165 -83
  47. package/dist/store.js.map +1 -1
  48. package/dist/trace.d.ts +150 -0
  49. package/dist/trace.d.ts.map +1 -0
  50. package/dist/trace.js +191 -0
  51. package/dist/trace.js.map +1 -0
  52. package/dist/workspace.d.ts.map +1 -1
  53. package/package.json +1 -1
package/dist/runtime.js CHANGED
@@ -29,7 +29,12 @@ const noRunnerInjected = async (_cwd, _agents, agentName, task) => ({
29
29
  import { aggregateUsage, emptyUsage } from "./usage.js";
30
30
  import { dependenciesOf, finalPhase, LOOP_DEFAULT_MAX_ITERATIONS, LOOP_HARD_MAX_ITERATIONS, MAX_DYNAMIC_MAP_ITEMS, MAX_DYNAMIC_NESTING, parseTtlMs, resolveArgs, topoLayers, TOURNAMENT_DEFAULT_VARIANTS, TOURNAMENT_HARD_MAX_VARIANTS, validateTaskflow } from "./schema.js";
31
31
  import { verifyTaskflow } from "./verify.js";
32
- import { combineScores, combineWithJudge, evaluatePureScorer, formatScorerReport, parseJudgeOutput, SCORE_DEFAULT_THRESHOLD, scoreResultJSON, scorerShapeErrors } from "./scorers.js";
32
+ import { combineScores, combineWithJudge, evaluatePureScorer, formatScorerReport, parseJudgeOutput, SCORE_DEFAULT_THRESHOLD, scoreResultJSON, scorerShapeErrors, WINNER_TOKEN_RE } from "./scorers.js";
33
+ import { parseGateVerdict, overBudget as overBudgetCheck } from "./deterministic.js";
34
+ import {} from "./trace.js";
35
+ // Re-export so existing `import { parseGateVerdict } from "./runtime.ts"` callers
36
+ // (and tests) keep working; the implementation now lives in deterministic.ts.
37
+ export { parseGateVerdict };
33
38
  import { runCodeCompilesScorer } from "./scorer-runtime.js";
34
39
  import { buildReflexionSummary, isContractViolation, REFLEXION_SENTINEL } from "./reflexion.js";
35
40
  import { hashInput, newRunId, runsDir } from "./store.js";
@@ -268,14 +273,12 @@ function overBudget(state) {
268
273
  const budget = state.def.budget;
269
274
  if (!budget)
270
275
  return { over: false, reason: "" };
271
- const u = aggregateUsage(Object.values(state.phases).map((p) => p.usage ?? emptyUsage()));
272
- if (budget.maxUSD !== undefined && u.cost > budget.maxUSD) {
273
- return { over: true, reason: `cost $${u.cost.toFixed(3)} exceeded cap $${budget.maxUSD}` };
274
- }
275
- if (budget.maxTokens !== undefined && u.input + u.output > budget.maxTokens) {
276
- return { over: true, reason: `tokens ${u.input + u.output} exceeded cap ${budget.maxTokens}` };
277
- }
278
- return { over: false, reason: "" };
276
+ const input = {
277
+ maxUSD: budget.maxUSD,
278
+ maxTokens: budget.maxTokens,
279
+ usages: Object.values(state.phases).map((p) => p.usage ?? emptyUsage()),
280
+ };
281
+ return overBudgetCheck(input);
279
282
  }
280
283
  /** Merge several sub-results into a single PhaseState (for map/parallel). */
281
284
  function mergePhaseState(id, results, inputHash, parseJson) {
@@ -331,6 +334,55 @@ function mergePhaseState(id, results, inputHash, parseJson) {
331
334
  * A live-update sink that mirrors a subagent's streaming progress into a single
332
335
  * phase's state row, then notifies the TUI. Shared by all single-agent phases.
333
336
  */
337
+ /** Fail-open trace emit: a throwing/missing sink must NEVER crash a run.
338
+ * Mirrors the `safeEmit` discipline used for `persist`/`onProgress`. */
339
+ function traceEmit(deps, event) {
340
+ try {
341
+ deps.trace?.emit(event);
342
+ }
343
+ catch {
344
+ /* trace is best-effort; never run-breaking */
345
+ }
346
+ }
347
+ /** Fail-open trace flush at phase-end. */
348
+ function traceFlush(deps, phaseId) {
349
+ try {
350
+ deps.trace?.flush(phaseId);
351
+ }
352
+ catch {
353
+ /* trace is best-effort; never run-breaking */
354
+ }
355
+ }
356
+ /** Emit a `decision: unreplayable` marker for a phase whose inputs the trace
357
+ * cannot fully capture (Shared Context Tree, inner sub-flows, context files,
358
+ * unobservable interpolation deps). A future replay marks such phases
359
+ * `needs-live-rerun` instead of silently reusing a recorded output.
360
+ * Single-phase analog of `hasUnobservedDependencies`. Fail-open. */
361
+ function emitUnreplayableMarker(deps, state, phase) {
362
+ const reason = unreplayableReason(state, phase);
363
+ if (!reason)
364
+ return;
365
+ traceEmit(deps, {
366
+ ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "decision",
367
+ decision: { type: "unreplayable", reason },
368
+ });
369
+ }
370
+ /** Why (if at all) a single phase cannot be deterministically replayed. */
371
+ function unreplayableReason(state, phase) {
372
+ if (phase.shareContext === true || state.def.contextSharing === true)
373
+ return "context-sharing";
374
+ if (phase.type === "flow")
375
+ return "inner-flow";
376
+ if (phase.context && phase.context.length > 0)
377
+ return "context-files";
378
+ // Interpolation refs that don't resolve through steps.*/args.*/item.* are
379
+ // unobservable to the trace (previous.output is observable via dependsOn).
380
+ const scan = (text) => !!text && /\{(previous\.output|item\b|item\.)/.test(text);
381
+ if (scan(phase.task) || scan(phase.when) || scan(phase.until) || (Array.isArray(phase.eval) && phase.eval.some(scan))) {
382
+ return "unobservable-deps";
383
+ }
384
+ return undefined;
385
+ }
334
386
  function liveSink(state, phaseId, emitProgress) {
335
387
  return (l) => {
336
388
  const live = state.phases[phaseId];
@@ -556,6 +608,38 @@ async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps
556
608
  return { reports: `\n\n<!-- ctx_spawn: ${lines.length} child report(s) -->\n${lines.join("\n\n")}`, usage };
557
609
  }
558
610
  async function executePhase(phase, state, deps, prior, emitProgress, _retryDepth = 0, opts) {
611
+ // Trace: phase-start (fail-open). Record whether this phase is replayable
612
+ // up front so a future replay can short-circuit it without walking events.
613
+ traceEmit(deps, { ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "phase-start" });
614
+ if (deps.trace)
615
+ emitUnreplayableMarker(deps, state, phase);
616
+ let result;
617
+ let threw = false;
618
+ try {
619
+ result = await executePhaseImpl(phase, state, deps, prior, emitProgress, _retryDepth, opts);
620
+ }
621
+ catch (e) {
622
+ threw = true;
623
+ // Trace: phase-end on failure (fail-open) before re-throwing.
624
+ traceEmit(deps, {
625
+ ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "phase-end",
626
+ status: "failed", error: e instanceof Error ? e.message : String(e),
627
+ });
628
+ traceFlush(deps, phase.id);
629
+ throw e;
630
+ }
631
+ if (threw)
632
+ return result; // unreachable; satisfies TS
633
+ // Trace: phase-end with the real status, then flush buffered events.
634
+ traceEmit(deps, {
635
+ ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "phase-end",
636
+ status: result.status, error: result.error,
637
+ });
638
+ traceFlush(deps, phase.id);
639
+ return result;
640
+ }
641
+ /** The pre-trace body of executePhase (workspace lifecycle + stamping). */
642
+ async function executePhaseImpl(phase, state, deps, prior, emitProgress, _retryDepth = 0, opts) {
559
643
  // Side-effect classification: stamp the marker at the single exit point so
560
644
  // every type branch inside executePhaseInner is covered. A skipped phase ran
561
645
  // nothing — no side effect to record.
@@ -855,6 +939,29 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
855
939
  if (usages.length > 1)
856
940
  last.usage = aggregateUsage(usages);
857
941
  last.attempts = usages.length;
942
+ // Trace: record this subagent call (the load-bearing record a future
943
+ // replay consumes). Fail-open. nodePath carries the item/variant
944
+ // discriminator (e.g. "review", "review-item-3", "gate-judge").
945
+ traceEmit(deps, {
946
+ ts: Date.now(),
947
+ runId: state.runId,
948
+ phaseId: phase.id,
949
+ kind: "subagent-call",
950
+ input: {
951
+ agent: agentName,
952
+ model: phase.model,
953
+ task,
954
+ preRead,
955
+ nodePath: ctxNodeId ?? phase.id,
956
+ attempt: usages.length > 0 ? usages.length - 1 : undefined,
957
+ },
958
+ output: {
959
+ text: last.output,
960
+ model: last.model,
961
+ usage: last.usage,
962
+ stopReason: last.stopReason,
963
+ },
964
+ });
858
965
  return last;
859
966
  };
860
967
  const parseJson = phase.output === "json";
@@ -1162,7 +1269,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1162
1269
  if (phase.task) {
1163
1270
  const agentName = resolveAgent(phase.agent, deps, state);
1164
1271
  const text = interpolate(phase.task, freshCtx).text;
1165
- const fullTask = `${preRead}${text}\n\n---\n\n${report}`;
1272
+ const fullTask = appendGateFormatSuffix(`${preRead}${text}\n\n---\n\n${report}`, phase);
1166
1273
  const ckT = cacheKeys(cc, [phase.id, agentName, phase.model ?? "", fullTask, scoreId]);
1167
1274
  const inputHash = ckT.key;
1168
1275
  const cachedT = cachedPhase(cc, ckT);
@@ -1253,7 +1360,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1253
1360
  const interpM = interpolate(phase.task ?? "", ctx);
1254
1361
  const textM = interpM.text;
1255
1362
  const refWarningM = warnUnresolvedRefs(phase.id, interpM.missing);
1256
- const fullTaskM = preRead + textM;
1363
+ const fullTaskM = appendGateFormatSuffix(preRead + textM, phase);
1257
1364
  const agentNameM = resolveAgent(phase.agent, deps, state);
1258
1365
  const ckM = cacheKeys(cc, [phase.id, agentNameM, phase.model ?? "", fullTaskM]);
1259
1366
  const cachedM = cachedPhase(cc, ckM);
@@ -1272,7 +1379,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1272
1379
  const interp = interpolate(phase.task ?? "", ctx);
1273
1380
  const text = interp.text;
1274
1381
  const refWarning = warnUnresolvedRefs(phase.id, interp.missing);
1275
- const fullTask = preRead + text;
1382
+ const fullTask = appendGateFormatSuffix(preRead + text, phase);
1276
1383
  const agentName = resolveAgent(phase.agent, deps, state);
1277
1384
  const ck = cacheKeys(cc, [phase.id, agentName, phase.model ?? "", fullTask]);
1278
1385
  const inputHash = ck.key;
@@ -1285,8 +1392,16 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1285
1392
  ps.reads = readRefsToReads(readRefs, state);
1286
1393
  if (refWarning)
1287
1394
  ps.warnings = [...(ps.warnings ?? []), refWarning];
1288
- if (type === "gate" && ps.status === "done")
1395
+ if (type === "gate" && ps.status === "done") {
1289
1396
  ps.gate = parseGateVerdict(r.output);
1397
+ // Trace: record the gate verdict decision (fail-open). A future replay
1398
+ // re-adjudicates this against a changed threshold.
1399
+ if (ps.gate)
1400
+ traceEmit(deps, {
1401
+ ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "decision",
1402
+ decision: { type: "gate-verdict", value: ps.gate.verdict, reason: ps.gate.reason },
1403
+ });
1404
+ }
1290
1405
  // Shared Context Tree: register this node, mark its terminal status, and
1291
1406
  // pick up any ctx_spawn intents the subagent queued. The spawned child
1292
1407
  // tasks run here (supervision loop) and their reports are folded into this
@@ -1339,7 +1454,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1339
1454
  }
1340
1455
  const retryCtx = buildInterpolationContext(state, lastCompletedOutput(state, phase));
1341
1456
  const retryText = interpolate(phase.task ?? "", retryCtx).text;
1342
- const retryTask = preRead + retryText;
1457
+ const retryTask = appendGateFormatSuffix(preRead + retryText, phase);
1343
1458
  const retryIH = cacheKeys(cc, [phase.id, agentName, phase.model ?? "", retryTask]).key;
1344
1459
  const retryR = await runOne(agentName, retryTask, liveSink(state, phase.id, emitProgress), undefined, contractCheck);
1345
1460
  gatePs = resultToPhaseState(phase.id, retryR, retryIH, parseJson);
@@ -2354,38 +2469,50 @@ function defaultAgent(deps) {
2354
2469
  return deps.agents[0]?.name ?? "default";
2355
2470
  }
2356
2471
  /**
2357
- * Parse a gate phase's output into a verdict. Blocks the flow only on an
2358
- * explicit negative signal; ambiguous output passes (fail-open).
2359
- * Accepts JSON ({continue|pass: bool} or {verdict: "..."}) or a text marker
2360
- * `VERDICT: PASS|BLOCK|FAIL|STOP|OK|REJECT|HALT` (last occurrence wins).
2472
+ * Parse a gate phase's output into a verdict. Blocks the flow on an explicit
2473
+ * negative signal OR on ambiguous, unparseable model output. Accepts JSON
2474
+ * ({continue|pass: bool} or {verdict: "..."}) or a text marker
2475
+ * `VERDICT: PASS|BLOCK|FAIL|STOP|OK|REJECT|HALT` (last occurrence wins). The text
2476
+ * matcher tolerates common Markdown emphasis around the verdict word
2477
+ * (`VERDICT: **BLOCK**`, `### VERDICT: __BLOCK__`, `VERDICT: `BLOCK``) so a
2478
+ * genuine BLOCK is never silently downgraded to PASS (issue #54).
2479
+ *
2480
+ * **Fail-closed:** if the model produced output but no verdict could be parsed,
2481
+ * the gate BLOCKS. A gate that cannot reach a verdict cannot be trusted to pass;
2482
+ * halting is recoverable (prior phases persist, the run is resumable) whereas a
2483
+ * rubber-stamped PASS is silent and potentially ships broken work. Note that a
2484
+ * JSON verdict object whose value is non-blocking (e.g. `{"verdict":"No issues
2485
+ * found"}`) is an *explicit* pass, not ambiguity, and still resolves to pass.
2361
2486
  */
2362
- export function parseGateVerdict(output) {
2363
- const json = safeParse(output);
2364
- if (json && typeof json === "object") {
2365
- const o = json;
2366
- if (typeof o.continue === "boolean")
2367
- return { verdict: o.continue ? "pass" : "block", reason: asReason(o.reason) };
2368
- if (typeof o.pass === "boolean")
2369
- return { verdict: o.pass ? "pass" : "block", reason: asReason(o.reason) };
2370
- if (typeof o.verdict === "string") {
2371
- // Note: do NOT include standalone "no" — natural-language verdicts like
2372
- // "No issues found" / "no errors" would otherwise be false-positive BLOCK.
2373
- // Fail-open covers any ambiguous text.
2374
- const block = /block|fail|stop|reject|halt/i.test(o.verdict);
2375
- return { verdict: block ? "block" : "pass", reason: asReason(o.reason) };
2376
- }
2377
- }
2378
- const matches = [...output.matchAll(/VERDICT\s*[:=]\s*(PASS|BLOCK|FAIL|STOP|OK|REJECT|HALT)/gi)];
2379
- if (matches.length) {
2380
- const v = matches[matches.length - 1][1].toUpperCase();
2381
- const pass = v === "PASS" || v === "OK";
2382
- return { verdict: pass ? "pass" : "block" };
2383
- }
2384
- return { verdict: "pass" };
2385
- }
2487
+ // `parseGateVerdict` is implemented in `deterministic.ts` (a pure seam a
2488
+ // future replay can import without dragging in the process-spawning runner).
2489
+ // It is imported at the top of this file and re-exported from the barrel.
2386
2490
  function asReason(v) {
2387
2491
  return typeof v === "string" && v.trim() ? v.trim() : undefined;
2388
2492
  }
2493
+ /**
2494
+ * If a gate phase relies on free-text verdict parsing (no `output:"json"` +
2495
+ * `expect` contract) and its task does not already demand a `VERDICT:` marker,
2496
+ * append a hard output-format suffix. This pushes the model toward the exact
2497
+ * machine-readable terminator the parser expects, so a genuine verdict is not
2498
+ * lost to an authoring slip or a model that "forgets" to emit one (issue #54).
2499
+ * When the phase already enforces a JSON contract, no suffix is needed — the
2500
+ * `expect` schema validates the output deterministically.
2501
+ */
2502
+ function appendGateFormatSuffix(task, phase) {
2503
+ // Only free-text GATE phases need the verdict terminator. Agent/map/reduce/loop
2504
+ // phases pass through untouched — they have no verdict to parse.
2505
+ if (phase.type !== "gate")
2506
+ return task;
2507
+ if (phase.output === "json" && phase.expect)
2508
+ return task;
2509
+ // Already asks for a verdict marker (any case) — don't duplicate.
2510
+ if (/VERDICT\s*[:=]/i.test(task))
2511
+ return task;
2512
+ return (`${task}\n\n--- Required output format ---\n` +
2513
+ `End your response with exactly one line in this exact form (no Markdown, no bold, no extra words):\n` +
2514
+ `VERDICT: PASS\nor\nVERDICT: BLOCK`);
2515
+ }
2389
2516
  /**
2390
2517
  * Parse a judge's pick of the winning variant. Accepts JSON ({"winner":n} or
2391
2518
  * {"best":n}) or a `WINNER: n` line (last match wins). Clamps to [1, count].
@@ -2402,7 +2529,7 @@ export function parseTournamentWinner(output, count) {
2402
2529
  if (Number.isFinite(n))
2403
2530
  return { winner: clamp(n), reason: asReason(o.reason) };
2404
2531
  }
2405
- const matches = [...output.matchAll(/WINNER\s{0,20}[:=]\s{0,20}#?\s{0,20}(\d+)/gi)];
2532
+ const matches = [...output.matchAll(WINNER_TOKEN_RE)];
2406
2533
  if (matches.length) {
2407
2534
  const n = Number(matches[matches.length - 1][1]);
2408
2535
  if (Number.isFinite(n))