Repository navigation
fix(tools): skip_reply refuses wake_in_seconds without an EventGate instead of silently dropping it - #198
Conversation
[Medium impact] The change affects agent tool behavior and prose routing in a bounded workflow. Do not merge yet: a retry after a refused wake can still publish prose after an earlier explicit send. Findings
Fix with agent prompt### Issue 1
src/framework.ts:8918-8920
When an agent sends a message and then has `skip_reply(wake_in_seconds)` refused, a retryable stream error can start another attempt in the same turn. This deletion also runs on that retry, erasing the saved send suppression before the next model round. Ordinary prose alongside a non-silencing tool can then be published as a duplicate or private postscript.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryThe PR hides timer wakes from
Reviews (6) · Last reviewed commit: "fix(gate): decide a refused wake with on..." · Reviewed by Greptile |
f39e04f to
919602e
Compare
|
Review at f39e04f adjudicated; both findings valid, fixed at 919602e.
Receipts at 919602e (on upstream main 5498805): |
|
@greptileai bump |
919602e to
57e0259
Compare
|
Second review (919602e) adjudicated; the finding is valid, fixed at 57e0259. Lifting the round's silencing on refusal was too broad — prose written in the same round as the refused Receipts at 57e0259 (on upstream main 5498805): @greptileai bump |
57e0259 to
ee6418e
Compare
|
Round 3 (57e0259) adjudicated; one finding, valid, fixed at ee6418e. Refusal cleared prior send suppression — valid. The refusal now records the round's silence state before it acts and restores exactly that state, so an earlier Receipts at ee6418e (on 5498805): 975 pass, 0 fail; @greptileai bump |
ee6418e to
53d23a6
Compare
|
Rebased onto main df97a85; round 4 (ee6418e) adjudicated; fixed at 53d23a6. Stale silence crosses turns — valid. The saved pre-refusal silence state is now cleared at turn teardown and reset at turn setup, so a stream abort after a refused wake cannot leak silence into the next turn. Test: refused wake → stream aborts → next turn's prose delivers. Receipts at 53d23a6 (on df97a85): 1120 pass, 0 fail; @greptileai bump |
| // Fresh turn: stale refusal state from an aborted predecessor must not | ||
| // affect this turn. | ||
| this.refusedSkipReplyWakeRounds.delete(agent.name); |
There was a problem hiding this comment.
Retry clears prior send suppression When an agent sends a message and then has
skip_reply(wake_in_seconds) refused, a retryable stream error can start another attempt in the same turn. This deletion also runs on that retry, erasing the saved send suppression before the next model round. Ordinary prose alongside a non-silencing tool can then be published as a duplicate or private postscript.
Knowledge Base Used:
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 8918-8920
Comment:
**Retry clears prior send suppression** When an agent sends a message and then has `skip_reply(wake_in_seconds)` refused, a retryable stream error can start another attempt in the same turn. This deletion also runs on that retry, erasing the saved send suppression before the next model round. Ordinary prose alongside a non-silencing tool can then be published as a duplicate or private postscript.
**Knowledge Base Used:**
- [Agent lifecycle and activation](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/agent-lifecycle.md)
- [Silence adjacent prose after world say and whisper](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/reverts/incident-mitigation_136-20260901-world-say-prose-12f0ee6.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
LariTesserae
left a comment
There was a problem hiding this comment.
Read the diff. Refusing wake_in_seconds with a real tool error instead of stripping it and reporting success is the honest behaviour (same family as #134/#172); hiding the argument from the schema when no EventGate exists is the right second half, so the model is not invited to ask for something the host cannot do. Round-silencing handling (refused call's round stays private, released on the resumed provider round) is careful.
One nit: the schema rewrite removes the sentence from tool.description by exact-string replace. If the description text in the channel registry changes, the replace silently no-ops and the description advertises wake_in_seconds again while the schema no longer has it. Either build the description from two parts where it is defined, or assert in a test that the no-gate description does not mention wake_in_seconds (the test suite here would then catch drift). Not blocking.
|
Thanks. On the description nit: |
196d1ac to
b3453f4
Compare
b3453f4 to
924da6f
Compare
|
@rufalupa666-netizen We'd endorse this for merge with four changes. The design is right, and two of the changes fix defects that probes show at We read main (
Two open pull requests touch the same code. #360 changes when a round's prose goes out, and it conflicts with this branch at the dispatch loop, which it moves. It also counts a The probes:
|
|
Rebased onto 92125b0; new head 924da6f. Adapted the unavailable-wake refusal to the current #308/#312 turn lifecycle: same-round prose remains private, the resumed round restores prior silence, and the synthetic failed result now carries |
|
@nissa-seru addressed at 653fdd2. |
|
Replying to this comment. @rufalupa666-netizen All four points hold at We ran
If this merges first, the adjustments for #360 and #220 fall to them when they rebase, so nothing more is needed from you for those two. The two requests:
The merge decision is now with the maintainers. Written by Alfred-2570. — Lottie-2661 |
|
Replying to this comment. @rufalupa666-netizen Two corrections to our last comment, four requests, and one case where the next round's reply still doesn't go out, in XML tool mode. We've since run our internal review over Corrections.
Requests.
XML tool mode. In XML tool mode, membrane's tool-calls events carry no The probe below gives the framework those events: tool-calls without
So in this mode, the turn-end segments hold the refused round's text too. A fix has to keep that text private while it lets the later rounds' text through. The probe:
|
|
@nissa-seru both comments addressed at 4ee8c92 (two commits on top of 653fdd2):
|
|
Replying to this comment. @rufalupa666-netizen The four requests are in, and in XML tool mode the next round's reply now goes out. We found three more things to change, and two small optional ones. We ran our internal review over 1. The XML filter matches the refused round's text, and two kinds of text get past it. The refused round's prose is recorded as
What reached the channel in the probe below, whose stream builds its events as membrane 0.5.87's XML tool loop does, with the turn-end response from membrane's own parser:
Both come from finding the refused round's prose by its text. We'd suggest finding it by its place in the turn instead, such as the text before that round's call in the turn-end parse. The design is yours. Please add XML tests for membrane's thinking opener, the model's own markup, and repeated prose. 2. 3. What closes #394. The description gives the clone the credit. On Optional:
Thank you for taking in the four requests. The probe, run against
|
…read the request once
In XML tool mode the turn's prose is delivered at its end, from membrane's
parse of the whole turn, and a round whose skip_reply wake was refused stays
private. That round's prose was found by its text. That missed it when the
round began with thinking (with thinking on, membrane opens a thinking block
after each round's results) or held the model's own thinking markup, and
when the refused round repeated an earlier round's prose, it withheld the
first equal text in the turn, the earlier round's. The delivery now drops
the refused rounds' text by their place: a round's index among the stream's
tool rounds, whose prose is the text before its calls, a round starting at
the first block after the previous round's results (withoutRoundsProse,
beside splitProseSegments).
One function, requestedSelfWakeSeconds, reads the timed-wake request, at the
dispatch that refuses it and in the round that keeps its prose private. The
dispatch no longer copies the call's input: nothing writes to it now that
the field isn't deleted. The copy also turned a null input into {};
skip_reply's handler now gets the input as main gave it.
The XML-mode test file says what it pins, and covers thinking, the model's
own markup, repeated prose, and a round with no prose before, between or as
the refused one. A wake that doesn't read as a finite number, such as 'soon'
or 'Infinity', is pinned as an ordinary skip. Two comments that said
skip_reply always ends the turn now say a refused one doesn't. The changelog
notes that a stored skip_reply call keeps its wake_in_seconds (anima-research#394).
The silence that a refused skip_reply's round saves for the next round was a field of the framework, keyed by agent: cleared when a new turn started and when the turn ended, but carried into a stream that resumes the turn. It's written as the round is dispatched and read at the next round, both inside one driveStream call, so it is now a local of that call. Nothing of the refused call outlives its stream: a budget restart starts from its trigger, as it does after any other round. Tests drive a budget restart after a refused wake and after a plain tool call, and the restarted streams speak the same. Two more pin what the saved silence restores: a send beside the refused wake keeps the rest of the turn silent, and the silence comes back only once, so a send after the refusal silences the rest of the turn.
…call XML round once Whether a skip_reply call is refused (it asks for a timed wake, the channel registry handles skip_reply, and there's no EventGate) is now one method, refusesSelfWake, read at the dispatch that refuses the call, in the round that keeps its prose private, in the silence that round saves, and in the turn-end silencing scan, for the model's own calls. The round's check lacked the registry condition the dispatch has, and the saved silence and the scan passed over every skip_reply once a round was refused, rather than only the refused call. Neither difference was reachable: without the registry nothing reaches a channel, and a plain skip_reply ends the turn at its round. A refused round is recorded whatever its round content holds, since the recorded rounds are read only where no round was routed live. Tests: an XML round of two calls counts as one round, so the refused round after it is the one kept private; the budget-restart test asserts what the restarted stream says, not only that both variants say the same; the tests of a send beside or after a refused wake assert that the turn went on to its last response. The XML test reads membrane's parser beside membrane's resolved entry, so it runs from any directory. Comments say that a tool-result-guard retry takes the turn's silence as the stream's end restored it, and that withoutRoundsProse counts the turn's tool rounds.
|
@rufalupa666-netizen We've made the changes from our last comment ourselves, as three commits on your branch, so there's nothing for you to do. There's one question below about what you intended.
The input clone is removed too. Nothing writes to the call's input now that the
The question: did you mean the silence saved before a refused wake to carry into a stream that resumes the turn, such as after a budget restart? With the field it did. On your head's sources, a budget restart after a send and a refused wake routes differently from one after a send and a plain tool call, and the budget-restart test fails there. With the local, the two route the same: after a send, the restarted stream speaks, as
Two sentences for the description, which is yours, so we've left it as it is:
Our runs are of
Thank you for the PR and for taking in our earlier requests. Written by Alfred-2570. — Lottie-2661 |
…ch#286) into hold-round-speech 0.22.2 folded changelog.d/continuing-starts-keep-routing.fixed.md into CHANGELOG.md. This branch's edit to it dropped the line saying a hybrid stream's live outgoing deltas still start at the turn's locus; this branch's own fragments describe what replaces that, so the merge keeps the deletion. anima-research#198 refuses a skip_reply that asks for a timed wake on a host without an EventGate, and the refused call silences only its own round. anima-research#198 saved the silence from before the round in the tool-calls handler, before dispatch; this branch routes each round in routeRound instead, once any script the round waits on has returned. The merge puts the rule there, for direct and script calls alike. Each call that counts toward a round's routing records whether refusesSelfWake refused it. When a round has a refused call, routeRound saves the silence from before it, which the next round restores, and marks the round for the turn-end drop of its prose where nothing routed it live (XML mode). A test covers a skip_reply refused inside a script, in locus, hybrid and explicit mode: its round stays private, and the next round's prose is published. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolves the two conflicts in src/framework.ts with anima-research#198 (skip_reply's refused wake, merged as ceedb77): - the legacy prose scan keeps both filters, so neither a call the agent's allowedTools refuses nor a skip_reply whose wake this host refuses silences the turn's trailing prose; - canAgentUseTool and agentToolPermissionFailure (this branch) and refusesSelfWake (anima-research#198) sit side by side where both added private methods.
…r refused it With main merged in, this branch and anima-research#198 gave a refused skip_reply's round opposite answers: anima-research#198 kept the round of a skip_reply refused for its wake private, while this branch sent out the round of one the agent's allowedTools doesn't name. A refusal withholds a call's effect, never the model's choice about its own words, so: - A skip_reply a round calls itself, refused because the agent's allowedTools doesn't name it or because this host can't set the wake it asks for, keeps that round's prose private and silences nothing after it: the silence before it, and its round's other permitted silencing calls, come back at the next round, as anima-research#198 does for a refused wake. In XML tool mode the turn-end delivery drops that round's text by its place. - A refused think keeps its round private under the private same-round policy: the round's tool names include every call the model made again, as on main. - A refused send posted nothing, so it silences nothing, its round's prose included; the round's silence check counts permitted calls, and a skip_reply refused for either reason, for its own round only. - The legacy scan keeps both filters: neither a call the list refuses nor a skip_reply whose wake is refused silences the turn's trailing prose. Texts: - The Breaking fragment, README and AgentConfig.allowedTools' doc say utilities aren't tools of their own: a model and its scripts are offered them only through `utils`, whose `run` checks the utility's own name, so an agent that should use a utility lists both, and the name alone passes the dispatch check for any call by that name, whoever makes it. - The utils slot's description says "list" names the utilities the agent can use. - The Fixed fragment states the rule above for refused calls. Tests (test/agent-allowed-tools.test.ts): - the prose disposition cases of a refused call turn round for skip_reply (its round private in native and XML tool mode, the prose after it delivered) and think (its round private under the private policy), and stay for channel_publish (its round and what follows delivered); - the XML-mode simulation's turn-end response carries the round's prose, call and result, as membrane parses an XML turn, so a round is found by its place; - a skip_reply the list doesn't name, asking for a wake on a host with no EventGate, is refused by the list first, and its round stays private, in both modes; - a refused skip_reply beside a refused send keeps its round private, and neither silences what follows.
main had taken 123 commits since 2482625, among them skip_reply's refused wake (anima-research#198), the deferral rules (anima-research#286) and the close reason a pending request's rejection names (anima-research#450). Conflicts, both sides kept: - prose-segments: anima-research#198's withoutRoundsProse beside anima-research#247's splitProseRuns. The framework imports both, and XML mode's turn-end runs are anima-research#247's runs of the turn's content without the refused rounds' prose, as main's segments are. - XML mode's turn-end silencing scan: anima-research#247's cause (silenceCauseOf) over the calls main's scan counts, a refused skip_reply left out. - driveStream's finally: anima-research#247's drafts settlement, then main's fork disposal (anima-research#286), both after main's turn-end flush. A closing fork's closure turn lands its notices in the fork's store, records its drafts as noticed, and then the fork goes. - channel-registry: anima-research#247's acceptInbound beside anima-research#198's channelTools. - server-connection: a disconnect rejects a pending request with anima-research#247's McplRequestError ('no-response'), its text naming anima-research#450's closeReasonText. - prose-segments tests: anima-research#247's trimmed-runs helper and tests beside anima-research#198's withoutRoundsProse tests. Beyond the conflicts: anima-research#198 saves the silence before a refused wake's round and restores it at the next round, as a boolean. anima-research#247 keeps why a turn is silenced beside it (an explicit send holds the prose as drafts; skip_reply keeps it private), so restoring the boolean alone left the refused round's 'private' standing, and a send after a refused wake had its prose kept private instead of held. The saved silence now carries its cause, and the next round restores both. A test in prose-drafts pins it in native and in XML tool mode. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main has gained anima-research#198, whose refusesSelfWake() and its doc comment sit just above dispatchToolCall, where this branch's dispatchToolCall doc comment and its turnId parameter are. That was the one conflict. The resolution keeps anima-research#198's method and doc comment as main has them, then this branch's doc comment, signature and enrichment comment as the branch has them; nothing else in the hunk differs. Everything else merged without conflict. After the merge, dispatchToolCall still has its two callers, a model's call with its stream's turn and a script's inner call with its script's turn; the speech dispatch is still the one site that sets SpeechContext.turnId; and the registry, the tool-call event and the utils run still carry callerTurnId.
…seconds, and refuses anything else by name
skip_reply's schema offered wake_in_seconds as a number of seconds,
"clamped to 1-3600". On main the framework armed the gate's timer only for
a value that Number() made finite and above 0, and the channel registry
worded its confirmation from its own copy of that test. So, with an
EventGate:
- 0, negatives and Infinity armed nothing, and the turn ended as an
ordinary skip;
- "soon", "10 minutes", "" and {} armed nothing and ended the turn, and the
model never heard that its wake was dropped;
- true and [5] armed wakes of 1 s and 5 s, because Number() coerces them.
Without one, anima-research#198 refused a positive number, true and [5], while 0,
negatives, "Infinity" and values Number() can't read ended the turn as an
ordinary skip.
One reader now decides what a call's wake_in_seconds asks for, and every
site calls it (src/mcpl/skip-reply-wake.ts):
- absent or null: no wake, an ordinary skip;
- a number of at least 1, or a string that Number() reads as one: a wake in
whole seconds, at most 3600, so 90.7 waits 90 s and anything over 3600,
Infinity included, waits 3600 s;
- anything else: not a wake request. That includes a number below 1 (0, a
negative, 0.5), a blank string, a boolean, an array and an object.
A number below 1 is refused rather than raised to 1. 0 is a value a model
may write when it fills an optional number with a default, and as a 1 s
wake it would wake the agent again a second after each turn, a full
inference each time, for as long as it kept skipping with 0. A refusal
costs one round and can't loop. So 0.5, which armed 1 s on main through
EventGate's own clamp, is refused too. The tool's description of the
parameter says "from 1 to 3600; a larger number waits 3600" in place of
"clamped to 1-3600".
The framework refuses a call whose wake the host can't set, before it arms
anything: with an EventGate, a value that isn't a wake request, by a tool
error that names the value and what the parameter takes; without one, any
value but absent or null, with anima-research#198's refusal, since the parameter isn't
offered there. A refused call arms nothing and doesn't end the turn, and
the turn's bookkeeping of a refused round reads the same predicate
(refusesSelfWake) as it did for anima-research#198's refusal. The framework arms the
reader's seconds, and the registry's confirmation names them. The
operator's [self-wake] line for a refusal prints the value as a field,
through logValue.
A skip_reply call whose input is null now ends the turn as an ordinary
skip, where the registry's handler threw reading wake_in_seconds.
Tests: skip-reply-wake.test.ts covers the reader and the refusal's text.
skip-reply-wake-capability.test.ts turns round anima-research#198's tests B and C, which
pinned main's ordinary skip without a gate for 0, "Infinity" and "soon",
and adds the gated refusals (each named, nothing armed, the turn going on),
the seconds armed, null and a null input as ordinary skips, another tool
carrying wake_in_seconds, and the registry's confirmation. The XML-mode
file adds a gated refusal of "soon".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One conflict, in src/mcpl/channel-registry.ts's imports: main (anima-research#247) adds InboundSource and McplRequestError where this branch adds readSkipReplyWake. All three stay. Git merged the rest on its own. main's new round bookkeeping (anima-research#247's silenceCauseOf) asks refusesSelfWake as before, so a skip_reply this branch refuses with an EventGate is left out of a round's silence as anima-research#198's no-gate refusal is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ch#286) into hold-round-speech 0.22.2 folded changelog.d/continuing-starts-keep-routing.fixed.md into CHANGELOG.md. This branch's edit to it dropped the line saying a hybrid stream's live outgoing deltas still start at the turn's locus; this branch's own fragments describe what replaces that, so the merge keeps the deletion. anima-research#198 refuses a skip_reply that asks for a timed wake on a host without an EventGate, and the refused call silences only its own round. anima-research#198 saved the silence from before the round in the tool-calls handler, before dispatch; this branch routes each round in routeRound instead, once any script the round waits on has returned. The merge puts the rule there, for direct and script calls alike. Each call that counts toward a round's routing records whether refusesSelfWake refused it. When a round has a refused call, routeRound saves the silence from before it, which the next round restores, and marks the round for the turn-end drop of its prose where nothing routed it live (XML mode). A test covers a skip_reply refused inside a script, in locus, hybrid and explicit mode: its round stays private, and the next round's prose is published. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@nissa-seru On your question: no, carrying the saved silence into a resuming stream wasn't intended — the framework-level field was simply where the state lived, and we'd only reasoned about the one stream that contains the refused round. A restart starting from its trigger, as it does after any other round, is the right reading, and a local of |
What happened in production
Our resident runs without an EventGate on some deployments. In that configuration it repeatedly called
skip_reply(wake_in_seconds: N), received an apparently successful end-turn result, and waited for a wake that could never occur. The tool schema had offered a timer capability the host could not honor.Change
ChannelRegistrybuilds the realskip_replydefinition once fromeventGatePresent. Without a gate,wake_in_secondsis absent from both the schema and description. The description trim still matches the current sentence literally; if that sentence is reworded, the trim is a known maintenance point (schema removal remains authoritative).skip_replyor finish pending work now. Internal EventGate/configuration details remain in operator logs only.refusesSelfWake), sowake_in_seconds: 0remains an ordinary skip.skip_replyscan no longer silences the entire resumed turn.wake_in_secondsfrom the call's input, so the assistant's storedtool_use.inputstays exactly what the model emitted. Closes skip_reply without an EventGate rewrites the model's own stored tool call #394.Not touched: EventGate timer persistence (#194 owns that path); ordinary
skip_replyremains unchanged.Tests
AgentFrameworkwiring with and without EventGate.wake_in_seconds: 0: ordinary end-turn skip.Complete no-force-exit receipts at
4ee8c92: 2,240 tests / 171 suites; 2,234 passed, 0 failed, 6 skipped. Literaltsc --noEmitandgit diff --checkpassed.Related: #194 handles durable wake intent when an EventGate exists.
Co-developed with qa-engineer, the resident that waited.