taskflow-core 0.1.3 → 0.1.5
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/README.md +861 -0
- package/dist/compile.d.ts.map +1 -1
- package/dist/compile.js +5 -1
- package/dist/compile.js.map +1 -1
- package/dist/contract.d.ts +39 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +150 -0
- package/dist/contract.js.map +1 -0
- package/dist/detached-runner.js +17 -0
- package/dist/detached-runner.js.map +1 -1
- package/dist/host/runner-types.d.ts +3 -0
- package/dist/host/runner-types.d.ts.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/interpolate.d.ts +5 -0
- package/dist/interpolate.d.ts.map +1 -1
- package/dist/interpolate.js +14 -3
- package/dist/interpolate.js.map +1 -1
- package/dist/peek.d.ts +39 -0
- package/dist/peek.d.ts.map +1 -0
- package/dist/peek.js +119 -0
- package/dist/peek.js.map +1 -0
- package/dist/reflexion.d.ts +46 -0
- package/dist/reflexion.d.ts.map +1 -0
- package/dist/reflexion.js +100 -0
- package/dist/reflexion.js.map +1 -0
- package/dist/runner-core.d.ts.map +1 -1
- package/dist/runner-core.js +3 -0
- package/dist/runner-core.js.map +1 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +589 -23
- package/dist/runtime.js.map +1 -1
- package/dist/schema.d.ts +21 -7
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +177 -4
- package/dist/schema.js.map +1 -1
- package/dist/scorer-runtime.d.ts +30 -0
- package/dist/scorer-runtime.d.ts.map +1 -0
- package/dist/scorer-runtime.js +134 -0
- package/dist/scorer-runtime.js.map +1 -0
- package/dist/scorers.d.ts +120 -0
- package/dist/scorers.d.ts.map +1 -0
- package/dist/scorers.js +313 -0
- package/dist/scorers.js.map +1 -0
- package/dist/store.d.ts +28 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js.map +1 -1
- package/dist/verify.d.ts +1 -1
- package/dist/verify.d.ts.map +1 -1
- package/dist/verify.js +59 -0
- package/dist/verify.js.map +1 -1
- package/package.json +2 -2
package/dist/runtime.js
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
import * as path from "node:path";
|
|
13
13
|
import * as fs from "node:fs";
|
|
14
14
|
import { coerceArray, evaluateCondition, interpolate, safeParse, tryEvaluateCondition } from "./interpolate.js";
|
|
15
|
-
import {
|
|
15
|
+
import { contractViolations } from "./contract.js";
|
|
16
|
+
import { isFailed, isTransientError, mapWithConcurrencyLimit, sanitizeErrorMessage } from "./runner-core.js";
|
|
16
17
|
/** Default runner used when no host injected one: fail loudly rather than
|
|
17
18
|
* silently spawn anything (core is host-neutral and cannot spawn pi/codex). */
|
|
18
19
|
const noRunnerInjected = async (_cwd, _agents, agentName, task) => ({
|
|
@@ -28,6 +29,9 @@ const noRunnerInjected = async (_cwd, _agents, agentName, task) => ({
|
|
|
28
29
|
import { aggregateUsage, emptyUsage } from "./usage.js";
|
|
29
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";
|
|
30
31
|
import { verifyTaskflow } from "./verify.js";
|
|
32
|
+
import { combineScores, combineWithJudge, evaluatePureScorer, formatScorerReport, parseJudgeOutput, SCORE_DEFAULT_THRESHOLD, scoreResultJSON, scorerShapeErrors } from "./scorers.js";
|
|
33
|
+
import { runCodeCompilesScorer } from "./scorer-runtime.js";
|
|
34
|
+
import { buildReflexionSummary, isContractViolation, REFLEXION_SENTINEL } from "./reflexion.js";
|
|
31
35
|
import { hashInput, newRunId, runsDir } from "./store.js";
|
|
32
36
|
import { CacheStore, resolveFingerprint } from "./cache.js";
|
|
33
37
|
import { compileTaskflowToIR, phaseFingerprint } from "./flowir/index.js";
|
|
@@ -64,7 +68,7 @@ export function summarizeReuse(state) {
|
|
|
64
68
|
savedUSD,
|
|
65
69
|
};
|
|
66
70
|
}
|
|
67
|
-
function buildInterpolationContext(state, previousOutput, locals, onRead) {
|
|
71
|
+
function buildInterpolationContext(state, previousOutput, locals, onRead, reflexion) {
|
|
68
72
|
const steps = {};
|
|
69
73
|
for (const [id, ps] of Object.entries(state.phases)) {
|
|
70
74
|
// Include both done AND failed phases so downstream phases can see
|
|
@@ -81,7 +85,7 @@ function buildInterpolationContext(state, previousOutput, locals, onRead) {
|
|
|
81
85
|
}
|
|
82
86
|
}
|
|
83
87
|
}
|
|
84
|
-
return { args: state.args, steps, previousOutput, locals, onRead };
|
|
88
|
+
return { args: state.args, steps, previousOutput, locals, onRead, reflexion };
|
|
85
89
|
}
|
|
86
90
|
function resultToPhaseState(id, r, inputHash, parseJson) {
|
|
87
91
|
const failed = isFailed(r);
|
|
@@ -100,6 +104,7 @@ function resultToPhaseState(id, r, inputHash, parseJson) {
|
|
|
100
104
|
usage: r.usage,
|
|
101
105
|
model: r.model,
|
|
102
106
|
attempts: attempts > 1 ? attempts : undefined,
|
|
107
|
+
timedOut: r.phaseTimeout || undefined,
|
|
103
108
|
error: failed ? r.errorMessage || r.stderr || r.output : undefined,
|
|
104
109
|
inputHash,
|
|
105
110
|
endedAt: Date.now(),
|
|
@@ -314,6 +319,7 @@ function mergePhaseState(id, results, inputHash, parseJson) {
|
|
|
314
319
|
usage,
|
|
315
320
|
model,
|
|
316
321
|
attempts: attempts > results.length ? attempts : undefined,
|
|
322
|
+
timedOut: ran.some((r) => r.phaseTimeout) || undefined,
|
|
317
323
|
budgetTruncated: budgetSkips.length > 0 || undefined,
|
|
318
324
|
subProgress: { done: ran.length, total: results.length, running: 0, failed: failedCount },
|
|
319
325
|
error: errors.length ? errors.join("; ") : undefined,
|
|
@@ -550,9 +556,26 @@ async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps
|
|
|
550
556
|
return { reports: `\n\n<!-- ctx_spawn: ${lines.length} child report(s) -->\n${lines.join("\n\n")}`, usage };
|
|
551
557
|
}
|
|
552
558
|
async function executePhase(phase, state, deps, prior, emitProgress, _retryDepth = 0, opts) {
|
|
559
|
+
// Side-effect classification: stamp the marker at the single exit point so
|
|
560
|
+
// every type branch inside executePhaseInner is covered. A skipped phase ran
|
|
561
|
+
// nothing — no side effect to record.
|
|
562
|
+
const stamp = (ps) => {
|
|
563
|
+
if (phase.idempotent === false && ps.status !== "skipped") {
|
|
564
|
+
ps.sideEffect = true;
|
|
565
|
+
// Resume double-fire warning (issue #20): a non-idempotent phase is never
|
|
566
|
+
// cached, so on resume it RE-EXECUTES even though a prior attempt already
|
|
567
|
+
// completed — re-firing its side effect. Surface it so operators aren't
|
|
568
|
+
// surprised by a second webhook/deploy. Only when a prior DONE state
|
|
569
|
+
// existed (the resume signal) and this run actually re-ran (status done).
|
|
570
|
+
if (prior?.status === "done" && ps.status === "done" && !ps.cacheHit) {
|
|
571
|
+
ps.warnings = [...(ps.warnings ?? []), "idempotent:false phase re-executed on resume (a prior attempt had completed) — its side effect fired again"];
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return ps;
|
|
575
|
+
};
|
|
553
576
|
// Non-keyword cwd (or none): no workspace lifecycle — run directly.
|
|
554
577
|
if (!isWorkspaceKeyword(phase.cwd)) {
|
|
555
|
-
return executePhaseInner(phase, state, deps, prior, emitProgress, _retryDepth, opts);
|
|
578
|
+
return stamp(await executePhaseInner(phase, state, deps, prior, emitProgress, _retryDepth, opts));
|
|
556
579
|
}
|
|
557
580
|
let ws;
|
|
558
581
|
try {
|
|
@@ -574,7 +597,7 @@ async function executePhase(phase, state, deps, prior, emitProgress, _retryDepth
|
|
|
574
597
|
const msg = ws.note ? `${tag} — ${ws.note}` : `${tag} at ${ws.dir}`;
|
|
575
598
|
ps.warnings = [...(ps.warnings ?? []), msg];
|
|
576
599
|
}
|
|
577
|
-
return ps;
|
|
600
|
+
return stamp(ps);
|
|
578
601
|
}
|
|
579
602
|
finally {
|
|
580
603
|
try {
|
|
@@ -648,13 +671,21 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
648
671
|
// each run (schema already rejects explicit cross-run, but the default-scope
|
|
649
672
|
// path must also be blocked). If flowDefHash failed, cross-run is unsafe
|
|
650
673
|
// because the key degrades to flowName-only and reopens cross-flow collisions.
|
|
651
|
-
const CROSS_RUN_BLOCKED_TYPES = new Set(["gate", "approval", "loop", "tournament"]);
|
|
674
|
+
const CROSS_RUN_BLOCKED_TYPES = new Set(["gate", "approval", "loop", "tournament", "script"]);
|
|
652
675
|
if (cacheScope === "cross-run" && CROSS_RUN_BLOCKED_TYPES.has(type)) {
|
|
653
676
|
cacheScope = "run-only";
|
|
654
677
|
}
|
|
655
678
|
if (state.flowDefHash === "failed" && cacheScope === "cross-run") {
|
|
656
679
|
cacheScope = "run-only";
|
|
657
680
|
}
|
|
681
|
+
// Side-effect classification: a non-idempotent phase is NEVER cached — not
|
|
682
|
+
// served from within-run resume, not served from or written to the cross-run
|
|
683
|
+
// store (cachedPhase and recordCache both gate on scope). One assignment
|
|
684
|
+
// covers every cache path, including the map per-item path (perItemCacheable
|
|
685
|
+
// requires scope === "cross-run").
|
|
686
|
+
if (phase.idempotent === false) {
|
|
687
|
+
cacheScope = "off";
|
|
688
|
+
}
|
|
658
689
|
const cc = {
|
|
659
690
|
scope: cacheScope,
|
|
660
691
|
ttlMs: phase.cache?.ttl ? (parseTtlMs(phase.cache.ttl) ?? undefined) : undefined,
|
|
@@ -671,12 +702,12 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
671
702
|
tools: phase.tools,
|
|
672
703
|
preRead,
|
|
673
704
|
};
|
|
674
|
-
const baseRun = (agentName, task, onLive, ctxNodeId) => run(effCwd, deps.agents, agentName, task, {
|
|
705
|
+
const baseRun = (agentName, task, onLive, ctxNodeId, signal) => run(effCwd, deps.agents, agentName, task, {
|
|
675
706
|
model: phase.model,
|
|
676
707
|
thinking: phase.thinking,
|
|
677
708
|
tools: phase.tools,
|
|
678
709
|
cwd: effCwd,
|
|
679
|
-
signal: deps.signal,
|
|
710
|
+
signal: signal ?? deps.signal,
|
|
680
711
|
onLive,
|
|
681
712
|
ctxDir: ctxDir,
|
|
682
713
|
nodeId: ctxDir ? ctxNodeId : undefined,
|
|
@@ -693,7 +724,12 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
693
724
|
const DEFAULT_TRANSIENT_RETRIES = 3;
|
|
694
725
|
const DEFAULT_TRANSIENT_BACKOFF_MS = 2000;
|
|
695
726
|
const DEFAULT_TRANSIENT_FACTOR = 2;
|
|
696
|
-
|
|
727
|
+
// Per-phase timeout: caps EACH subagent call of an agent-running phase.
|
|
728
|
+
// (script phases enforce their own child-process timeout — see the script branch.)
|
|
729
|
+
const phaseTimeoutMs = type !== "script" && typeof phase.timeout === "number" && Number.isFinite(phase.timeout) && phase.timeout >= 1000
|
|
730
|
+
? phase.timeout
|
|
731
|
+
: undefined;
|
|
732
|
+
const runOne = async (agentName, task, onLive, ctxNodeId, check) => {
|
|
697
733
|
const explicitMax = Math.max(1, 1 + Math.max(0, Math.floor(retry?.max ?? 0)));
|
|
698
734
|
// Allow enough attempts to cover whichever policy applies on a given attempt.
|
|
699
735
|
const maxAttempts = Math.max(explicitMax, 1 + DEFAULT_TRANSIENT_RETRIES);
|
|
@@ -702,8 +738,65 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
702
738
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
703
739
|
if (deps.signal?.aborted)
|
|
704
740
|
break;
|
|
705
|
-
|
|
741
|
+
// Phase timeout — an AbortController chained to the run's signal that also
|
|
742
|
+
// fires after `phase.timeout` ms. Deterministic: a timed-out call is never
|
|
743
|
+
// retried (retrying a call that just burned its full cap would double-spend).
|
|
744
|
+
let timedOut = false;
|
|
745
|
+
let timer;
|
|
746
|
+
let onParentAbort;
|
|
747
|
+
let callSignal;
|
|
748
|
+
if (phaseTimeoutMs) {
|
|
749
|
+
const ac = new AbortController();
|
|
750
|
+
callSignal = ac.signal;
|
|
751
|
+
if (deps.signal?.aborted)
|
|
752
|
+
ac.abort();
|
|
753
|
+
else if (deps.signal) {
|
|
754
|
+
onParentAbort = () => ac.abort();
|
|
755
|
+
deps.signal.addEventListener("abort", onParentAbort, { once: true });
|
|
756
|
+
}
|
|
757
|
+
timer = setTimeout(() => {
|
|
758
|
+
timedOut = true;
|
|
759
|
+
ac.abort();
|
|
760
|
+
}, phaseTimeoutMs);
|
|
761
|
+
}
|
|
762
|
+
try {
|
|
763
|
+
last = await baseRun(agentName, task, onLive, ctxNodeId, callSignal);
|
|
764
|
+
}
|
|
765
|
+
finally {
|
|
766
|
+
if (timer)
|
|
767
|
+
clearTimeout(timer);
|
|
768
|
+
if (onParentAbort)
|
|
769
|
+
deps.signal?.removeEventListener("abort", onParentAbort);
|
|
770
|
+
}
|
|
771
|
+
if (timedOut) {
|
|
772
|
+
// Reclassify the abort as a phase timeout: a distinct, deterministic
|
|
773
|
+
// failure (stopReason "error" keeps it in the failed bucket; the
|
|
774
|
+
// phaseTimeout marker excludes it from transient retry).
|
|
775
|
+
last = {
|
|
776
|
+
...last,
|
|
777
|
+
exitCode: last.exitCode === 0 ? 1 : last.exitCode,
|
|
778
|
+
stopReason: "error",
|
|
779
|
+
errorMessage: `Phase timed out after ${phaseTimeoutMs}ms (subagent aborted)`,
|
|
780
|
+
phaseTimeout: true,
|
|
781
|
+
};
|
|
782
|
+
usages.push(last.usage);
|
|
783
|
+
break;
|
|
784
|
+
}
|
|
706
785
|
usages.push(last.usage);
|
|
786
|
+
// Output contract (`expect`): a successful attempt whose JSON output
|
|
787
|
+
// violates the declared contract is a failure — eligible for the phase's
|
|
788
|
+
// explicit retry policy (never the transient fallback).
|
|
789
|
+
if (check && !isFailed(last)) {
|
|
790
|
+
const violations = check(last);
|
|
791
|
+
if (violations.length > 0) {
|
|
792
|
+
last = {
|
|
793
|
+
...last,
|
|
794
|
+
exitCode: last.exitCode === 0 ? 1 : last.exitCode,
|
|
795
|
+
stopReason: "error",
|
|
796
|
+
errorMessage: `Output contract violated:\n- ${violations.join("\n- ")}`,
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
}
|
|
707
800
|
// B6: aggregate and surface cumulative usage before the retry decision,
|
|
708
801
|
// so the TUI / budget guard see the in-flight spend on every attempt.
|
|
709
802
|
const liveRetry = state.phases[phase.id];
|
|
@@ -720,7 +813,13 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
720
813
|
// policy stops immediately (no point burning attempts on a hard error).
|
|
721
814
|
const withinExplicit = attempt < explicitMax - 1;
|
|
722
815
|
const transient = isTransientError(last);
|
|
723
|
-
|
|
816
|
+
// Side-effect classification: the implicit transient retry is a runtime
|
|
817
|
+
// safety net the author did not ask for — repeating a side-effecting
|
|
818
|
+
// call behind the author's back is exactly the hazard idempotent:false
|
|
819
|
+
// exists to prevent. Explicit retry{} (withinExplicit) stays honored:
|
|
820
|
+
// it is the author's declaration that a repeat is acceptable.
|
|
821
|
+
const allowTransient = phase.idempotent !== false;
|
|
822
|
+
const withinTransient = transient && allowTransient && attempt < DEFAULT_TRANSIENT_RETRIES;
|
|
724
823
|
if (!withinExplicit && !withinTransient)
|
|
725
824
|
break;
|
|
726
825
|
// Backoff: prefer the explicit policy's curve when the phase defines one
|
|
@@ -759,6 +858,17 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
759
858
|
return last;
|
|
760
859
|
};
|
|
761
860
|
const parseJson = phase.output === "json";
|
|
861
|
+
// Output contract (`expect`): validates the parsed JSON output of a finished
|
|
862
|
+
// subagent call. Wired into `runOne` so a violation counts as a failed attempt
|
|
863
|
+
// (retryable under the phase's explicit `retry` policy).
|
|
864
|
+
const contractCheck = phase.expect !== undefined && parseJson
|
|
865
|
+
? (r) => {
|
|
866
|
+
const parsed = safeParse(r.output);
|
|
867
|
+
if (parsed === undefined)
|
|
868
|
+
return ["$: output is not valid JSON (contract could not be checked)"];
|
|
869
|
+
return contractViolations(parsed, phase.expect);
|
|
870
|
+
}
|
|
871
|
+
: undefined;
|
|
762
872
|
// Runs a list of sub-tasks with live fan-out progress + aggregate live usage/activity.
|
|
763
873
|
// `perItem` (map only) enables per-item cross-run caching: each item is looked
|
|
764
874
|
// up in the cache before spawning a subagent, and a successful fresh item is
|
|
@@ -949,6 +1059,216 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
949
1059
|
return ps;
|
|
950
1060
|
}
|
|
951
1061
|
}
|
|
1062
|
+
// Scoring gate (`score`): deterministic scorers → zero-token auto-pass /
|
|
1063
|
+
// LLM judge / task fallback. Self-contained (including its own onBlock:retry
|
|
1064
|
+
// mirror) so the non-score gate path below stays byte-identical.
|
|
1065
|
+
const scoreRaw = type === "gate" ? phase.score : undefined;
|
|
1066
|
+
if (scoreRaw !== undefined) {
|
|
1067
|
+
const shapeErrs = scorerShapeErrors(scoreRaw);
|
|
1068
|
+
if (shapeErrs.length === 0) {
|
|
1069
|
+
const sc = scoreRaw;
|
|
1070
|
+
const combine = sc.combine ?? "all";
|
|
1071
|
+
const threshold = combine === "weighted" ? (sc.threshold ?? SCORE_DEFAULT_THRESHOLD) : undefined;
|
|
1072
|
+
const scoreId = JSON.stringify(scoreRaw);
|
|
1073
|
+
// One full score evaluation (deterministics → auto-pass | judge | task |
|
|
1074
|
+
// deterministic BLOCK). Re-invoked by the onBlock:retry loop, so it
|
|
1075
|
+
// rebuilds its interpolation context from the CURRENT state each call.
|
|
1076
|
+
const evaluateScore = async () => {
|
|
1077
|
+
const freshPrev = lastCompletedOutput(state, phase);
|
|
1078
|
+
const freshCtx = buildInterpolationContext(state, freshPrev, undefined, onRead);
|
|
1079
|
+
const tInterp = interpolate(sc.target ?? "{previous.output}", freshCtx);
|
|
1080
|
+
const targetResolved = tInterp.missing.length === 0;
|
|
1081
|
+
const target = tInterp.text;
|
|
1082
|
+
// Deterministic scorers — pure ones inline, code-compiles via the
|
|
1083
|
+
// impure runtime module. Skipped entirely when the target ref did not
|
|
1084
|
+
// resolve (scoring a literal placeholder would be noise).
|
|
1085
|
+
const results = [];
|
|
1086
|
+
if (targetResolved) {
|
|
1087
|
+
for (let i = 0; i < sc.scorers.length; i++) {
|
|
1088
|
+
const s = sc.scorers[i];
|
|
1089
|
+
results.push(s.type === "code-compiles"
|
|
1090
|
+
? await runCodeCompilesScorer(s, i, target)
|
|
1091
|
+
: evaluatePureScorer(s, i, target));
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
// Weighted + judge: the judge's weight enlarges the denominator so the
|
|
1095
|
+
// deterministic combination is a LOWER BOUND — clearing the threshold
|
|
1096
|
+
// without the judge means the judge could not change the outcome.
|
|
1097
|
+
const judgeWeight = combine === "weighted" && sc.judge ? (sc.weights?.[sc.scorers.length] ?? 1) : 0;
|
|
1098
|
+
const det = combineScores(results, combine, sc.weights, threshold ?? SCORE_DEFAULT_THRESHOLD, judgeWeight);
|
|
1099
|
+
// Auto-pass is only sound when the judge could not veto it:
|
|
1100
|
+
// - no judge configured → the deterministics ARE the decision;
|
|
1101
|
+
// - weighted + judge → det.passed used the judge-inflated denominator
|
|
1102
|
+
// (lower bound), so the judge's score cannot drop it below threshold.
|
|
1103
|
+
// all/any WITH a judge must NOT auto-skip: there the judge's verdict is
|
|
1104
|
+
// authoritative (it may check what scorers cannot — e.g. factuality) and
|
|
1105
|
+
// skipping it would silently bypass a configured quality check.
|
|
1106
|
+
const judgeCannotVeto = !sc.judge || combine === "weighted";
|
|
1107
|
+
if (targetResolved && det.passed && judgeCannotVeto) {
|
|
1108
|
+
// AUTO-PASS — zero LLM tokens (mirrors the eval-skip fast-path).
|
|
1109
|
+
const inputHash = cacheKeys(cc, [phase.id, "score-skip", scoreId, target]).key;
|
|
1110
|
+
const scores = { results, combined: det.combined, threshold };
|
|
1111
|
+
const ps = {
|
|
1112
|
+
id: phase.id,
|
|
1113
|
+
status: "done",
|
|
1114
|
+
output: "PASS (scorers passed — no LLM call)",
|
|
1115
|
+
gate: { verdict: "pass", scores },
|
|
1116
|
+
json: scoreResultJSON(results, det.combined, "pass", threshold),
|
|
1117
|
+
usage: emptyUsage(),
|
|
1118
|
+
inputHash,
|
|
1119
|
+
endedAt: Date.now(),
|
|
1120
|
+
};
|
|
1121
|
+
if (readRefs.length)
|
|
1122
|
+
ps.reads = readRefsToReads(readRefs, state);
|
|
1123
|
+
return ps;
|
|
1124
|
+
}
|
|
1125
|
+
const report = targetResolved
|
|
1126
|
+
? formatScorerReport(results, det.combined, threshold)
|
|
1127
|
+
: `## Deterministic scorer report\n(scorers skipped — score.target did not resolve: ${tInterp.missing.join(", ")})`;
|
|
1128
|
+
// Judge fallback — the LLM-as-judge decides, with the target and the
|
|
1129
|
+
// deterministic report in evidence. Fail-open on unparseable output.
|
|
1130
|
+
if (sc.judge) {
|
|
1131
|
+
const judgeAgent = resolveAgent(sc.judge.agent ?? phase.agent, deps, state);
|
|
1132
|
+
const judgeText = interpolate(sc.judge.task, freshCtx).text;
|
|
1133
|
+
// Neutralize fences in the (model-produced) target so it cannot
|
|
1134
|
+
// close the evidence block and inject instructions at prompt level.
|
|
1135
|
+
const safeTarget = target.replace(/```/g, "`\u200b``");
|
|
1136
|
+
const fullJudgeTask = `${preRead}${judgeText}\n\n---\n\n## Target under evaluation\n\`\`\`\n${safeTarget}\n\`\`\`\n\n${report}\n\n` +
|
|
1137
|
+
`Return JSON {"score": 0.0-1.0, "verdict": "pass"|"block", "reason": "..."} (or end with VERDICT: PASS|BLOCK).`;
|
|
1138
|
+
const ckJ = cacheKeys(cc, [phase.id, judgeAgent, phase.model ?? "", fullJudgeTask, scoreId]);
|
|
1139
|
+
const inputHash = ckJ.key;
|
|
1140
|
+
const cachedJ = cachedPhase(cc, ckJ);
|
|
1141
|
+
if (cachedJ)
|
|
1142
|
+
return cachedJ;
|
|
1143
|
+
const r = await runOne(judgeAgent, fullJudgeTask, liveSink(state, phase.id, emitProgress), nodeIdFor("judge"));
|
|
1144
|
+
const ps = resultToPhaseState(phase.id, r, inputHash, false);
|
|
1145
|
+
if (ps.status === "done") {
|
|
1146
|
+
const judged = parseJudgeOutput(r.output);
|
|
1147
|
+
// Weighted: the judge's score folds into the combination and the
|
|
1148
|
+
// threshold decides. all/any: the judge's verdict is authoritative.
|
|
1149
|
+
const final = combine === "weighted"
|
|
1150
|
+
? combineWithJudge(results, sc.weights, threshold ?? SCORE_DEFAULT_THRESHOLD, judged.score)
|
|
1151
|
+
: { combined: judged.score, passed: judged.verdict === "pass" };
|
|
1152
|
+
const verdict = final.passed ? "pass" : "block";
|
|
1153
|
+
ps.gate = { verdict, reason: judged.reason, scores: { results, combined: final.combined, threshold } };
|
|
1154
|
+
ps.json = scoreResultJSON(results, final.combined, verdict, threshold, { score: judged.score, reason: judged.reason });
|
|
1155
|
+
}
|
|
1156
|
+
if (readRefs.length)
|
|
1157
|
+
ps.reads = readRefsToReads(readRefs, state);
|
|
1158
|
+
return ps;
|
|
1159
|
+
}
|
|
1160
|
+
// Task fallback — the gate's own LLM task runs with the scorer report
|
|
1161
|
+
// appended, verdict parsed as usual.
|
|
1162
|
+
if (phase.task) {
|
|
1163
|
+
const agentName = resolveAgent(phase.agent, deps, state);
|
|
1164
|
+
const text = interpolate(phase.task, freshCtx).text;
|
|
1165
|
+
const fullTask = `${preRead}${text}\n\n---\n\n${report}`;
|
|
1166
|
+
const ckT = cacheKeys(cc, [phase.id, agentName, phase.model ?? "", fullTask, scoreId]);
|
|
1167
|
+
const inputHash = ckT.key;
|
|
1168
|
+
const cachedT = cachedPhase(cc, ckT);
|
|
1169
|
+
if (cachedT)
|
|
1170
|
+
return cachedT;
|
|
1171
|
+
const r = await runOne(agentName, fullTask, liveSink(state, phase.id, emitProgress), nodeIdFor(), contractCheck);
|
|
1172
|
+
const ps = resultToPhaseState(phase.id, r, inputHash, parseJson);
|
|
1173
|
+
if (ps.status === "done") {
|
|
1174
|
+
const v = parseGateVerdict(r.output);
|
|
1175
|
+
ps.gate = { ...v, scores: { results, combined: det.combined, threshold } };
|
|
1176
|
+
ps.json = scoreResultJSON(results, det.combined, v.verdict, threshold);
|
|
1177
|
+
}
|
|
1178
|
+
if (readRefs.length)
|
|
1179
|
+
ps.reads = readRefsToReads(readRefs, state);
|
|
1180
|
+
return ps;
|
|
1181
|
+
}
|
|
1182
|
+
// No LLM fallback configured.
|
|
1183
|
+
if (!targetResolved) {
|
|
1184
|
+
// Unresolved target with no judge/task is AMBIGUITY, not an explicit
|
|
1185
|
+
// failure — fail-open PASS with a warning (project invariant).
|
|
1186
|
+
const inputHash = cacheKeys(cc, [phase.id, "score-unresolved", scoreId]).key;
|
|
1187
|
+
return {
|
|
1188
|
+
id: phase.id,
|
|
1189
|
+
status: "done",
|
|
1190
|
+
output: "PASS (score.target did not resolve — fail-open)",
|
|
1191
|
+
gate: { verdict: "pass", reason: `score.target unresolved: ${tInterp.missing.join(", ")}` },
|
|
1192
|
+
json: scoreResultJSON([], 0, "pass", threshold),
|
|
1193
|
+
warnings: [`gate score.target did not resolve (${tInterp.missing.join(", ")}) — fail-open PASS`],
|
|
1194
|
+
usage: emptyUsage(),
|
|
1195
|
+
inputHash,
|
|
1196
|
+
endedAt: Date.now(),
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
// Deterministic explicit failure is NOT ambiguity: BLOCK.
|
|
1200
|
+
const inputHash = cacheKeys(cc, [phase.id, "score-block", scoreId, target]).key;
|
|
1201
|
+
const scores = { results, combined: det.combined, threshold };
|
|
1202
|
+
const ps = {
|
|
1203
|
+
id: phase.id,
|
|
1204
|
+
status: "done",
|
|
1205
|
+
output: `BLOCK (deterministic scorers failed — no LLM fallback)\n\n${report}`,
|
|
1206
|
+
gate: { verdict: "block", reason: "deterministic scorers below threshold", scores },
|
|
1207
|
+
json: scoreResultJSON(results, det.combined, "block", threshold),
|
|
1208
|
+
usage: emptyUsage(),
|
|
1209
|
+
inputHash,
|
|
1210
|
+
endedAt: Date.now(),
|
|
1211
|
+
};
|
|
1212
|
+
if (readRefs.length)
|
|
1213
|
+
ps.reads = readRefsToReads(readRefs, state);
|
|
1214
|
+
return ps;
|
|
1215
|
+
};
|
|
1216
|
+
let ps = await evaluateScore();
|
|
1217
|
+
// onBlock:retry — mirrors the non-score gate loop below (re-run upstream
|
|
1218
|
+
// deps, then RE-SCORE), sharing its depth cap and budget/abort guards.
|
|
1219
|
+
if (ps.gate?.verdict === "block") {
|
|
1220
|
+
const onBlockV = phase.onBlock ?? "halt";
|
|
1221
|
+
const MAX_RETRY_DEPTH = 3;
|
|
1222
|
+
let attempt = 0;
|
|
1223
|
+
while (onBlockV === "retry" && attempt < (phase.retry?.max ?? 1)) {
|
|
1224
|
+
if (deps.signal?.aborted || overBudget(state).over)
|
|
1225
|
+
break;
|
|
1226
|
+
attempt++;
|
|
1227
|
+
if (_retryDepth < MAX_RETRY_DEPTH) {
|
|
1228
|
+
const { _cwdOverride: _dropGateWs, ...depsForUpstream } = deps;
|
|
1229
|
+
for (const depId of phase.dependsOn ?? []) {
|
|
1230
|
+
const d = state.def.phases.find((p) => p.id === depId);
|
|
1231
|
+
if (!d)
|
|
1232
|
+
continue;
|
|
1233
|
+
const dPs = await executePhase(d, state, depsForUpstream, prior, emitProgress, _retryDepth + 1, undefined);
|
|
1234
|
+
state.phases[depId] = dPs;
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
const prevAttempts = ps.attempts ?? 0;
|
|
1238
|
+
ps = await evaluateScore();
|
|
1239
|
+
ps.attempts = prevAttempts + (ps.attempts ?? 0);
|
|
1240
|
+
if (ps.gate?.verdict !== "block" || overBudget(state).over)
|
|
1241
|
+
break;
|
|
1242
|
+
}
|
|
1243
|
+
if (attempt > 0)
|
|
1244
|
+
ps.attempts = Math.max(ps.attempts ?? 0, attempt);
|
|
1245
|
+
}
|
|
1246
|
+
recordCache(cc, ps);
|
|
1247
|
+
return ps;
|
|
1248
|
+
}
|
|
1249
|
+
// Malformed score (validation reports it) — fail-open: fall through to
|
|
1250
|
+
// the plain LLM gate with a warning so an authoring slip degrades to the
|
|
1251
|
+
// historical behavior instead of crashing the phase.
|
|
1252
|
+
const scoreWarning = `gate 'score' is malformed and was ignored: ${shapeErrs[0]}`;
|
|
1253
|
+
const interpM = interpolate(phase.task ?? "", ctx);
|
|
1254
|
+
const textM = interpM.text;
|
|
1255
|
+
const refWarningM = warnUnresolvedRefs(phase.id, interpM.missing);
|
|
1256
|
+
const fullTaskM = preRead + textM;
|
|
1257
|
+
const agentNameM = resolveAgent(phase.agent, deps, state);
|
|
1258
|
+
const ckM = cacheKeys(cc, [phase.id, agentNameM, phase.model ?? "", fullTaskM]);
|
|
1259
|
+
const cachedM = cachedPhase(cc, ckM);
|
|
1260
|
+
if (cachedM)
|
|
1261
|
+
return cachedM;
|
|
1262
|
+
const rM = await runOne(agentNameM, fullTaskM, liveSink(state, phase.id, emitProgress), nodeIdFor(), contractCheck);
|
|
1263
|
+
const psM = resultToPhaseState(phase.id, rM, ckM.key, parseJson);
|
|
1264
|
+
if (readRefs.length)
|
|
1265
|
+
psM.reads = readRefsToReads(readRefs, state);
|
|
1266
|
+
psM.warnings = [...(psM.warnings ?? []), scoreWarning, ...(refWarningM ? [refWarningM] : [])];
|
|
1267
|
+
if (psM.status === "done")
|
|
1268
|
+
psM.gate = parseGateVerdict(rM.output);
|
|
1269
|
+
recordCache(cc, psM);
|
|
1270
|
+
return psM;
|
|
1271
|
+
}
|
|
952
1272
|
const interp = interpolate(phase.task ?? "", ctx);
|
|
953
1273
|
const text = interp.text;
|
|
954
1274
|
const refWarning = warnUnresolvedRefs(phase.id, interp.missing);
|
|
@@ -959,7 +1279,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
959
1279
|
const cached = cachedPhase(cc, ck);
|
|
960
1280
|
if (cached)
|
|
961
1281
|
return cached;
|
|
962
|
-
const r = await runOne(agentName, fullTask, liveSink(state, phase.id, emitProgress), nodeIdFor());
|
|
1282
|
+
const r = await runOne(agentName, fullTask, liveSink(state, phase.id, emitProgress), nodeIdFor(), contractCheck);
|
|
963
1283
|
const ps = resultToPhaseState(phase.id, r, inputHash, parseJson);
|
|
964
1284
|
if (readRefs.length)
|
|
965
1285
|
ps.reads = readRefsToReads(readRefs, state);
|
|
@@ -1021,7 +1341,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1021
1341
|
const retryText = interpolate(phase.task ?? "", retryCtx).text;
|
|
1022
1342
|
const retryTask = preRead + retryText;
|
|
1023
1343
|
const retryIH = cacheKeys(cc, [phase.id, agentName, phase.model ?? "", retryTask]).key;
|
|
1024
|
-
const retryR = await runOne(agentName, retryTask, liveSink(state, phase.id, emitProgress));
|
|
1344
|
+
const retryR = await runOne(agentName, retryTask, liveSink(state, phase.id, emitProgress), undefined, contractCheck);
|
|
1025
1345
|
gatePs = resultToPhaseState(phase.id, retryR, retryIH, parseJson);
|
|
1026
1346
|
if (gatePs.status === "done")
|
|
1027
1347
|
gatePs.gate = parseGateVerdict(retryR.output);
|
|
@@ -1035,6 +1355,157 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1035
1355
|
recordCache(cc, ps);
|
|
1036
1356
|
return ps;
|
|
1037
1357
|
}
|
|
1358
|
+
// script — zero-token shell command. Spawns a child process, pipes
|
|
1359
|
+
// interpolated `input` to stdin, captures stdout as the phase output.
|
|
1360
|
+
if (type === "script") {
|
|
1361
|
+
const cmd = phase.run;
|
|
1362
|
+
if (!cmd) {
|
|
1363
|
+
return {
|
|
1364
|
+
id: phase.id,
|
|
1365
|
+
status: "failed",
|
|
1366
|
+
error: "script phase requires 'run'",
|
|
1367
|
+
endedAt: Date.now(),
|
|
1368
|
+
usage: emptyUsage(),
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
// Interpolate the command.
|
|
1372
|
+
// Array form: interpolate each element (safe — schema rejects placeholders in string form).
|
|
1373
|
+
// String form: skip interpolation — schema already guarantees no {placeholders}.
|
|
1374
|
+
const interpRun = Array.isArray(cmd)
|
|
1375
|
+
? cmd.map((s) => interpolate(s, ctx))
|
|
1376
|
+
: [{ text: cmd, missing: [] }];
|
|
1377
|
+
// Warn unresolved references.
|
|
1378
|
+
for (const r of interpRun) {
|
|
1379
|
+
if (r.missing.length)
|
|
1380
|
+
warnUnresolvedRefs(phase.id, r.missing);
|
|
1381
|
+
}
|
|
1382
|
+
const interpRunText = interpRun.map((r) => r.text);
|
|
1383
|
+
// Interpolate stdin input if provided.
|
|
1384
|
+
const stdinInterp = phase.input !== undefined ? interpolate(phase.input, ctx) : undefined;
|
|
1385
|
+
if (stdinInterp?.missing.length)
|
|
1386
|
+
warnUnresolvedRefs(phase.id, stdinInterp.missing);
|
|
1387
|
+
const stdinInput = stdinInterp?.text;
|
|
1388
|
+
const ck = cacheKeys(cc, [phase.id, JSON.stringify(interpRunText), stdinInput ?? ""]);
|
|
1389
|
+
const inputHash = ck.key;
|
|
1390
|
+
const cached = cachedPhase(cc, ck);
|
|
1391
|
+
if (cached)
|
|
1392
|
+
return cached;
|
|
1393
|
+
const MAX_STDOUT = 1_048_576; // 1 MB cap
|
|
1394
|
+
const SCRIPT_TIMEOUT_MS = (phase.timeout ?? 60_000); // default 60 s, configurable via phase.timeout
|
|
1395
|
+
const SIGKILL_GRACE_MS = 5_000; // grace period after SIGTERM before SIGKILL
|
|
1396
|
+
try {
|
|
1397
|
+
const { spawn } = await import("node:child_process");
|
|
1398
|
+
const result = await new Promise((resolve, reject) => {
|
|
1399
|
+
// Array command → direct spawn (safe); string → shell (rejected at validation if it has placeholders).
|
|
1400
|
+
const child = Array.isArray(cmd)
|
|
1401
|
+
? spawn(interpRunText[0], interpRunText.slice(1), {
|
|
1402
|
+
cwd: effCwd,
|
|
1403
|
+
env: process.env,
|
|
1404
|
+
shell: false,
|
|
1405
|
+
signal: deps.signal,
|
|
1406
|
+
})
|
|
1407
|
+
: spawn(interpRunText[0], [], {
|
|
1408
|
+
cwd: effCwd,
|
|
1409
|
+
env: process.env,
|
|
1410
|
+
shell: true,
|
|
1411
|
+
signal: deps.signal,
|
|
1412
|
+
});
|
|
1413
|
+
let stdout = "";
|
|
1414
|
+
let stderr = "";
|
|
1415
|
+
let stdoutOversize = false;
|
|
1416
|
+
let timedOut = false;
|
|
1417
|
+
child.stdout?.on("data", (d) => {
|
|
1418
|
+
if (stdout.length < MAX_STDOUT) {
|
|
1419
|
+
const need = MAX_STDOUT - stdout.length;
|
|
1420
|
+
stdout += d.toString().slice(0, need);
|
|
1421
|
+
if (stdout.length >= MAX_STDOUT)
|
|
1422
|
+
stdoutOversize = true;
|
|
1423
|
+
}
|
|
1424
|
+
});
|
|
1425
|
+
child.stderr?.on("data", (d) => {
|
|
1426
|
+
if (stderr.length < 500) {
|
|
1427
|
+
stderr += d.toString().slice(0, 500 - stderr.length);
|
|
1428
|
+
}
|
|
1429
|
+
});
|
|
1430
|
+
// Timeout guard: SIGTERM first, then SIGKILL after grace period.
|
|
1431
|
+
let sigkillTimer;
|
|
1432
|
+
const timer = setTimeout(() => {
|
|
1433
|
+
timedOut = true;
|
|
1434
|
+
child.kill("SIGTERM");
|
|
1435
|
+
// SIGKILL fallback if process ignores SIGTERM.
|
|
1436
|
+
sigkillTimer = setTimeout(() => {
|
|
1437
|
+
try {
|
|
1438
|
+
child.kill("SIGKILL");
|
|
1439
|
+
}
|
|
1440
|
+
catch { /* already dead */ }
|
|
1441
|
+
}, SIGKILL_GRACE_MS);
|
|
1442
|
+
}, SCRIPT_TIMEOUT_MS);
|
|
1443
|
+
child.on("error", (err) => {
|
|
1444
|
+
clearTimeout(timer);
|
|
1445
|
+
clearTimeout(sigkillTimer);
|
|
1446
|
+
reject(err);
|
|
1447
|
+
});
|
|
1448
|
+
child.on("close", (code) => {
|
|
1449
|
+
clearTimeout(timer);
|
|
1450
|
+
clearTimeout(sigkillTimer);
|
|
1451
|
+
resolve({ stdout, stderr, code, stdoutOversize, timedOut });
|
|
1452
|
+
});
|
|
1453
|
+
if (stdinInput !== undefined) {
|
|
1454
|
+
child.stdin?.on("error", () => { }); // swallow EPIPE when child closes stdin early
|
|
1455
|
+
child.stdin?.write(stdinInput);
|
|
1456
|
+
child.stdin?.end();
|
|
1457
|
+
}
|
|
1458
|
+
});
|
|
1459
|
+
if (result.code !== 0 || result.timedOut) {
|
|
1460
|
+
const ps = {
|
|
1461
|
+
id: phase.id,
|
|
1462
|
+
status: "failed",
|
|
1463
|
+
output: result.stdout,
|
|
1464
|
+
error: result.timedOut
|
|
1465
|
+
? `Script timed out after ${SCRIPT_TIMEOUT_MS}ms`
|
|
1466
|
+
: `Script exited with code ${result.code}${result.stderr ? ": " + result.stderr.slice(0, 500) : ""}${result.stdoutOversize ? " [stdout truncated at 1 MB]" : ""}`,
|
|
1467
|
+
timedOut: result.timedOut || undefined,
|
|
1468
|
+
usage: emptyUsage(),
|
|
1469
|
+
inputHash,
|
|
1470
|
+
endedAt: Date.now(),
|
|
1471
|
+
};
|
|
1472
|
+
if (readRefs.length)
|
|
1473
|
+
ps.reads = readRefsToReads(readRefs, state);
|
|
1474
|
+
// Non-zero exit: cache (deterministic failure). Timeout: don't cache (transient, like spawn errors).
|
|
1475
|
+
if (!result.timedOut)
|
|
1476
|
+
recordCache(cc, ps);
|
|
1477
|
+
return ps;
|
|
1478
|
+
}
|
|
1479
|
+
const ps = {
|
|
1480
|
+
id: phase.id,
|
|
1481
|
+
status: "done",
|
|
1482
|
+
output: result.stdout.trimEnd() + (result.stdoutOversize ? "\n[stdout truncated at 1 MB]" : ""),
|
|
1483
|
+
usage: emptyUsage(),
|
|
1484
|
+
inputHash,
|
|
1485
|
+
endedAt: Date.now(),
|
|
1486
|
+
};
|
|
1487
|
+
if (readRefs.length)
|
|
1488
|
+
ps.reads = readRefsToReads(readRefs, state);
|
|
1489
|
+
recordCache(cc, ps);
|
|
1490
|
+
return ps;
|
|
1491
|
+
}
|
|
1492
|
+
catch (err) {
|
|
1493
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1494
|
+
// Note: intentionally NOT cached — spawn errors (ENOENT, permission denied, etc.)
|
|
1495
|
+
// are treated as transient. The phase will re-execute on resume/retry.
|
|
1496
|
+
const ps = {
|
|
1497
|
+
id: phase.id,
|
|
1498
|
+
status: "failed",
|
|
1499
|
+
error: `Script error: ${msg}`,
|
|
1500
|
+
usage: emptyUsage(),
|
|
1501
|
+
inputHash,
|
|
1502
|
+
endedAt: Date.now(),
|
|
1503
|
+
};
|
|
1504
|
+
if (readRefs.length)
|
|
1505
|
+
ps.reads = readRefsToReads(readRefs, state);
|
|
1506
|
+
return ps;
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1038
1509
|
if (type === "parallel") {
|
|
1039
1510
|
const branches = (phase.branches ?? []).map((b) => {
|
|
1040
1511
|
const r = interpolate(b.task, ctx);
|
|
@@ -1390,14 +1861,17 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1390
1861
|
const rawMax = phase.maxIterations ?? LOOP_DEFAULT_MAX_ITERATIONS;
|
|
1391
1862
|
const maxIters = Math.max(1, Math.min(LOOP_HARD_MAX_ITERATIONS, Math.floor(rawMax)));
|
|
1392
1863
|
const convergence = phase.convergence ?? true;
|
|
1864
|
+
const reflexionOn = phase.reflexion === true;
|
|
1393
1865
|
// Canonical first-iteration body for the cache key. It must fold in the
|
|
1394
1866
|
// interpolated task/upstream refs so that a changed upstream changes the
|
|
1395
1867
|
// key and recompute no longer silently reuses a stale loop (critic finding).
|
|
1868
|
+
// Reflexion loops resolve {reflexion} to the SENTINEL here so the key
|
|
1869
|
+
// reflects the true first prompt (not a literal placeholder).
|
|
1396
1870
|
const firstBodyCtx = buildInterpolationContext(state, previousOutput, {
|
|
1397
1871
|
loop: { iteration: 1, lastOutput: "", maxIterations: maxIters },
|
|
1398
|
-
}, (ref) => readRefs.push(ref));
|
|
1872
|
+
}, (ref) => readRefs.push(ref), reflexionOn ? REFLEXION_SENTINEL : undefined);
|
|
1399
1873
|
const firstBody = preRead + interpolate(phase.task ?? "", firstBodyCtx).text;
|
|
1400
|
-
const inputHash = hashInput(phase.id, "loop", phase.until ?? "", firstBody, String(maxIters));
|
|
1874
|
+
const inputHash = hashInput(phase.id, "loop", phase.until ?? "", firstBody, String(maxIters), reflexionOn ? "reflexion" : "");
|
|
1401
1875
|
const usages = [];
|
|
1402
1876
|
const loopWarnings = [];
|
|
1403
1877
|
let lastOutput = "";
|
|
@@ -1405,24 +1879,103 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1405
1879
|
let iterations = 0;
|
|
1406
1880
|
let stop = "maxIterations";
|
|
1407
1881
|
let failedResult;
|
|
1882
|
+
// Bounded history of failed iterations (issue #17): when a reflexion loop
|
|
1883
|
+
// continues past failures, only the terminal one survives in `error` — keep
|
|
1884
|
+
// the rest for post-hoc debugging. Capped to avoid unbounded state growth.
|
|
1885
|
+
const LOOP_FAILURE_HISTORY_CAP = 20;
|
|
1886
|
+
const loopFailures = [];
|
|
1887
|
+
const recordLoopFailure = (iteration, r) => {
|
|
1888
|
+
const err = isContractViolation(r.errorMessage)
|
|
1889
|
+
? r.errorMessage
|
|
1890
|
+
: sanitizeErrorMessage(r.errorMessage || r.stderr || "") || `iteration ${iteration} failed`;
|
|
1891
|
+
loopFailures.push({ iteration, error: err });
|
|
1892
|
+
if (loopFailures.length > LOOP_FAILURE_HISTORY_CAP)
|
|
1893
|
+
loopFailures.shift();
|
|
1894
|
+
};
|
|
1895
|
+
// Reflexion state: what the NEXT iteration should be told about THIS one.
|
|
1896
|
+
let reflexionNext;
|
|
1897
|
+
let lastReflexion;
|
|
1898
|
+
let reflexionAppendWarned = false;
|
|
1899
|
+
// With reflexion on, a failed iteration continues (as feedback) instead of
|
|
1900
|
+
// terminating — track whether the LAST iteration failed so an exhausted
|
|
1901
|
+
// loop still fails (reflexion defers failure, it does not erase it).
|
|
1902
|
+
let lastIterationFailed = false;
|
|
1408
1903
|
for (let i = 1; i <= maxIters; i++) {
|
|
1409
1904
|
if (deps.signal?.aborted) {
|
|
1410
1905
|
stop = "aborted";
|
|
1411
1906
|
break;
|
|
1412
1907
|
}
|
|
1413
1908
|
iterations = i;
|
|
1909
|
+
// Assemble the reflexion summary for this iteration (fail-open: a
|
|
1910
|
+
// reflexion assembly bug must never sink the phase).
|
|
1911
|
+
let reflexionStr;
|
|
1912
|
+
if (reflexionOn) {
|
|
1913
|
+
if (i === 1 || !reflexionNext) {
|
|
1914
|
+
reflexionStr = REFLEXION_SENTINEL;
|
|
1915
|
+
}
|
|
1916
|
+
else {
|
|
1917
|
+
try {
|
|
1918
|
+
reflexionStr = buildReflexionSummary(reflexionNext);
|
|
1919
|
+
}
|
|
1920
|
+
catch (e) {
|
|
1921
|
+
reflexionStr = REFLEXION_SENTINEL;
|
|
1922
|
+
loopWarnings.push(`reflexion summary failed to assemble (iteration ${i}): ${e instanceof Error ? e.message : String(e)}`);
|
|
1923
|
+
}
|
|
1924
|
+
lastReflexion = reflexionStr;
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1414
1927
|
// The body sees its iteration number and the prior iteration's output.
|
|
1415
1928
|
const bodyCtx = buildInterpolationContext(state, previousOutput, {
|
|
1416
1929
|
loop: { iteration: i, lastOutput, maxIterations: maxIters },
|
|
1417
|
-
}, (ref) => readRefs.push(ref));
|
|
1418
|
-
|
|
1419
|
-
|
|
1930
|
+
}, (ref) => readRefs.push(ref), reflexionStr);
|
|
1931
|
+
let body = preRead + interpolate(phase.task ?? "", bodyCtx).text;
|
|
1932
|
+
// Auto-append: reflexion is on but the task never mentions {reflexion} —
|
|
1933
|
+
// inject the summary anyway (opt-in via the flag IS the author's intent)
|
|
1934
|
+
// and tell them once how to control placement.
|
|
1935
|
+
if (reflexionOn && reflexionStr && reflexionStr !== REFLEXION_SENTINEL && !(phase.task ?? "").includes("{reflexion}")) {
|
|
1936
|
+
body = `${body}\n\n---\n\n${reflexionStr}`;
|
|
1937
|
+
if (!reflexionAppendWarned) {
|
|
1938
|
+
reflexionAppendWarned = true;
|
|
1939
|
+
loopWarnings.push("reflexion: true but the task has no {reflexion} placeholder — the summary was auto-appended; add {reflexion} to control placement");
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
const r = await runOne(agentName, body, liveSink(state, phase.id, emitProgress), undefined, contractCheck);
|
|
1420
1943
|
usages.push(r.usage);
|
|
1944
|
+
// Fold cumulative loop spend into the live phase state so the run-level
|
|
1945
|
+
// budget guard (overBudget reads state.phases[*].usage) sees the loop's
|
|
1946
|
+
// accrual mid-phase — otherwise each iteration would overwrite the last
|
|
1947
|
+
// and a reflexion loop could spend past the ceiling unnoticed.
|
|
1948
|
+
const livePs = state.phases[phase.id];
|
|
1949
|
+
if (livePs)
|
|
1950
|
+
livePs.usage = aggregateUsage(usages);
|
|
1421
1951
|
if (isFailed(r)) {
|
|
1952
|
+
// Reflexion mode: a body failure becomes feedback for the next
|
|
1953
|
+
// iteration instead of terminating the loop. Timeout, abort, and an
|
|
1954
|
+
// exhausted budget still hard-stop (consistent with "timedOut is never
|
|
1955
|
+
// retried"; continuing past the budget would spend past the ceiling).
|
|
1956
|
+
const hardStop = !reflexionOn || r.phaseTimeout === true || deps.signal?.aborted === true || overBudget(state).over;
|
|
1957
|
+
if (hardStop) {
|
|
1958
|
+
failedResult = r;
|
|
1959
|
+
stop = "failed";
|
|
1960
|
+
recordLoopFailure(i, r);
|
|
1961
|
+
break;
|
|
1962
|
+
}
|
|
1422
1963
|
failedResult = r;
|
|
1423
|
-
|
|
1424
|
-
|
|
1964
|
+
lastIterationFailed = true;
|
|
1965
|
+
recordLoopFailure(i, r);
|
|
1966
|
+
// Sanitize before injecting into the next prompt: raw provider errors
|
|
1967
|
+
// can carry HTML/transport noise (same policy as the transcript path).
|
|
1968
|
+
const rawErr = r.errorMessage || r.stderr || undefined;
|
|
1969
|
+
reflexionNext = {
|
|
1970
|
+
iteration: i,
|
|
1971
|
+
outcome: isContractViolation(r.errorMessage) ? "contract-violation" : "subagent-error",
|
|
1972
|
+
output: r.output,
|
|
1973
|
+
errorMessage: isContractViolation(r.errorMessage) ? r.errorMessage : rawErr ? sanitizeErrorMessage(rawErr) : undefined,
|
|
1974
|
+
};
|
|
1975
|
+
continue;
|
|
1425
1976
|
}
|
|
1977
|
+
lastIterationFailed = false;
|
|
1978
|
+
failedResult = undefined;
|
|
1426
1979
|
prevOutput = lastOutput;
|
|
1427
1980
|
lastOutput = r.output;
|
|
1428
1981
|
// Expose this iteration's output as {steps.<thisId>.output|json} so the
|
|
@@ -1449,16 +2002,28 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1449
2002
|
stop = "converged";
|
|
1450
2003
|
break;
|
|
1451
2004
|
}
|
|
2005
|
+
// Succeeded but not done — the next iteration reflects on the unmet
|
|
2006
|
+
// stop condition (until-not-met is a signal too, not just failures).
|
|
2007
|
+
if (reflexionOn) {
|
|
2008
|
+
reflexionNext = { iteration: i, outcome: "until-not-met", output: lastOutput, until: phase.until };
|
|
2009
|
+
}
|
|
1452
2010
|
}
|
|
1453
2011
|
const aggUsage = usages.length ? aggregateUsage(usages) : emptyUsage();
|
|
1454
|
-
|
|
2012
|
+
// Reflexion: an exhausted loop whose LAST iteration failed is a failure —
|
|
2013
|
+
// reflexion defers termination for feedback, it does not convert a failing
|
|
2014
|
+
// loop into a success.
|
|
2015
|
+
if (reflexionOn && stop === "maxIterations" && lastIterationFailed) {
|
|
2016
|
+
stop = "failed";
|
|
2017
|
+
}
|
|
2018
|
+
if (stop === "failed" || stop === "aborted") {
|
|
1455
2019
|
return {
|
|
1456
2020
|
id: phase.id,
|
|
1457
2021
|
status: "failed",
|
|
1458
2022
|
output: lastOutput || undefined,
|
|
1459
2023
|
usage: aggUsage,
|
|
2024
|
+
timedOut: failedResult?.phaseTimeout || undefined,
|
|
1460
2025
|
error: failedResult?.errorMessage || failedResult?.stderr || (stop === "aborted" ? "Aborted" : `loop '${phase.id}' iteration ${iterations} failed`),
|
|
1461
|
-
loop: { iterations, stop },
|
|
2026
|
+
loop: { iterations, stop, ...(lastReflexion ? { reflexion: lastReflexion } : {}), ...(loopFailures.length ? { failures: [...loopFailures] } : {}) },
|
|
1462
2027
|
warnings: loopWarnings.length ? loopWarnings : undefined,
|
|
1463
2028
|
inputHash,
|
|
1464
2029
|
reads: readRefsToReads(readRefs, state),
|
|
@@ -1471,7 +2036,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1471
2036
|
output: lastOutput,
|
|
1472
2037
|
json: parseJson ? safeParse(lastOutput) : undefined,
|
|
1473
2038
|
usage: aggUsage,
|
|
1474
|
-
loop: { iterations, stop },
|
|
2039
|
+
loop: { iterations, stop, ...(lastReflexion ? { reflexion: lastReflexion } : {}), ...(loopFailures.length ? { failures: [...loopFailures] } : {}) },
|
|
1475
2040
|
warnings: loopWarnings.length ? loopWarnings : undefined,
|
|
1476
2041
|
inputHash,
|
|
1477
2042
|
reads: readRefsToReads(readRefs, state),
|
|
@@ -1518,6 +2083,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1518
2083
|
status: "failed",
|
|
1519
2084
|
usage: variantUsage,
|
|
1520
2085
|
error: `tournament '${phase.id}': all ${competitors.length} variants failed`,
|
|
2086
|
+
timedOut: ran.some((r) => r.phaseTimeout) || undefined,
|
|
1521
2087
|
budgetTruncated: budgetSkipCount > 0 || undefined,
|
|
1522
2088
|
tournament: { variants: competitors.length, winner: 0, mode },
|
|
1523
2089
|
inputHash,
|