Skip to content

feat: scoring gates, reflexion loops, and side-effect classification - #16

Merged
heggria merged 4 commits into
mainfrom
feat/top3-idempotent-score-reflexion
Jul 3, 2026
Merged

heggria merged 4 commits into
mainfrom
feat/top3-idempotent-score-reflexion

Conversation

@heggria

@heggria heggria commented Jul 3, 2026

Copy link
Copy Markdown
Owner

Summary

The Top 3 features from the 2026-07 frontier council, in the arbiter's order (C → A → B), per the arbitrated plan in docs/internal/plan-2026-07-top3.md (included).

# Feature What it closes
C idempotent: false — side-effect classification Graph-Harness principle 4; unblocks safe incremental usage; prerequisite for checkpoint snapshots
A score — scoring gates (6 deterministic scorers + llm-judge, all/any/weighted) The only HIGH competitive gap (vs Mastra's 15+ scorers); zero-token quality bars
B reflexion: true — reflexion memory in loops White space #10 — no competitor ships it; loops converge faster (fewer iterations = fewer tokens)

Design highlights

C (2 surgical lines + a single-exit-point stamp):

  • cacheScope = "off" gates ALL cache paths at once (within-run, cross-run serve/store, map per-item, incremental)
  • Only the implicit transient auto-retry is suppressed — explicit retry{} stays honored (the author's declared acceptance of repeats)
  • sideEffect: true marker (⚡ in the TUI), stamped in the executePhase wrapper so all 10 type branches are covered

A (extends gate — no 11th phase type):

  • Deterministic pass → auto-PASS with zero LLM tokens (mirrors the eval fast-path)
  • Weighted + judge: deterministic score is a lower bound — clearing the threshold skips the judge (token-saving)
  • Fail-open per project invariants: unparseable judge → PASS; unresolved target → PASS + warning; malformed score → plain LLM gate. Deterministic failure with no fallback → explicit BLOCK (not ambiguity)
  • Structured result is the gate's .json — downstream when: "{steps.quality.json.combined} >= 0.9" routing works
  • Pure/impure split mirrors contract.ts: scorers.ts (pure) ↔ scorer-runtime.ts (spawns compilers, 30s cap)

B (arbiter-resolved semantics):

  • Body failures become feedback, not termination — expect contract diagnostics ($.done: required key is missing) feed the next iteration verbatim
  • Timeout/abort still hard-stop; maxIterations exhausted on a failure still fails the phase (reflexion defers, never erases)
  • reflexion off = byte-for-byte historical behavior (pinned by test)
  • Also fixes a latent validation bug: loop until self-references ({steps.<thisId>.json.done} — the documented pattern) were flagged as errors

Cross-feature composition (verified end-to-end)

examples/quality-pipeline.json: analyze → score gate (weighted, judge escalation) → reflexion loop (contract-driven action plan) → idempotent:false notify script. A mock-runner smoke test of this exact composition passes: zero-token gate auto-pass, iteration-2 prompt carries the $.done diagnostic, notify records ⚡ and never caches.

Test plan

  • 49 new tests: 11 idempotent (full behavioral matrix) + 21 score (every scorer, combine mode, fallback, fail-open) + 17 reflexion (compat, feedback, auto-append, hard-stops, audit)
  • Full suite: 1001/1001 (was 952)
  • npm run typecheck + npm run build clean
  • Skills (pi + codex), README, and example updated in the same PR (per RELEASE.md checklist)

heggria added 2 commits July 3, 2026 11:28
The Top 3 features from the 2026-07 frontier council, implemented per the
arbitrated plan (docs/internal/plan-2026-07-top3.md), in the arbiter's
order: idempotent → score → reflexion.

## Feature C — Side-effect classification (`idempotent: false`)

Any phase may declare it has irreversible side effects (webhook POSTs,
deploys, DB writes). When set:
- transient provider errors are NOT auto-retried — repeating a
  side-effecting call behind the author's back is exactly the hazard the
  flag prevents; explicit `retry{}` stays honored (the author's declared
  acceptance of repeats)
- the result is never cached in ANY scope: within-run resume, cross-run
  serve AND store, map per-item — one cacheScope="off" assignment gates
  every path, including under a flow-level `incremental`
- the phase state records `sideEffect: true` (rendered as ⚡), stamped at
  executePhase's single exit point so every type branch is covered

Default true — 100% backward compatible. Validation warns on the
cross-run/incremental conflicts (runtime silently does the safe thing).

## Feature A — Scoring gates (gate `score` field)

Gates gain graded, composable, auditable quality checks. Six deterministic
scorers (exact-match, contains, regex[negate], json-schema — reusing the
expect contract validator, length-range, code-compiles via node --check /
tsc) run against an interpolated target at ZERO tokens, combined via
all/any/weighted+threshold:
- deterministic pass → auto-PASS, no LLM call (mirrors the eval fast-path;
  with weighted+judge the deterministic score is a LOWER BOUND — clearing
  the threshold makes the judge's opinion irrelevant, so it is skipped)
- deterministic fail → optional llm-judge decides (sees target + scorer
  report; fail-open PASS on unparseable output), or the gate task runs
  with the report appended, or — with no fallback — explicit BLOCK
  (a deterministic failure is not ambiguity)
- structured result is the gate's ps.json: downstream phases route on
  {steps.<gate>.json.combined} / .json.results.N.passed
- score gates support onBlock:"retry" (re-run upstream, re-score) with the
  same depth/budget guards as the non-score path
- malformed score fails open to the plain LLM gate with a warning

Pure/impure split mirrors contract.ts: scorers.ts (pure, zero-dep) ↔
scorer-runtime.ts (spawns compilers, 30s timeout, temp-file cleanup).

## Feature B — Reflexion memory in loops (`reflexion: true`)

Loop iterations after the first receive a structured failure summary of
the prior one via {reflexion} (auto-appended when absent, with a one-time
warning; iteration 1 sees a sentinel): expect-contract diagnostics (the
strongest signal — "$.done: required key is missing"), the error message,
or the unmet until condition, plus a truncated output snippet (2000-char
cap, head+tail truncation).

Semantics shift to enable self-correction: body failures become FEEDBACK
instead of terminating the loop. Timeout/abort still hard-stop; exhausting
maxIterations on a failure still fails the phase (reflexion defers
failure, never erases it). Convergence/until still evaluate on successful
outputs only. The last summary is persisted on ps.loop.reflexion for
audit. reflexion off (default) is byte-for-byte the historical behavior.

Also fixes a latent validation bug: loop phases legitimately reference
their own output ({steps.<thisId>.json.done} in until — the documented
stop-condition pattern) but the self-reference check flagged it as an
error; loops are now exempt.

## Surface updates

- schema.ts: 3 new PhaseSchema fields + validation + collectRefs scanning
  score.target / score.judge.task
- interpolate.ts: {reflexion} placeholder (single string, no sub-paths)
- store.ts: PhaseState.sideEffect, gate.scores, loop.reflexion
- render.ts: ⚡ side-effect marker, combined score next to PASS/BLOCK
- skills (pi SKILL.md + codex plugin SKILL.md), README, and a runnable
  examples/quality-pipeline.json composing all three features
- 49 new tests (11 idempotent + 21 score + 17 reflexion); suite 1001/1001

Ships docs/internal/plan-2026-07-top3.md — the arbitrated implementation
plan this PR executes.
Multi-agent adversarial review (4 attack lenses → code-grounded claims
verifier → final arbiter; run top3-adversarial-review-mr4drcvh) ruled
MERGE-WITH-FIXES on PR #16. This commit applies all 3 merge blockers and
all 5 should-fixes.

Merge blockers:
- C1 (correctness): all/any + judge no longer auto-skips the judge when
  deterministics pass. The auto-pass fast-path now requires that the
  judge CANNOT veto: either no judge is configured, or combine is
  weighted (where the deterministic score is a judge-inflated lower
  bound). With all/any the judge's verdict is authoritative — it may
  check what scorers cannot (e.g. factuality) — so it always runs.
  Test: "all/any + judge — judge ALWAYS runs".
- S1 (security): code-compiles scorers are blocked in dynamic
  (LLM-generated) sub-flows. The typescript path runs `npx --no-install
  tsc`, resolving node_modules/.bin from the working directory — a
  repo-planted fake tsc becomes arbitrary code execution, the same
  capability class as the already-blocked `script` phases.
- S2 (security): regex scorers are blocked in dynamic sub-flows —
  `new RegExp(pattern).test(target)` with an LLM-authored catastrophic-
  backtracking pattern is a synchronous ReDoS with no timeout.
  Author-written flows keep both (a human reviewed them).
  Test: "dynamic flows: code-compiles and regex scorers are blocked".

Should-fixes:
- R2: reflexion loops now hard-stop on over-budget (previously only the
  runOne retry loop and run-level guard applied; a failing reflexion
  loop could spend past the ceiling). Also folds cumulative loop usage
  into the live phase state each iteration so overBudget() actually
  sees mid-loop accrual. Test: "over-budget hard-stops the loop".
- S4: provider error noise is sanitized (sanitizeErrorMessage) before
  reflexion injects it into the next prompt — HTML gateway pages no
  longer pollute iteration prompts. Contract-violation messages are
  passed through verbatim (they're runtime-generated, and the precise
  diagnostics are the feature). Test: "provider error noise is
  sanitized before prompt injection".
- S5: the judge prompt neutralizes ``` fences in the model-produced
  target (zero-width-space split) so crafted output cannot close the
  evidence block and inject instructions at prompt level.
  Test: "judge prompt neutralizes fences in the target".
- V5: until/maxIterations/convergence on non-loop phases now warn
  (previously silently ignored).
- V1: per-scorer fields not applicable to the scorer's type are now
  shape errors (e.g. {type:"contains", negate:true} was silently
  dropping negate — the author believed negation happened).

Docs updated to match: SKILL.md (pi + codex) and README now state the
judge-cannot-veto auto-pass condition and the dynamic-flow scorer
blocks.

Suite: 1006/1006 (5 new regression tests pinning the fixes).
@heggria

heggria commented Jul 3, 2026

Copy link
Copy Markdown
Owner Author

🔍 Multi-agent adversarial review — ruling applied

Process: 4 parallel attack lenses (risk-reviewer: runtime semantics · security-reviewer: new attack surface · reviewer: DSL/docs consistency · critic: design decisions) → code-grounded claims verifier (every finding checked against actual source; hallucinated findings refuted) → final-arbiter ruling.

Verdict: MERGE-WITH-FIXES — all 3 blockers + all 5 should-fixes applied in d63ed41.

Merge blockers (fixed)

# Finding Fix
C1 (correctness) all/any + judge: deterministic pass silently bypassed a configured judge — a factuality judge would never run on a scorer-passing-but-wrong output. Code comment even contradicted the behavior. Auto-pass now requires the judge cannot veto: no judge, or weighted (lower-bound math). all/any + judge → judge always runs, verdict authoritative.
S1 (security) code-compiles reachable from LLM-generated dynamic sub-flows: the ts path runs npx --no-install tsc, resolving node_modules/.bin from cwd → repo-planted fake tsc = RCE. Same capability class as script (already blocked) — the block just didn't cover scorers. Blocked in validateTaskflow dynamic hardening; authored flows unaffected.
S2 (security) regex scorers in dynamic flows: LLM-authored catastrophic-backtracking pattern = synchronous ReDoS, no timeout. Blocked in dynamic flows (Option A per arbiter); authored flows keep regex.

Should-fixes (applied)

  • R2: reflexion loops hard-stop on over-budget + mid-loop usage now folds into live phase state so overBudget() sees the accrual
  • S4: provider error noise sanitized before reflexion prompt injection (contract diagnostics pass through verbatim — they're the feature)
  • S5: judge prompt neutralizes ``` fences in the model-produced target (fence-escape injection guard)
  • V5: until/maxIterations/convergence on non-loop phases now warn
  • V1: per-scorer inapplicable fields rejected ({type:"contains", negate:true} silently dropped negate)

Notable refuted findings (verifier caught these hallucinations)

  • R1 "stale-cache on onBlock:retry" (risk-reviewer's ONLY blocker candidate): factually wrong — cc.prior is captured before the phase runs and the first score result is a local never written back; no cache hit possible on retry.
  • C4 "sideEffect stamp poisons cross-run cache": non-idempotent phases never reach recordCache (scope forced off) — unreachable by construction.

Deferred → filed as issues

#17 (reflexion failure history) · #18 (temp-file TOCTOU + process-group kill) · #19 (interpolation docs + codex SKILL parity) · #20 (idempotent resume warning + approval/flow semantics)

Design rulings (arbiter, kept as-is)

  • reflexion couples feedback + failure-continuation in one flag: kept — opt-in, documented, bounded by maxIterations+budget; split only if adoption shows confusion
  • deterministic-fail=BLOCK vs unresolved-target=PASS asymmetry: principled — explicit failure vs ambiguity, consistent with the fail-open invariant

Suite: 1006/1006 (5 new regression tests pin the fixes).

heggria added 2 commits July 3, 2026 14:27
…ve + close follow-ups

Integrates the single-source skill architecture (#14) with the Top 3
features (#16): the score/reflexion/idempotent documentation, previously
edited directly in the generated SKILL.md files, is now authored ONCE in
skills-src/taskflow/core.md and compiled to both hosts via
build-skills.mjs. The drift guard (skills-build.test.ts) passes.

Also resolves all four deferred review follow-ups (issues #17-#20) so the
release ships without known debt:

- #17 (loop): PhaseState.loop.failures records every failed iteration
  (bounded to 20, sanitized). A reflexion loop that continues past
  failures no longer loses the intermediate diagnostics — only the
  terminal one previously survived in `error`.
- #18 (scorer-runtime): code-compiles now runs inside an isolated
  mkdtempSync directory used as the compiler cwd. Closes the predictable
  temp-name TOCTOU race AND stops `npx tsc` resolving a repo-planted
  node_modules/.bin/tsc from the phase cwd (defense-in-depth beyond the
  dynamic-flow scorer block). The cwd parameter is dropped from the
  scorer signature. (Process-group kill declined: the codebase's script
  phase kills the direct child too; orphans are bounded by the 30s
  timeout and cross-platform process groups would diverge from that.)
- #19 (docs): the shared core.md now documents {reflexion}, {loop.*},
  and the score.target/judge.task interpolation sites; codex SKILL.md
  reaches feature parity automatically through the single-source build.
- #20 (idempotent): a non-idempotent phase that re-executes on resume
  (a prior attempt had completed) now surfaces a warning so operators
  see the side effect re-firing; validation warns that idempotent:false
  on approval/flow phases is a no-op.

Docs (README, both SKILL.md sets) reflect the post-review score
semantics: auto-pass only when the judge cannot veto (no judge, or
weighted lower-bound), and the dynamic-flow code-compiles/regex blocks.

Suite: 1014/1014 (drift guard + 4 new regression tests for the
follow-ups). dist rebuilt.
…DoS)

CodeQL flagged 2 high-severity js/polynomial-redos alerts on PR #16.
Root cause: the #16 edit to interpolate.ts (the {reflexion} placeholder
hook) shifted line numbers, re-attributing two PRE-EXISTING super-linear
regexes in safeParse to the PR diff. Both are genuine polynomial
backtracking and worth fixing regardless of attribution:

- The fence extractor had an OPTIONAL trailing newline, letting the
  horizontal-whitespace class and the body's lazy [\s\S]*? both match a
  whitespace run — quadratic on a fence opener followed by many
  spaces/tabs with no closing fence. Rewritten so the info string stops
  at the first newline (no overlap with the body) and the newline is
  mandatory — linear by construction. (scorer-runtime's extractCode
  already used a mandatory newline, which is why CodeQL did not flag it.)
- The stray-key hint had two \s* around an optional separator (ambiguous
  whitespace). Collapsed to a single character class — linear.

Behavior preserved (all fence/stray-key tests pass unchanged; every
fenced example in the suite uses a newline after the tag). Adds a
regression test asserting safeParse stays sub-second on 100k-char
adversarial inputs.

Suite: 1015/1015.
@heggria
heggria merged commit 5b19875 into main Jul 3, 2026
6 checks passed
@heggria
heggria deleted the feat/top3-idempotent-score-reflexion branch July 3, 2026 06:37
@heggria heggria mentioned this pull request Jul 3, 2026
5 tasks done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant