Skip to content

fix(gate)!: answer the reply judge's first review: refused calls, a failed last turn, pending batches, quoted participant text and too-long requests - #850

Open
nissa-seru wants to merge 30 commits into
anima-research:mainfrom
nissa-seru:fix/reply-judge-refused-calls
Open

nissa-seru wants to merge 30 commits into
anima-research:mainfrom
nissa-seru:fix/reply-judge-refused-calls

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

This answers @Anarchid's first review of the reply judge (#801, released in agent-framework 0.28.0): under onError: "skip", when the framework refuses a batch's judge call, the gate now holds the batch until it can be judged; the judge is told when the agent's last turn failed and about every pending batch; and participant text can no longer make the structure of the judge's document. It also asks a request the provider refuses as too long once more, at half the ratio it was built at, judges a batch by its rule as the rule is when the judge is asked, and corrects the texts in the code that said more than the code does. A changelog fragment corrects 0.28.0's notes on the judge where they did too. One change is breaking for code that handles each value of the judge's verdict: 'refused' joins judgeState.last.verdict and the gate:judge-verdict trace's verdict. Each item below that answers a finding names it, as R1-n from the review. "Out of scope" names what this leaves of the review.

Problem

The review, at b296996, found these in #801, which has since merged and was released in 0.28.0. This change's tests fail on 0.28.0's code (59d60958) for each of the first six (Tests, below).

  • R1-1. When the framework refused a judge call without making it (a provider hold on the agent or on the judge's model, a surgery's hold on the store, a stopping framework), the gate dropped the batch under onError: "skip". Under either setting, nothing traced or counted the refusal.
  • R1-2. When a turn the judge's REPLY woke failed with its retries spent, the judge's next request said nothing of the failure beyond any [inference-failed] marker the failed turn left in the agent's context. On two such requests built by feat(gate): judge wake rules — ask a small model before waking #801's code, the judge's model answered WAIT 10 times of 10 each (Not verified says what was measured).
  • R1-3. Message text and event content went into the judge's document as written, so a participant could write a line that reads as another participant's message, or close <new_events> and open a tag of their own.
  • R1-4. A request the provider refused as too long failed as any failed call does, and wasn't asked again. Nothing lowered the estimate's ratio after the refusal: it's corrected only from a response (calibrate, called at src/gate/reply-judge.ts:488 in 0.28.0).
  • R1-5. A batch pending when gate.json was reloaded was judged by its rule's instructions from before the edit.
  • R1-6. <if_you_wait> didn't list other judge rules' batches being decided or held, or events buffered behind a running turn.
  • R1-4, R1-7 and R1-10, in texts. Texts said more than the code does:
    • the judge's header said the budget keeps every request under the 100,000-token line, and the maxInputTokens doc called it the most input tokens one request may carry;
    • the header and the changelog said the judge reads the request the agent would send, with no word that the dry run leaves out injections;
    • the changelog said the judge always uses membrane's native formatter;
    • the cacheTtl doc didn't say that Bedrock drops it.

Changes

  • A refused call (R1-1). When the framework refuses a judge call without making it, the gate records a verdict of refused:

    • counted in the rule's judgeState.verdicts.refused (refused calls, not batches), apart from errors, and outside the three-failures streak;
    • traced as gate:judge-verdict with verdict: "refused", the hold's words (hold) and its stated end (until), when it stated one;
    • named in gate_status's errors while it lasts, with what judge rules do meanwhile;
    • logged with where its batch goes: it "wakes the agent", "is held while the judge's calls are refused", or "goes back to the judge".
  • While a refusal with a stated end lasts (R1-1), the judge isn't asked, since asking compiles the agent's context only to be refused. The end counts as at least a second on, since a hold whose end has just passed refuses with no time left until the framework's timer releases it.

    • Under onError: "wake", the refused batch, each new batch, and batches held for a turn (at the turn's end) wake the agent unjudged.
    • Under "skip", the refused batch, with the events queued behind it, and each new batch are held in memory, and judged once that end comes.
  • A refusal with no stated end (R1-1), under "skip", keeps its held batches through turns' ends, with any batch that arrived during a turn.

    • A batch that arrives with no turn running is still asked, taking its rule's held batch in with it, so the rule's events stay in the order they came. A verdict on its call ends the refusal and releases the other rules' held batches.
    • An operator's release of a provider wait, and a surgery's end, release them too (EventGate.providerCallsReleased()).
    • A hold that lifts unannounced, as recorded provider waits read again after they couldn't be, leaves them held until the next batch or a release.
  • The newest outcome decides (R1-1). Calls are numbered as the gate asks them.

    • A refusal stands only when no later-asked call has had a verdict, and no release has come, by the time it's reported. Otherwise it doesn't stand: under "skip" its batch is held for whatever refusal does stand, or asked again at once when none does, and under "wake" it wakes the agent, as any refused call's batch does.
    • A verdict ends a refusal only when its call was asked after the refused one.
    • A failed call says nothing of the hold, so it changes neither.
  • A failed last turn (R1-2). When the agent's last turn failed with its retries spent and no turn has completed since, <if_you_wait> opens with one line. It says the turn failed and may not have answered all of what woke it, and then one of three things:

    • that a wake for the agent is already queued;
    • that one is held, due to be let go at a stated end, which its host or a wait can extend, or until the hold on it is released;
    • or that nothing runs the turn again, so what woke it stays unanswered until something wakes the agent.

    The framework's wake list and this line read one helper, wakesOnTheirWay, so they agree. "Queued" counts requests waiting for the scheduler, in a pass's hands, parked as continuations, or a start that holds provider admission and waits for an auxiliary call to finish. "Held" covers a provider hold whose release hands on a request or queues a retry, and a wait on the agent's model under which the scheduler will hold a queued request. A wake queued while the agent waits for a re-arm counts as queued, since the scheduler takes it up at once and starts the turn (test/exhausted-turn-rearm.test.ts:639). Neither says a turn will start, since the inference policy can decline a wake. The framework gives the judge this through ReplyJudgeDeps.failedLastTurn (type FailedLastTurn).

  • Participant text in the document (R1-3). Message text and event content are escaped (&, <, >), with each line after the first indented. Names, channels and author ids are kept to one line, with square brackets escaped. An event's "(addresses the agent)" marker comes right after its time, ahead of the channel and author. The line-break rule is its own module, src/supplied-text.ts (oneLine, normalizeLineBreaks).

  • The rule in force (R1-5). A batch is judged by its rule as the rule is when the judge is asked. A reload of gate.json clears the changed rules' state, puts the new rules in force, then sends their pending batches, as a write already did; judgeBatch reads the rule then.

  • Every pending batch (R1-6). A judge request lists every pending batch but the one it asks about. Besides what 0.30.0 listed, that's other judge rules' batches being decided (with what queued behind them) and every held batch, with what it waits for, as "maybe". It's also events buffered while a turn runs or the host is quiesced, listed as certain, to be delivered "once no turn runs and the host isn't quiesced". A buffered event whose channel is held when the list is made isn't counted, since delivery drops an event whose channel is held then, as it has since feat(dendrite): one registry, lifecycle and fork primitive for agents; admission, reader forks, the all-except scope and speech.locus (#232, #77, #270) #546 brought that check to main.

    • EventGate.upcomingWakes(agentNames, exceptPolicy?) keeps 0.28.0's signature and lists the same, leaving out the named rule's batches and its buffered events. The judge's request leaves out only the batch it asks about, so the same rule's other batches and buffered events are listed.
    • The .changed fragment tells a host that gives an EventGate a judge function of its own that an UpcomingWake without at is no longer always due as soon as the agent is free.
    • Held batches are asked about in turn, each leaving the held set as it's asked, so each request lists the rest.
    • The list shows eight, soonest first, and its last line says how many more there are and how many of those are only maybe.
    • A wake due at no time anyone can name says when in words (UpcomingWake.when) and comes last. The framework lists a turn held until an operator releases its wait that way.
    • Each wake's words are quoted as participant text is.
  • A request refused as too long (R1-4). A request the provider refuses as context_length is asked once more, from a fresh window, with the ratio it was built at halved, to no less than one character a token, unless it was built at one already. Refused again, it fails as any failed call does. The lower ratio stays for later requests until responses correct it.

  • Texts (R1-4, R1-7, R1-10).

    • The judge's header and its maxInputTokens doc now say that the budget is met by an estimate, which a request on text denser than three characters a token can pass until a response has corrected the estimate, as can one whose newest message alone is over it, since that message is always shown.
    • The header says how a request refused as too long is asked again.
    • The header says the dry run leaves out what modules' gatherContext and MCPL servers' beforeInference would inject, and ReplyJudgeDeps.preview says it's without its injections.
    • cacheTtl's doc says Bedrock drops it.
    • The docs of onError, JudgeConfig, judgeOnError, UpcomingWake and the gate:judge-verdict trace say what a refused call does.
    • For an agent with the judge wired, wake_remove_rule's description now says in its first sentence that a judge rule's pending batches still go to the judge, by the rule as it was. 0.28.0 and 0.30.0 say it in the last sentence. feat(toolsets)!: the host's own toolsets: a built-in "settings" toolset and MCPL server tabs #860, merged into main after 0.28.0, added a settings toolset, and src/host-presentation.ts:318 lists that toolset's tools with toolLine, which shows the firstSentence of each description (src/mcpl/tool-signature.ts:163-164), so the last sentence wasn't in that list.
    • The review rejected a finding that a wait drops events never placed in the agent's context, since the workspace module stores its message before the gate runs. JudgeConfig's doc now says that a batch's events are in the agent's context whether the agent is woken or not, when they're stored as they arrive, as MCPL's channel and push events are and as the framework's own modules store the message an event of theirs carries, and that a wait, like defer, adds nothing more.
  • The changelog. 0.28.0's release folded feat(gate): judge wake rules — ask a small model before waking #801's fragment into CHANGELOG.md, which stays as released. This change adds four fragments that say what changes against 0.30.0, the latest release:

    • .breaking: the 'refused' value, for code that handles each value of the verdict;
    • .added: EventGate.providerCallsReleased(), ReplyJudgeDeps.failedLastTurn with FailedLastTurn, UpcomingWake.when, and buildRequest()'s ratio;
    • .changed: R1-1 to R1-4, what an UpcomingWake without at now means, and wake_remove_rule's first sentence;
    • .fixed: R1-5 and R1-6, where the code didn't do what 0.28.0's notes say and still didn't in 0.30.0, and five sentences of 0.28.0's notes that said more than the code does, each quoted with what's true. Those are the dry run, the native formatter, maxInputTokens, cacheTtl, and the events staying in context on a wait. For the formatter, a membrane whose own formatter is OpenAI Responses', outside the adapter's subscription mode, keeps it whatever a request asks.

What each part is for

  • The refused verdict, its count, trace and notice. It makes a refusal visible apart from a judge that's failing. Counting it as an error would set off "the judge's model may not serve it" for a provider hold, and leaving it unrecorded is the defect. Its cost is one more value in two verdict unions that 0.28.0 released, so code that handles each of their values needs a 'refused' case, as the .breaking fragment says.
  • Holding under "skip", with a timer at the stated end and the end at least a second on. It's what keeps the batch. Waking under "skip" would change what "skip" means. Waiting for the next turn's end could leave the batch for hours. The second's floor sits in the refusal's end itself, so a turn's end or a new batch can't ask again at once either.
  • Not asking during a stated wait. Each ask runs previewActivation, a compile of the agent's context, before the framework refuses it. Under "wake" the operator chose to wake when the judge can't answer, so the batch wakes unjudged; under "skip" it joins the held ones.
  • The no-end rule: held through turns' ends, a new batch still asked. Holding at turns' ends avoids a compile per turn for each held rule. Letting a new batch ask means a hold that lifted unannounced ends at the next batch, without polling.
  • providerCallsReleased(), called where an operator releases a provider wait (always, since the wait may have lifted unannounced) and where a surgery lets go of the store. Those holds state no end, or an end the release comes before. A provider hold on the agent is asked about again at its stated end by the refusal's timer, and one its host extended refuses again with the new end, so its release isn't wired.
  • The ask counter and the newest outcome. They tell a refusal or a verdict that's news from one an outcome since has overtaken. A refusal of an older call reported late doesn't replace a newer one or outlast a verdict or a release, and a verdict on an older call doesn't end a newer refusal. The gate can't see when the framework admits a call, so the order it asks them in stands in for that.
  • wakesOnTheirWay. One reading for the wake list and the failed-turn line, so the judge isn't told a wake is held in one and that nothing is scheduled in the other. Two of its sources each have a test that fails without them: a wake a scheduler pass still holds while it waits for an earlier agent's start, and a start that waits behind an auxiliary call with nothing queued. A provider hold left holding nothing, by a drop or by a withdrawal of its held wakes (which came to main with fix(scheduler): a merged start that goes back before its turn is merged again from its wakes #877), counts as holding a wake only where it holds back a queued request, since its own end starts no turn. Two conditions on a provider hold each have a test that fails without them. A re-arm doesn't hold a wake queued while the agent waits for it (src/framework.ts:6090). Without that condition, "a wake queued while the agent waits for a re-arm is what the judge is told of, not the re-arm's end, since the wake starts the turn at once" fails. A hold that holds nothing yet still holds a queued wake (:6092). Without that, "lists a wake queued behind a hold that holds nothing yet as held until the hold ends, since a scheduler pass holds it there" fails.
  • wake_remove_rule's first sentence for an agent with the judge (src/framework.ts:13032–13033). The settings toolset shows a tool by its first sentence, so that sentence says a judge rule's pending batches still go to the judge, by the rule as it was. After it comes WAKE_REMOVE_RULE_REST (:12884), the rest of the description every agent gets (:13016), so the two descriptions can't drift apart there. With 4b6c4c1438's source, whose judged description said it last, the test that checks the first sentence (test/event-gate-judge.test.ts:1530–1532) fails (Tests).
  • The failed-turn line, its three endings, and failedLastTurnFor. On two requests an earlier version of this change built, with the line's "nothing runs it again" ending, the judge's model answered REPLY 10 times of 10 on each, and WAIT 10 times of 10 on each without the line (Not verified says what was measured). The endings say only what the framework's state shows, and never that a turn runs.
  • UpcomingWake.when. Without it, a wake due at no nameable time would read "as soon as it is free", or be left out, and the list would then say nothing is scheduled while the failed-turn line said a wake was held. A batch held for the judge under a refusal with no stated end has the when "once a later judge call gets a REPLY or WAIT, or the hold on its calls is released, and no turn or quiesce holds it" (src/gate/event-gate.ts:4005), since a verdict on a call asked before the refusal doesn't end it. With 4b6c4c1438's source, the test of that when fails (Tests).
  • Installing before firing, and reading the rule when asked. Both are needed for gate.json edits: the first for reload's order, the second because a fired batch carried the rule it was batched under.
  • What each list leaves out (upcomingWakesBut). The judge's request leaves out only the batch it asks about. upcomingWakes leaves out the rule it's given, its batches and buffered events alike, so its signature stays 0.28.0's. Without leaving out the asked batch, the judge is told about the batch it's deciding. Asked with its rule's name, as in 0.28.0, the judge isn't told its own rule's next batch. Without the given rule left out of each kind, upcomingWakes lists that rule's batch being collected, decided or held, or its buffered events.
  • The buffered entry, and each condition as a when. Without the entry, the judge isn't told about events already waiting while a turn runs or the host is quiesced. They're delivered once no turn runs and the host isn't quiesced, so the entry lists them as certain. It leaves out an event whose channel is held when the list is made. One predicate, heldNow, decides both which events delivery drops for a held channel and which ones the entry leaves out. Without it, the entry calls a wake certain that delivery would drop, and the test "leaves out of the buffered count an event whose channel is held now" fails. That condition, and the one for a batch held for the judge during a turn or the host's quiesce, are each a when, since a wake with neither at nor when reads as due as soon as the agent is free.
  • The refusal's log line says whether a refused batch is held while the judge's calls are refused or goes back to the judge. Without the difference, a batch that goes back to the judge is logged as held.
  • Tests that pin what earlier parts promise:
    • "a wait on an event outside MCPL writes nothing either, as defer doesn't: its module stores its own message" (test/event-gate-judge.test.ts:236);
    • the whole of the batch asked in "under 'skip', a batch asked while a refusal with no end holds its rule's batch keeps the newest 100 events of the two" (:766), the held batch's last 40 events and the 60 new ones, so the cap can't keep the wrong hundred;
    • re-arming off in the failed-turn test (:1939), so a re-arm's held ending doesn't take the place of its "nothing runs it again" one, and the held line, with its due time, in exhausted-turn-rearm's judge test (test/exhausted-turn-rearm.test.ts:695).
  • The lists, asking held batches in turn, and the overflow line. Without the second, batches flushed together never see each other. The flush checks the refusal again when it runs, so a refusal recorded after the flush was queued keeps the batches held. The cap of eight bounds the request.
  • The quoting helpers and the module. Escaping alone leaves line starts to forge a message, and indenting alone leaves tags. The module is its own, so code outside the judge can take the same rule.
  • An ask taking in its rule's held batch. Without it, a rule's later batch could be asked and delivered while its earlier one waited, and the agent would read the later events first. With it, a rule's held batch and a call being decided never coexist, so a queue or a held batch only ever gains newer events and needs no sorting. The batch it makes is capped as a held batch is, dropping the oldest events past the cap, counted and logged, so taking a long held batch in can drop some of its oldest events, as holding it longer would.
  • The too-long retry, and ask().
    • Without the retry, a request over the model's window fails, and the next is built at the same ratio, since the ratio is corrected only from a response. So while the agent's context stays that long and no response corrects the ratio, every batch's request fails the same way, and under "skip" the agent doesn't wake for its judge rules meanwhile.
    • Halving needs no lookup of the window, and covers both an operator's budget set above it and text denser than the estimate.
    • One retry bounds the cost. A ratio already at 1 isn't asked again, since the request would be the same.
    • It halves the ratio the refused request was built at, not the estimate as it is when the refusal comes back, so two requests refused together halve it once.
    • It drops the window's anchor, since an anchored window that still fits the halved budget would make the same request again.
    • ask() is the one place the judge's call is made, so the first ask and the retry send the same way.
  • The guards.
    • A stated wait that doesn't end at a time a Date can name (Infinity, NaN, or past the year 275760) counts as no end: a timer given a non-finite delay fires at once, and gate_status must be able to name the end.
    • The timer's step: a delay past 2^31−1 ms fires at once too, so a longer wait is waited out in steps.
    • The catch around failedLastTurn: a provider that throws counts as no failed turn, as upcomingWakes does.
  • The mocked clock in event-gate-judge. The file's tests step the gate's own timers (the debounce, the refusal's one-second floor and a stated end) on node:test's mock timers (setTimeout, setInterval and Date) rather than wait them out. A test that asserts nothing happened by a deadline advances the clock past it. Fixed waits step the clock 5 ms at a time, and a wait for a condition, or for the framework to go idle, steps it until the condition holds and fails past a bound on the mocked clock. One test stays on real time, the MCPL end-to-end test, since it waits for a child process's replies, which arrive in real time.
  • Test helpers and test changes. refusingMembrane refuses chosen requests as too long, judgeOver builds a judge over a conversation and budget a test chooses (the fresh-window test needs a conversation that fits the halved budget's anchored window), and quietly silences the gate's log while a test expects one. feat(gate): judge wake rules — ask a small model before waking #801's judge timeout test now waits for the timed-out judge's wake instead of sleeping a fixed 200 ms, which a loaded full suite overran. The failed-turn test's release step asserts the retry ran, by the failure count rising. scriptedJudge records when each request was asked, so tests assert on the time of an ask rather than sleep through windows. answerAt and failAt settle one waiting request out of order. makeGate's onTrace lets a test act as a trace listener, and logDuring collects what the gate logs while a step runs. judgeState and refusal shorten what every refusal test repeats. feat(gate): judge wake rules — ask a small model before waking #801's test of the gate's own timers now checks the self-wake's time exactly, where it allowed 2 s either way.
  • Cut:
    • the hold's start time in the refusal, which nothing read;
    • the two calls at a provider hold's release;
    • the comments that gave reasons, which are in the commit message;
    • the sorting by arrival an earlier version added where batches meet, which taking in the held batch made unnecessary;
    • a separate Number.isFinite check and a second refusal check before the held flush is queued, which the Date bound and the flush's own check cover;
    • the wake list's check that a provider hold made for another model than the agent's doesn't hold back the agent's queued requests. A hold carries a model only when it's made from a stated wait (src/framework.ts:6884), each place that makes one passes it a wait recorded or looked up for the agent's own model (:6490, :6628, :19485, :23235 and :24238), and the agent's hold is deleted when its runtime is released (:11970).

Tests

This head, 0342d90c75, changes only the .changed and .fixed fragments from a4e345bd7e, so the runs below at a4e345bd7e ran this head's src, test and package.json.

  • The full check at a4e345bd7e, whose last merge of main brought in 612f6fd3bd, on Linux with Node 24.22.1, chronicle 0.5.0, context-manager 0.24.1, mcpl-core 0.4.0 and membrane 0.8.0, from a clean checkout and a fresh install:
    • tsc and the build exit clean;
    • the full suite runs 9,474 tests: 9,412 pass, none fail and 62 are skipped;
    • the unforced step passes 137 of 137.
  • On Node 22.12.0, in the same copy with the same packages, the two judge files and four hold suites pass: event-gate-judge 100 of 100, reply-judge 33 of 33, provider-acceleration-cooldown 10 of 10, provider-waits 93 of 93, held-turn-retry 66 of 66 and host-quiesce 51 of 51.
  • The two judge files with 0.28.0's code (59d60958), on Linux with Node 24.22.1, context-manager 0.23.2, membrane 0.8.0 and chronicle 0.4.0. They're run with this change's src/supplied-text.ts and the src/notice-tags.ts it imports added, since the tests import the first, and with an empty stand-in for failedTurnLine, which 0.28.0 lacks. tsc reports 14 errors, among them that 0.28.0's FrameworkConfig has no exhaustedTurnRearmMs, and the tests run from the emitted JavaScript.
    • event-gate-judge: 51 of 100 fail. 48 fail at an assertion: 42 on the behaviour this change makes, 5 only on the refused: 0 the count now carries, and 1 on how fix!: mark the framework's notice openings in others' text, and keep supplied values on one line #852, merged into main after 0.28.0, writes a judge's reason. 3 fail on what 0.28.0 lacks: 1 on providerCallsReleased, and 2 at a test helper that reads hostPresentation.builtinTools. The test that a wait on an event outside MCPL writes no message passes with 0.28.0's code too.
    • reply-judge: 14 of 33 fail. 5 of them are too-long retry tests: 3 fail at an assertion, and 2 when the provider's "prompt is too long" rejects the judge's one call. The other 9 fail at an assertion: 8 on the other behaviour this change makes, and 1 on the stand-in. The sixth retry test, that no other failure is asked again, passes with 0.28.0's code too.
  • The wake_remove_rule check against the merge before it: this head's event-gate-judge.test.ts, run with the source of 4b6c4c1438, this change's merge of main e85c57db9a before its wake_remove_rule commit, on Linux with Node 24.22.1, context-manager 0.23.2, membrane 0.8.0 and chronicle 0.4.0, passes 96 of 100. One test that fails is the one that checks wake_remove_rule's first sentence, which reads "Remove a wake rule (gate.json policy) by name at runtime." there. Another checks the when of a batch held under a refusal with no end, whose wording this change set later. The other two test what came to main after e85c57db9a: a hold emptied by a withdrawal of its held wakes (fix(scheduler): a merged start that goes back before its turn is merged again from its wakes #877), and a buffered event on a channel held at delivery (feat(dendrite): one registry, lifecycle and fork primitive for agents; admission, reader forks, the all-except scope and speech.locus (#232, #77, #270) #546).
  • The re-arm's held ending: at a clean checkout of a4e345bd7e, on Linux with Node 24.22.1, chronicle 0.5.0, context-manager 0.24.1 and membrane 0.8.0, event-gate-judge passes 100 of 100 and exhausted-turn-rearm 84 of 84. exhausted-turn-rearm's judge test checks that the judge is told a re-armed turn's wake is held. With failedLastTurnFor made to answer that nothing runs the turn again where it would say a wake is held, each file fails one test: in event-gate-judge, the test that the judge is told the last turn failed and whether a wake is on its way, alike in the line and the wake list, and in exhausted-turn-rearm, its judge test.
  • The wake list's two newly tested sources, at a clean checkout of a4e345bd7e on Linux with Node 24.22.1, chronicle 0.5.0, context-manager 0.24.1 and membrane 0.8.0: with the list ignoring the wakes a scheduler pass holds, event-gate-judge passes 99 of 100, and only "lists another agent's wake that a scheduler pass holds while it awaits an earlier agent's start" fails. With no wake for a start waiting behind an auxiliary call, it passes 99 of 100, and only "lists a start that waits behind an auxiliary call, with nothing else queued, as a wake on its way" fails. Each fails at its last assertion, with the list empty.
  • The two conditions on a provider hold, in the same copy: without the re-arm condition, exhausted-turn-rearm passes 83 of 84, and only "a wake queued while the agent waits for a re-arm is what the judge is told of, not the re-arm's end, since the wake starts the turn at once" fails. Without the clause that a queued wake is held behind a hold that holds nothing yet, event-gate-judge passes 99 of 100, and only "lists a wake queued behind a hold that holds nothing yet as held until the hold ends, since a scheduler pass holds it there" fails.
  • Removing each part: at 72141a75b8, this change's last commit before its merge of 0.28.0, each of 62 parts was taken out in turn and the two judge files run. 59 removals each failed at least one test. The three that failed none were holding a refused batch before its trace is emitted rather than after, where the tests pass in either order, and two parts of the "queued" ending: a start waiting for an auxiliary call, and a queued request beside a hold that doesn't bind it. At this head the first of those has a test, and the second is cut from the code ("What each part is for"). The run was on Linux with Node 24.22.1, and its receipt doesn't record which packages were installed. At 03422d5948, from a clean checkout on Linux, 11 removals of parts of the judge's list of upcoming wakes and of the refusal's log line were run the same way. At fbf84e01f6, 6 more were: four of those again, the buffered entry's when, and the cap on the batch an ask makes when it takes in its rule's held batch. At 92b0d3ca48, the commit before this change's merge of main at 65bf22bc0a, the buffered entry's when was taken out again, and so was the when of a batch held during a turn or the host's quiesce. Each of these 19 removals failed at least one test. None of the three receipts records the Node version or which packages were installed.
  • The time of event-gate-judge, at 72141a75b8, on Linux with Node 24.22.1 and no added load: five runs of that commit's file took 3.4 to 6.4 s of wall time, and five runs of 8e54e6dd59's version, on real timers and interleaved with them, took 28.3 to 33.3 s.
  • What the tests cover:
    • test/event-gate-judge.test.ts:
      • the refusal: counted, traced with its stated end, noticed per onError and per kind, logged with where its batch goes, held, judged at the end, and held again;
      • a new batch joining a held one, and waking unjudged under "wake";
      • a turn's end under each kind, and the floor whatever asks;
      • a refusal ended only by a verdict on a later-asked call, an older refusal reported late, and one reported after a verdict or a release;
      • a release from a trace listener, a wait that isn't finite or is past one timer, and nothing after dispose;
      • a later batch taking in its rule's held one, the streak, the reload and write paths, and every pending batch's listing, the judge's own rule's next batch and buffered events among them;
      • events buffered while the host is quiesced, in the list;
      • what upcomingWakes leaves out when given a rule's name;
      • the newest 100 events kept when an ask takes in its rule's held batch;
      • a wait on a workspace event, which writes no message and wakes nothing;
      • and, through the framework: an operator's release (with or without a wait recorded), a host hold and a surgery;
      • the failed-turn line and the wake list in the judge's real request: queued, parked, under a wait on the agent's model, held by it, a hold on its model that holds no turn but queues a retry at release, and none;
      • a turn held until released, in the wake list.
    • test/reply-judge.test.ts:
      • the failed-turn line and its endings;
      • the wake list's quoting, its time in words and its overflow line;
      • the quoting of messages, names, channels, authors and events, the addressed marker's place, and the line-break rule;
      • a request refused as too long: asked once more and smaller, from a fresh window when the last one's would still fit; failing when refused again or for another reason; the lower ratio kept, down to one character a token, where it isn't asked again; and two refused together halving it once.

Out of scope

  • R1-4's other two code suggestions, a lower starting ratio and keeping the ratio with the usage state, aren't taken. The review itself puts the cold-start overshoot at cents.
  • R1-8: nothing changes. 0.28.0's notes say each judge request's usage counts as the judged agent's, in its usage tally and the session's totals, as a turn's does.
  • R1-9: nothing changes. The judge stays on unless the host turns it off.
  • R1-3's sentence for the system prompt, saying the document is data rather than instructions, isn't added. Escaping and indenting answer the finding in the document's structure, and a sentence's effect on the model hasn't been measured.
  • Re-arming a turn that exhausted its retries came to main with feat(turns): send a turn's trigger again when its retries run out on a transient failure, with delays of 2, 8 and 32 minutes, and alert on an hour of stated waits #846, and this head carries it. This change only tells the judge about such a turn. With re-arming on, as it is by default, the failed-turn line says a wake for the agent is held, or queued once another wake for it is queued.

Not verified

  • The failed-turn line was measured only with its "nothing runs it again" ending, on claude-haiku-5-5. The calls went through membrane 0.7.2's AnthropicAdapter, with Bun 1.4.4 on Linux.
    • On requests an earlier version of this change built: the requests were built at 8e54e6dd59, with membrane 0.8.0 and Node 24.22.1, and sent between 10:33 and 10:35 UTC on October 27.
      • Two judge requests had the line: one whose failed turn sent nothing, and one where it answered in part. Each answered REPLY 10 times of 10. The same two requests without the line each answered WAIT 10 times of 10.
      • Each answer is read by its last verdict line, as the judge's parseVerdict reads it. One answer to the partial request opened with WAIT and ended with REPLY.
      • The ages the requests show were set by hand, to "2m ago" for the failed turn and "10s ago" for the new message.
    • On requests feat(gate): judge wake rules — ask a small model before waking #801's code builds, with the line inserted as this change's code words it, between 08:08 and 08:10 UTC: REPLY 10 times of 10 on each with the line, and WAIT 10 times of 10 on each without it. Their messages' later lines aren't indented, and their [inference-failed] marker has the wording from before fix(turns): name unposted speech, say whether tool rounds ran, and say when live-routed speech got no [delivered] line #840, which sets the marker's sentences by what the failed turn delivered.
    • The other two endings weren't measured. One of them is the "held" ending, which the judge reads after an exhausted turn while re-arming is on, as it is by default. No other live call was made against this change's text.
  • How a model reads escaped text (&lt;) in the document wasn't measured.
  • Two conditions of the auxiliary start: the wake list counts a start waiting behind an auxiliary call only while no turn of the agent's runs and nothing of its is queued. No test makes either condition false, so those two checks rest on reading the code.
  • The retry through the framework: no test sends a context_length refusal through the framework's judgeMembraneFor, so whether the retry sees its type there rests on reading the code.
  • A restart after a failed turn: no test or run restarts the framework after one. The failed-turn line reads a failure count and time that the framework keeps in memory (consecutiveInferenceFailures and lastInferenceAt, read at src/framework.ts:6102-6103).
  • A held batch past what its entry names: no test reads the list while a turn or the host's quiesce holds a batch past the time, verdict or release its entry names. However its refusal ends, a held batch goes to the judge through flushJudgeHeldIfClear, which waits while the host is quiesced or a turn that changes what the judge reads is running (src/gate/event-gate.ts:4245-4246). So a host that calls upcomingWakes during such a turn can read a time that has passed.

  • Changelog fragments added: changelog.d/reply-judge-review.breaking.md, changelog.d/reply-judge-review.added.md, changelog.d/reply-judge-review.changed.md and changelog.d/reply-judge-review.fixed.md.

Rupert-2634 wrote this change, and Ada-1017 made its design calls. Desmond-2579 read an earlier version of it, before its merges of main and the too-long retry, and fourteen rounds of internal review read earlier versions of it. Duncan-2410 read its last commit before its merge of 65bf22bc0a, Gordon-2414 read that merge, and Alfred-2570 read its merge of e85c57db9a. Alfred-2570 also read its merge of 4a45766f01, except where that merge meets his own commits on main, which Naomi-2422 read. Crispin-2713 weighed what each source of the wake list pays for, which led to 1a34d6b690, and read that commit word for word. He also read 2ca6e5b0a1's changes word for word, before Rupert-2634 added one clause to its changelog fragment. Naomi-2422 read its merge of 612f6fd3bd, a4e345bd7e, and Crispin-2713 read its last commit, which rewords the fragments against 0.30.0. Imogen-2490 wrote the harness behind the live measurement and ran it on requests an earlier version of this change built.

Fiona-2757 wrote this description from Rupert-2634's draft.

Rupert-2634 and others added 14 commits October 27, 2026 04:24
…s document keeps its own structure

Answers the first review of the reply judge (anima-research#801).

A judge call the framework refuses without making it (a provider hold on
the agent or on the judge's model, a surgery's hold on the store, a
stopping framework) is a `refused` verdict: counted in judgeState apart
from errors, outside the three-failures streak, traced with the hold's
words and stated end, and named in gate_status while it lasts.
- While a refusal's stated wait lasts (at least a second on, since a hold
  whose end has just passed refuses with no time left until the
  framework's timer releases it), the judge isn't asked: asking compiles
  the agent's context only to be refused. Under onError "wake" each batch
  wakes the agent unjudged; under "skip" it's held in memory and judged
  once the wait ends, by the gate's own timer.
- A refusal with no stated end, under "skip", keeps held batches through
  turns' ends. A batch that arrives with no turn running is still asked,
  taking its rule's held batch in with it, so a hold that lifted
  unannounced ends there without polling and a rule's events stay in
  order. EventGate.providerCallsReleased(), called where an operator
  releases a provider wait (always: the wait may have lifted unannounced)
  and where a surgery lets go of the store, releases them too. A provider
  hold on the agent is asked about again at its stated end by the
  refusal's timer, so its release isn't wired.
- The gate numbers its asks and keeps the ask of the newest outcome it
  knows of. A refusal stands only when its call is newer than every
  outcome since; a verdict ends one only when its call is newer; a failed
  call changes neither; a release marks every call asked so far as older.
  The gate can't see admission, so asking order stands in for it.
- The refused batch is held before its trace is emitted. A stated wait
  that doesn't end at a time a Date can name counts as no end: a timer
  given a non-finite delay fires at once, and gate_status must name the
  end; a delay past 2^31-1 ms is waited out in steps.

When the agent's last turn failed with its retries spent and no turn has
completed since, <if_you_wait> opens with one line: the turn failed and may
not have answered all of what woke it, then that a wake for it is already
queued, is held (due to be let go at its stated end, or when the hold is
released), or that nothing runs it again, so what woke it stays unanswered
until something wakes the agent. The framework computes it
(ReplyJudgeDeps.failedLastTurn) from the same reading of its state as the
wake list (wakesOnTheirWay): requests pending, parked, in a scheduler pass,
or a start waiting for an auxiliary call count as queued; a cooldown whose
release hands on a request or queues a retry, or a wait on the agent's
model under which the scheduler holds a queued request, holds one. Neither
ending says a turn will start, since the inference policy can decline a
wake. With the last ending, the judge's model answered REPLY 10 of 10 on
both measured bases, and WAIT 10 of 10 without the line.

A batch is judged by its rule as the rule is when the judge is asked; a
reload clears the changed rules' state, installs the new rules, then sends
their batches (fireCleared). A judge request lists every other pending
batch as maybe, with what a held one waits for (UpcomingWake.when, a time
in words, for one due at no time anyone can name), asks held batches in
turn so each request lists the rest, and the wake list shows eight, soonest
first, saying how many more are only maybe. The flush checks the refusal
when its microtask runs.

Participant text in the judge's document is escaped (&, <, >), with each
line after the first indented, so it can't open or close a tag or start
where the document's structure does; names, channels and author ids are
kept to one line with square brackets escaped, so one can't end the [name]
it's in; each wake's words are quoted the same way, since a queued
request's reason can carry a participant's name. The line-break rule is
src/supplied-text.ts (oneLine, normalizeLineBreaks), for the framework's
notices to share.

Texts that said more than the code does: the budget is met by an estimate,
which text denser than three characters a token, or a newest message or
event that alone is over it, can pass; the dry run leaves out injections;
Bedrock drops cacheTtl; a membrane whose formatter is OpenAI Responses',
outside the adapter's subscription mode, keeps it; the header's order of
the document. The fragment, reply-judge.added.md, carries them, since no
release has carried the judge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…get on its own

An event's content is cut to 200 characters when the gate takes it
(MAX_CONTENT_SNIPPET), so the newest event can't be over a budget of at
least 4,000 tokens by itself; the clause the previous commit added for it
goes. The newest message is always shown, cut to 12,000 characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the ratio

Answers R1-4's case where the judge's request exceeds the model's window: a
budget is met by an estimate, which text far denser than the estimate's
ratio, or a budget set above the window, can pass, and calibrate runs only
on a response, so nothing would adapt and every batch would fail the same
way. A context_length failure now halves the characters-a-token ratio,
clamped at 1, rebuilds the request and asks the same batch once more; a
second failure goes to onError as any failure does. At a ratio of 1 already
it isn't asked again, since the request would be the same. The halved ratio
stays, and later responses correct it as before.

Also: the timeout test waits for the timed-out judge's wake instead of
sleeping a fixed 200 ms, which a loaded full suite overran.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… at, from a fresh window

Answers the review of the retry. The retry halves the ratio the refused
request was built at, which buildRequest now returns, and never raises the
estimate (the lower of the two), so two requests refused together halve it
once; at a ratio of one already the refusal isn't asked again. It drops the
agent's window anchor before rebuilding, so the retry starts a fresh window
at the halved budget rather than keeping an anchored window that still
fits it. The texts say the lower ratio stays for later requests until
responses correct it, and providerCallsReleased's doc says a refusal
reported after a release doesn't stand.

Tests: a retry from an anchored window that still fits is smaller; three
refused batches ask 2, 2 and 1 times and leave the ratio at 1; two requests
refused together leave it at 1.5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…essed marker leads the event line

Two more parts of R1-3's answer, and the operators' texts from a seat read.

A verdict decides whether the agent wakes, not whether its events reach its
context. MCPL's channel and push events are stored when they arrive; any
other event a judge rule matched (an agent's own rule can match any type)
had its content quoted only when the batch woke the agent, so a wait, or a
failure under onError "skip", left it out of context for good. Now such a
batch stores those events' content as a delivered batch quotes them, with
no wake (source "gate:judge-wait"), through one helper shared with
deliverEvents. A defer rule is unchanged: it still stores nothing for such
an event.

The judge's event line puts "(addresses the agent)" right after the time,
ahead of the channel and author, which participants can name.

gate_status: under "skip" with a stated end, held batches are judged after
it "or sooner if the hold is released"; with no end, it says nothing asks
the judge again on its own, and they're judged once a new batch gets a
verdict or the hold is released. The judge's listing of another rule's
batch held with no end says it goes right after this call if this call
gets a verdict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Takes back the store the previous commit added on the judge's no-wake
branch. The framework's own modules whose events reach the gate (api,
workspace, discord) store their message for the event in the same response
that asks for inference, before the gate is asked, so a wait already left
the content in context and the store only wrote a second copy on every
wait. deliverEvents is as it was before that commit.

JudgeConfig's doc and the changelog now say the events are in the agent's
context either way when they're stored as they arrive (MCPL's channel and
push events, and the framework's own modules' events), and that a wait,
like defer, adds nothing more.

The addressed marker's move and the seat read's wordings stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
event-gate-judge waited out the gate's own timers in real time: the
debounce, the refusal's one-second floor and stated ends, and the
windows a test watches for nothing to happen. At 8e54e6d the file
took about 30 s (21 s at main), and a negative window waited out in real
time can miss a regression that comes late under load.

Every test now runs on node:test's mock timers (setTimeout, setInterval
and Date), enabled in the file's beforeEach. setImmediate stays real, so
`settle()` lets promise chains, the gate's queueMicrotask and I/O
callbacks run between steps. `advance(ms)` steps the clock 1 ms at a
time, settling after each step; it replaces every fixed sleep. `until()`
is a condition on the same clock, its limit in mocked time. A negative
window now steps past its deadline and asserts nothing happened, which
load can't change either way.

`idle(framework)` waits for runUntilIdle while the clock steps, with a
60 s mocked bound no healthy run reaches. It replaces both
Promise.race([runUntilIdle(), sleep(3000)]) caps, which went on after
3 s whether or not the framework was idle, and each direct await of
runUntilIdle. The framework suite's own 20 ms polling `until` is gone in
favour of the file's. Each framework test's teardown puts the real
clock back before stop().

The elapsed-time assertions now hold to the stated end: >= 1000 for a
one-second end or floor, >= 1200 for a 1.2 s end, where they allowed
50 ms of real-timer slack.

One test stays on real time: "a mention rule ahead of a judge rule, end
to end over an MCPL server" spawns the MCPL fixture as a child process,
whose command polling and replies run on its own real clock; stepping
the parent's clock while it waits on that process would race it.
`useRealClock()` puts it back on real timers, and `advance`/`until` poll
in real time there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…two comments and the doc as the code bears them out

From a review of the previous commits:

- The wait store's revert (ff81a57) removed the test that pinned the
  store and added none for the rule the doc now states, that a wait, like
  defer, adds nothing more. The removed test is turned round: a wait on a
  workspace:modified event writes no message and wakes nothing.
- JudgeConfig's doc and the changelog said the framework's own modules
  store "theirs"; an API inference request, and a workspace event with no
  paths, carry no message, so they now say the modules store the message
  an event of theirs carries.
- settle()'s comment said I/O callbacks run between the mocked clock's
  steps; only those already due do, and slower I/O isn't waited for.
- The MCPL test's comment gave the child's own clock as the reason it
  runs on real time; the reason is that it waits for the child's replies,
  which arrive in real time, and stepping a mocked clock while waiting
  would spend the wait's bound first. (The previous commit's message gave
  the first reason; this is the one that holds.) Likewise until()'s and
  idle()'s bounds are counted in mocked milliseconds, one per step, not
  in elapsed time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wake to its exact time

A 5 ms step is taken because it cuts the CPU the file spends and no
assertion loosens past one step. Every
elapsed-time assertion in the file is a lower bound (>= 1000 or >= 1200),
so a coarser step can only make an ask land later within a step. advance()
ticks min(5 ms, what remains) and settles after each tick; until() steps
5 ms at a time.

CPU time (children's user + sys), five interleaved runs each, no added
load: real timers 2.1-2.4 s CPU, 28-33 s wall;
1 ms steps 2.3-2.8 s CPU, 3.7-9.6 s wall; 5 ms steps 1.8-2.1 s CPU,
3.4-6.4 s wall.

anima-research#801's self-wake check allowed 2 s of real-timer slack
(Math.abs(at - (t0 + 600000)) < 2000); on the mocked clock the wake's time
is exact, so it's an equality.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
0.28.0 shipped anima-research#801's reply judge and folded its fragment into
CHANGELOG.md, so this change's entries now say what changes against that
release, in four fragments by kind:

- breaking: judgeState.last.verdict and the gate:judge-verdict trace's
  verdict gain 'refused'; code handling each value needs a case for it.
- added: EventGate.providerCallsReleased(), ReplyJudgeDeps.failedLastTurn
  and FailedLastTurn, UpcomingWake.when, and buildRequest()'s ratio.
- changed (behaviour 0.28.0 set on purpose, or said nothing about):
  refused calls counted, traced, named in gate_status, held under skip and
  woken unjudged under wake while a stated wait lasts (R1-1); the
  failed-turn line (R1-2); quoting and the addressed marker's place (R1-3);
  the too-long retry (R1-4).
- fixed (code brought to what 0.28.0's notes say, each line quoted): the
  rule in force when asked, "by the rule as it is when the judge is asked"
  (R1-5); every pending batch listed, "other rules' pending batches"
  (R1-6); and the sentences of 0.28.0's notes on the judge that weren't
  true, each quoted beside what is: the dry run, the native formatter, the
  token budget, cacheTtl on Bedrock, and what a wait leaves in context.

A scratch fold with the release script (version 0.29.0) puts them under
Breaking, Added, Changed and Fixed with no complaint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review

Recommendation: merge and fast-follow. 3 medium severity findings can be fixed after the merge.

Reviewed commit b7ca786 with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.

Does this change belong in the project?

It fits the project. This change fits. It is a follow-up that fixes the reply judge shipped in 0.28.0, answering that feature's first review item by item, and it stays inside the existing gate/judge modules with the changelog fragments the project requires.

5 reasons with sources
  • It fixes and corrects a feature the project already ships rather than adding new scope. Each item answers a finding (R1-1 to R1-10) from the review of #801, the reply judge released in 0.28.0. The items cover refused calls, a failed last turn, pending batches, quoting participant text and too-long requests. Some fragments also correct wording in 0.28.0's notes. (changelog.d/reply-judge-review.fixed.md, changelog.d/reply-judge-review.changed.md)
  • The code goes where the judge and the gate already live. The gate holds and releases batches and the judge builds its document. The framework supplies the new failedLastTurn hook, the same way it already supplies upcomingWakes. The one new file, src/supplied-text.ts, holds small text helpers. Only reply-judge.ts uses them, which a separate review notes as a minor issue. (src/gate/ (event-gate.ts, reply-judge.ts, types.ts), src/framework.ts)
  • The breaking change is declared the way the contributing guide asks. The new 'refused' value of the verdict gets a .breaking.md fragment, the title is marked !, and added, changed and fixed fragments land in the same PR. (CONTRIBUTING.md, Changelog)
  • It is one coherent change despite its size: every part answers one review of one feature. The guide expects review findings to be answered one by one. #801 had already merged, so a single follow-up PR that names each finding and lists what it leaves out ("Out of scope") matches that practice. (CONTRIBUTING.md, Review process)
  • Tests come with the behavior changes, and the PR says they fail on 0.28.0's code. test/event-gate-judge.test.ts and test/reply-judge.test.ts are extended. The description says the tests fail on main (59d6095) for each of the first six findings. (CONTRIBUTING.md, What a PR should contain)

Findings

3 findings of medium or high severity, see the inline comments:

5 more findings, less severe
  • Low severity, documentation, src/gate/types.ts:261-264. The onError doc doesn't describe the new refusal behavior. ReplyJudgeOptions.onError still says only that "skip" doesn't wake the agent when the judge fails, times out or answers unreadably. The change gives onError new meanings for refused calls. Under "skip", refused batches are now held in memory until the hold's stated end or a release, and a refusal with no end can hold them indefinitely. Under "wake", while a stated refusal lasts, every judge rule's batch wakes the agent without the judge being asked. A host choosing between the two settings learns none of this from the option's own doc. The JudgeConfig doc comment, which says a failure under skip means the next batch is judged afresh, has the same gap.
  • Low severity, documentation, src/supplied-text.ts:1-6. The module doc says these helpers serve framework notices, but only the reply judge uses them. The header of the new src/supplied-text.ts says it is for text placed beside the framework's own structure: "the reply judge's document, and notices the framework writes". The only importer is src/gate/reply-judge.ts (plus a test). No framework notice uses oneLine or normalizeLineBreaks. A reader could wrongly assume framework notices are already protected against line-break injection.
  • Low severity, maintainability, src/supplied-text.ts:8-10. Line-break set duplicated from workspace/result-text.ts. LINE_BREAK (\r\n|[\r\n\u000b\u000c\u0085\u2028\u2029]) repeats the set already defined in src/modules/workspace/result-text.ts:13 ([\n\v\f\r\u0085\u2028\u2029]). The new module is meant to be the shared place for handling supplied text, so the workspace module should use it, or the two sets will drift apart.
  • Low severity, test coverage, src/framework.ts:5527-5535. wakesOnTheirWay's pass-request and pending-start branches have no tests. wakesOnTheirWay now counts requests taken by a running pass (passRequests) as queued. When no request is queued, it also reports 'a start waiting for an auxiliary call to finish' while providerGates.primaryDepth > 0 and no turn token is held. Both feed the judge's failed-turn line and the wake list. The new failed-turn test only exercises pendingRequests, parkedContinuations, the acceleration cooldown and provider waits. A mistake in either untested branch would make the judge say "Nothing runs it again" while a wake is in fact on its way.
  • Low severity, documentation, src/gate/types.ts:308-313. Custom judges now get wakes with when and no at, which 0.28.0 read as 'as soon as free'. Before this change, an UpcomingWake without at meant 'due as soon as the agent is free'. The gate and the framework now also send entries that have when and no at, such as 'when the hold on it is released', and gate entries with neither field (batches being decided or held, events buffered behind a turn). A JudgeFn that the host passes to EventGate (the judge option) and that renders wakes the way 0.28.0's ifYouWait did will present a hold that only a release ends as 'as soon as it is free'. The at doc on UpcomingWake still says absent means 'due as soon as the agent is free', which now contradicts the new when field, and the breaking fragment doesn't mention it.

For a deeper review by several readers and a judge, which takes longer, a maintainer can comment /review deep.

Comment thread src/gate/event-gate.ts Outdated
* the gate delivers when that turn ends.
*/
upcomingWakes(agentNames: string[], exceptPolicy?: string): UpcomingWake[] {
upcomingWakes(agentNames: string[], asking?: JudgingState): UpcomingWake[] {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium severity. Public EventGate.upcomingWakes changed its second parameter from a policy name to a private JudgingState.

upcomingWakes(agentNames, exceptPolicy?: string) was a public method of the exported EventGate class (re-exported from src/index.ts). It is now upcomingWakes(agentNames, asking?: JudgingState), and JudgingState is an internal interface that is not exported. Any host that called it with a policy name to leave that rule out no longer compiles. It also can no longer express "every pending batch except rule X's", because only the gate can build a JudgingState. The breaking fragment (changelog.d/reply-judge-review.breaking.md) names only the new 'refused' verdict, not this signature change. Either keep the string overload or record the break in the breaking fragment.

Comment thread src/gate/event-gate.ts
Comment on lines +4008 to +4037
if (state.ask > this.judgeNewestOutcome) {
this.judgeNewestOutcome = state.ask;
this.judgeRefusal = { hold, ...(until !== undefined ? { until: Math.max(until, now + JUDGE_REFUSAL_MIN_RETRY_MS) } : {}) };
}
const wakes = this.judgeOnError === 'wake';
if (!this.disposed && !wakes) {
this.holdForJudge(policyName, [...state.events, ...state.queued], state.judge);
if (this.judgeRefusal) this.armJudgeRefusalTimer();
else this.flushJudgeHeldIfClear();
}
const n = state.events.length;
console.error(
`[gate] Policy "${policyName}": the reply judge's call was refused (${hold}), so its batch of ${n} `
+ `${n === 1 ? 'event' : 'events'} ${wakes ? 'wakes the agent' : 'is held'} (judge onError "${this.judgeOnError}")`,
);
const record = this.judgeRecords.get(policyName) ?? { verdicts: { reply: 0, wait: 0, error: 0, refused: 0 } };
record.verdicts.refused++;
record.last = { at: Date.now(), verdict: 'refused', reason: hold, eventCount: n, durationMs };
if (this.config.policies.some((p) => p.name === policyName)) this.judgeRecords.set(policyName, record);
this.emitTrace({
type: 'gate:judge-verdict',
policyName,
verdict: 'refused',
woke: wakes,
eventCount: n,
durationMs,
hold,
...(until !== undefined ? { until } : {}),
timestamp: Date.now(),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium severity. A refusal that doesn't stand is still logged as 'is held' and traced with an until the gate never uses.

In refusedJudging, until is computed from the error's retryAfterMs and always goes into the gate:judge-verdict trace. The log line always says the batch "is held" under skip. That happens even when state.ask <= judgeNewestOutcome, meaning the refusal doesn't stand. In that case nothing is held to that end: the batch is asked again at once through flushJudgeHeldIfClear, or held for a different, standing refusal. Even when the refusal stands, the trace carries the raw until, while gate_status and the timer use Math.max(until, now + JUDGE_REFUSAL_MIN_RETRY_MS), so a zero-length refusal is traced as ending now but is retried a second later. Anyone reading traces or logs to see when a held batch will be judged gets the wrong answer in these cases.

Comment on lines 1161 to 1163
assert.deepStrictEqual(judge.requests[1]!.events.map((e) => e.content), ['lol']);
judge.answer({ reply: true, reason: 'stopped, so ignored' });
judge.answer({ reply: false, reason: 'the turn answered it' });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium severity, test coverage. Every test in the file now runs on a mocked clock that races ahead of real I/O.

beforeEach turns on mock.timers (setTimeout, setInterval and Date) for every test in the file, including the framework-level tests that read a real store and compile context. until and idle then move the mocked clock forward 5 ms after a few setImmediate turns. The file's own comment says "slower I/O isn't waited for", and the MCPL end-to-end test has to switch back to the real clock for this reason. In the other framework tests (frameworkWith, the failed-turn test, the provider-hold tests), mocked time can pass the judge's 20 s timeout, a hold's end or the 5 s until bound before slow real I/O finishes. These tests can therefore fail intermittently on a loaded CI machine.

Rupert-2634 and others added 5 commits October 27, 2026 15:17
…sed batch goes

EventGate.upcomingWakes(agentNames, exceptPolicy?) is public, and 0.28.0
exported it. The judge's request needs every pending batch but the one it's
asked about, its own rule's next batch included, so earlier commits here
changed the second parameter to the internal JudgingState: a host calling
it with a rule's name no longer compiled, and nothing declared the break.
The public method takes a rule's name again and leaves out that rule's
batches, collected, decided or held, as its doc says ("the pending batches
of rules other than exceptPolicy"). The judge's request reads a private
upcomingWakesBut, which leaves out the asked batch alone.

A refused call's log line said its batch "is held" under skip even when the
refusal doesn't stand (an older call's, reported after a newer outcome) and
the batch goes back to the judge at once. It now says which, by the test
the held batches' flush uses (judgeHeldForRefusal). The verdict trace's doc
said the same, and now says a refused batch is held while the judge's calls
are refused. The trace's until stays the hold's stated end, as its doc says.

Docs that hadn't caught up with refusals: ReplyJudgeOptions.onError,
JudgeConfig and the gate's judgeOnError option now say what each setting
does with a refused call. UpcomingWake.at said an absent at means due as
soon as the agent is free; a wake can now come with when instead, or with
neither for a batch the judge is deciding or will decide after a turn, so
the doc lists those, and a changed entry tells hosts whose own judge shows
wakes as 0.28.0's did. JudgeRequest.upcomingWakes's doc names what the gate
lists now. supplied-text.ts no longer names notices it isn't used for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… word the judge's notes as 0.28.0's code bears them out

From a review of the previous commit:

- upcomingWakes(agentNames, exceptPolicy) left out the rule's collecting,
  decided and held batches, but still counted its events buffered behind a
  running turn. Buffered events carry the rule they came from, so they're
  left out by it too; the judge's own request still counts them all.
- A test now pins the judge's request listing its own rule's next batch
  (collecting while a held one is asked), which the public method given
  that rule leaves out. The public test's title no longer claims 0.28.0
  listed decided or held batches; it never did.
- The fixed entry for the wake list said every pending batch was listed as
  "maybe"; a debounce's stays certain. It now names what's listed besides
  0.28.0's list, and says EventGate.upcomingWakes lists it too.
- The changed entry for the too-long retry said 0.28.0 built every later
  request at the same ratio; 0.28.0 corrected the ratio from each
  response, so the entry says the refusal left the ratio as it was.
- onError's doc and the changed entry for held batches now say a rule
  keeps its newest 100 events, as it does while a turn holds them.
- UpcomingWake.at's doc and the changed entry name the host's quiesce
  beside a turn's end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ce as what they wait for

From a review of the previous commit:

- Events in the inference buffer have already been decided to wake the
  agent: a debounce's batch that fired during a turn, or a judge batch
  delivered on reply or under onError "wake". flushInferenceBufferIfClear
  hands them to deliverEvents as a fired debounce's batch is handed, and
  that batch is listed as certain, so the buffered entry is certain too.
  "Maybe" stays for judge rules' batches still waiting on the judge.
- The buffer also fills while the host is quiesced with no turn running,
  and is delivered when the quiesce ends. The listing said "buffered
  behind a running turn, delivered when it ends"; it now names the quiesce,
  and so do upcomingWakes' doc, UpcomingWake.at's and JudgeRequest's docs
  and both fragments.
- A test pins that the judge's request counts its own rule's buffered
  events, which the public method given that rule leaves out.
- The fixed entry says upcomingWakes leaves out a given rule's buffered
  events as well as its batches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…held-batch cap and a hold past the floor

From a review of the previous commit:

- Buffered events are delivered only once no turn runs and the host isn't
  quiesced, both, not when one turn or the quiesce ends. Their entry now
  carries that as when ("once no turn runs and the host isn't quiesced"),
  so the framework's judge shows the condition and lists it last, instead
  of "as soon as it is free" ahead of every timed wake; it reads "N events
  buffered, delivered then". The docs and both fragments say the same.
- upcomingWakes' doc said a debounce's batch wakes the agent when it
  fires; one that fires during a turn or a quiesce is buffered, so it
  wakes the agent when it's delivered.
- JudgeRequest.upcomingWakes' doc said the judge adds the framework's
  wakes; the framework's own judge does, and a host's judge function gets
  the gate's list alone.
- The fixed entry said the list gives what a held batch waits for; it
  gives that for one held for a refusal.
- Tests: events buffered while the host is quiesced are listed, certain,
  with when; a batch asked under a refusal with no end, taking in its
  rule's held batch, keeps the newest 100 events of the two; the
  host-hold test holds for 3 s, past the gate's one-second floor, so a
  call made before the hold ends would fail it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eld and buffered wakes so they stand alone

From a review of the previous commit:

- A batch held for the judge while a turn changing what it reads runs, or
  the host is quiesced, had neither at nor when, so the framework's judge
  showed it as "as soon as it is free". It now carries when, "once no turn
  or quiesce holds it", as the buffered events' entry does.
- The no-end refusal's held batch said "right after this call, if it gets
  a verdict; otherwise once a later call does", which reads wrongly from a
  host's own call to upcomingWakes, where there is no call. It now says
  "right after the judge's next verdict, or once the hold on its calls is
  released", true in a judge request and outside one.
- The buffered entry's what read "N events buffered, delivered then",
  leaning on its when; it now reads "N events buffered for delivery".
- UpcomingWake.at's doc and the changed entry put the held batch among the
  wakes when describes.
- The cap test asserts the whole list the judge is asked about, in order,
  not only its length and ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru force-pushed the fix/reply-judge-refused-calls branch from b7ca786 to 92b0d3c Compare October 27, 2026 21:08
Rupert-2634 added 4 commits October 27, 2026 19:44
…ed-calls

Conflict in src/framework.ts: the reply judge's wakesOnTheirWay and
failedLastTurnFor, and the heartbeat's heartbeatWakesFor and
heartbeatWakeBefore, are each added after upcomingWakesFor. Both groups
are kept.
…ed-calls

Three conflicts, resolved:
- src/framework.ts, upcomingWakesFor: main lists a re-armed turn's
  provider hold as 'a turn whose retries ran out, sent again (n of m)';
  this branch lists the agent's held wake through wakesOnTheirWay. The
  held wake keeps this branch's wording, and takes main's re-arm wording
  when the hold is a re-arm's. wakesOnTheirWay now reports the re-arm,
  and failedLastTurnFor passes on only the hold's end.
- src/supplied-text.ts: main's version, which adds the notice marking
  to the same normalizeLineBreaks and oneLine this branch uses.
- test/event-gate-judge.test.ts: both new tests are kept.
…aves to the judge

For an agent with the judge wired, wake_remove_rule's description told
it in its last sentence that a judge rule's batch, waiting out its quiet
period, held or being judged, still goes to the judge by the rule as it
was. main's settings toolset shows a tool by its first sentence alone
(firstSentence), so that sentence was never shown there. Its substance
now opens the description, within firstSentence's 140 characters, and a
last sentence says what pending means. Every other agent's description
is unchanged, built from the same text.

The test checks firstSentence of the description, and the fragment says
what changes against 0.28.0.
…-re-arm case says so

main re-arms a turn whose retries ran out, by default. The judge test of
the failed-turn line had a turn that nothing sends again as its premise,
which the default now breaks: the turn is re-armed, held, and the line
says a wake for the agent is held. That test now turns re-arming off
(exhaustedTurnRearmMs: false) for its case. The re-arm file's judge test
also checks that the judge is told the failed turn is held, not that
nothing runs it again; it fails when the line ignores the hold.
Rupert-2634 added 2 commits October 29, 2026 11:28
No textual conflicts. One change beyond the merge, so the result builds:
- src/framework.ts, wakesOnTheirWay: main replaced the provider hold's
  emptiedByDrop with emptiedBy ('drop' | 'withdrawal'), and a hold emptied
  either way starts no turn when it ends. The wake list now reads
  emptiedBy, as main's end-of-hold rule does, so a hold whose held wakes
  were all withdrawn lists no wake, as one emptied by a drop already
  didn't. Without this line tsc fails here (TS2551).
… no wake

Since main's withdrawal can empty a provider hold (emptiedBy
'withdrawal'), and such a hold's end starts no turn, the wake list must
not list a wake on its way for it. The test holds one wake under a
coalescing subject, checks it's listed, withdraws the subject's wakes,
checks the hold stays, emptied by the withdrawal, and expects an empty
wake list. It fails when wakesOnTheirWay reads emptiedByDrop instead.
Rupert-2634 added 5 commits October 29, 2026 11:38
…ery would drop

main's deliverEvents now drops, at delivery, an event whose channel (its
route's, else its own) is held then. The wake list the judge is shown
counted every buffered event as a certain wake, held ones included, so
it could tell the judge a wake was certain when delivery would drop all
of it. heldNow, the one predicate deliverEvents uses, now also decides
which buffered events the list counts; what delivery does is unchanged.

The test buffers two events while the host is quiesced, then tunes one
channel out: the list goes from two buffered events to one. It fails if
the list counts held events.
… its other-model check

wakesOnTheirWay tells the judge what will wake the agent if it waits, and
a source it reports wrongly leans the judge toward WAIT on a wake that
never comes. Three of its sources had no test that failed without them.

- A wake a scheduler pass still holds while it awaits an earlier agent's
  start (passRequests): pinned by a test with two agents, the first one's
  start held in a module's gatherContext, which lists the second agent's
  wake as queued. It fails when the list ignores passRequests.
- A start that waits behind an auxiliary call, with nothing queued:
  pinned by a test that holds withAuxiliaryAdmission open and runs a real
  scheduler pass, whose start parks behind it. It fails without that
  source.
- A provider hold on another model than the agent's not binding its
  queued requests: no path makes it so. Every hold's waitModel comes from
  a wait recorded for agent.model, an Agent's model is set only when it's
  made, and a hold ends with its agent (releaseAgentRuntime deletes it),
  so an agent made again under the same name starts without one. The
  list now treats every hold as binding.
…nd refusal's when as it is

The judge's wake list and its failed-turn line read one classification
(wakesOnTheirWay), which has to follow what the scheduler does with each
hold.

- A wake queued while a re-armed turn waits for its delay starts the turn
  at once: the scheduler releases a re-arm cooldown when it takes up
  another wake and merges the re-armed turn into it. The classification
  reported such a wake as held until the re-arm's end. With a re-arm and
  a wake queued, the wake is now listed as queued and the failed-turn
  line says so. A test queues a wake under a real re-arm and checks the
  list, the failed-turn ending and that the turn then starts at once.
- A wake queued behind a hold that holds nothing yet (one an auxiliary
  call armed, or one emptied by a drop or a withdrawal) is held by the
  next scheduler pass until the hold ends, so it is listed as held, as it
  already was; a test now pins it, arming such a hold through a failed
  auxiliary call and checking the list and that a pass then holds the
  wake.
- A batch held for the judge under a refusal that stated no end is asked
  once a later judge call gets a REPLY or WAIT, or the hold on the judge's
  calls is released, and no turn or quiesce holds it: a verdict on a call
  asked before the refusal doesn't end it. Its `when` now says that.
- The fragment's line on buffered events says those whose channel is held
  at delivery don't wake the agent, and that one whose channel is held
  when the judge is asked isn't counted.
- Comments: the settings toolset shows a tool by its first sentence alone
  (firstSentence), which is why a judged agent's wake_remove_rule says in
  its first sentence what a removal leaves to the judge; that reason is
  here now rather than in a comment. Two other comments that explained
  internals are gone, and the held-buffer test's title says what it checks.
One conflict, in test/event-gate-judge.test.ts. main's change there
(cb34359) has the judge-timeout test wait for the judge's request to
be aborted and the agent woken, within a minute, in place of a fixed
200 ms sleep, with a `sleep` helper and a 60 s bound for it. This
branch's version of that test already waits for the wake on the
controlled clock (`until`, which steps the mocked timers) and then
asserts the abort, so the resolution keeps this branch's side of both
hunks; `sleep` and the 60 s bound have no use left. The file is this
branch's, byte for byte.
The next release's notes follow 0.30.0, so each fragment line that says
what the framework did before now says what 0.30.0 does. The judge's
source is 0.28.0's at 0.30.0 (reply-judge.ts is unchanged between the
tags, and event-gate.ts's judge code changes only how the last failure is
cut), so most lines change only the version. One differs: since 0.29.0
the judge's list of upcoming wakes names a held re-arm ("a turn whose
retries ran out, sent again (n of m)"), so the failed-turn bullet's old
side says that. The lead lines keep 0.28.0 where they name where the
judge and its notes came from, and .fixed's lead says the notes still
didn't hold in 0.30.0.
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