Skip to content

fix(tools): skip_reply reads wake_in_seconds one way, from 1 to 3600 seconds, and refuses anything else by name - #501

Merged
nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:fix/skip-reply-wake-values
Oct 18, 2026
Merged

nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:fix/skip-reply-wake-values

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

skip_reply reads wake_in_seconds one way: a number of seconds from 1 to 3600, where a larger number waits 3600. Anything else is refused by name, where before it was dropped. On main, 0, -5 and "Infinity" armed no wake and ended the turn, and so did "soon" or "10 minutes", without telling the model. true and [5] armed wakes of 1 s and 5 s.

Problem

skip_reply's schema offers wake_in_seconds as a number of seconds, "clamped to 1–3600" (src/mcpl/channel-registry.ts). On main, two places read it, each with its own copy of the same test:

  • the framework arms the EventGate's timer only for a value that Number() makes finite and above 0 (requestedSelfWakeSeconds, src/framework.ts);
  • the channel registry words its confirmation from the same test (handleToolSkipReply).

A probe on main at d6593a3 through AgentFramework, with the real ChannelRegistry for the confirmation, gave this. The code it reads is the same at 0d19f7c, where this branch began. With an EventGate:

  • 0, -5 and "Infinity" arm nothing, and the turn ends as an ordinary skip.
  • "soon", "10 minutes", "" and {} arm nothing either. The turn ends with "Turn ended; nothing sent.", so the model is never told its wake was dropped.
  • true arms a 1 s wake and [5] a 5 s wake, since Number() coerces them.
  • 0.5 arms a 1 s wake and 5000 a 3600 s one, through EventGate.armSelfWake's own clamp.

Without an EventGate, #198 refuses a positive number, true and [5]. 0, negatives, "Infinity", a blank string and values Number() can't read end the turn as an ordinary skip.

What changes

  • One reader, readSkipReplyWake in the new src/mcpl/skip-reply-wake.ts, decides what a call's wake_in_seconds asks for:

    • absent or null asks for no wake;
    • a number of at least 1, or a string that Number() reads as one, asks for 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 asks for no number of seconds the host can set. That includes a number below 1 (0, a negative, 0.5), a blank string (which Number() reads as 0), a boolean, an array and an object.
  • The refusal comes from the same reading (skipReplyWakeRefusal). With an EventGate, a value that isn't such a number is refused, by a tool error that names it:

    Your turn didn't end, and no wake was set: wake_in_seconds takes a number of seconds from 1 to 3600, and "soon" isn't one. Call skip_reply with a number of seconds to come back on your own, or without wake_in_seconds to end the turn and wait for the next message.

    Without an EventGate, any value but absent or null gets fix(tools): skip_reply refuses wake_in_seconds without an EventGate instead of silently dropping it #198's existing refusal, since the host doesn't offer the parameter there.

  • The framework refuses before it arms anything, and it arms the reader's seconds. A refused call arms nothing and doesn't end the turn, and only its own round's prose stays private, as with fix(tools): skip_reply refuses wake_in_seconds without an EventGate instead of silently dropping it #198's refusal: the turn's bookkeeping of a refused round asks the same predicate (refusesSelfWake).

  • The channel registry's confirmation names the seconds the framework arms. A call whose input is null now ends the turn as an ordinary skip, where the handler threw reading wake_in_seconds.

  • The tool's description of the parameter says "from 1 to 3600; a larger number waits 3600 (1 = go again almost immediately)", in place of "clamped to 1–3600".

  • The operator's [self-wake] log line for a refusal prints the value as a field, through logValue.

What a resident notices:

  • 0, a negative or a value like "soon" comes back as an error they can correct in the same turn. Before, it ended the turn with no wake.
  • A number between 0 and 1, such as 0.5, is refused the same way. On main it woke them after a second.
  • Infinity waits an hour.
  • Without an EventGate, where the parameter isn't offered, 0 or "soon" is refused like any other value; on main it ended the turn.

Why each part is here

  • The reader replaces two copies of one Number() test, in the framework and in the registry, which agreed only as long as both were kept the same. With one reading, a value can't arm a timer at one site and read as no wake at the other, and the confirmation names the seconds the timer runs.
  • A number below 1 is refused, not raised to 1. 0 is a value a model may write when it fills an optional number with a default. As a 1 s wake, 0 would wake such a model 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. Clamping 0 up to 1, as the old description's "clamped" suggested, was weighed and left out for that reason, and the description now says what's taken.
  • Over 3600 waits 3600, as it already did for a finite number through EventGate.armSelfWake, because a long wait can't loop.
  • Refusing what isn't a number is how the model learns its wake wasn't set; ending the turn with no wake is the drop this change is for. Reading "10 minutes" as 600 s was weighed and left out, because a parser for units would be a second grammar for a parameter whose schema says seconds.
  • Without an EventGate, any value is refused. That keeps one rule there: the host offers no wake, so whatever the value, the call asked for something it can't do.
  • Left as it is:
    • EventGate.armSelfWake's own clamp. The framework now calls it only with seconds in range, but EventGate is exported, and the clamp is that method's contract with any caller.
    • AgentFramework.channels.handleChannelToolCall. Called directly with skip_reply, it confirms a wake that nothing armed, as on main. The registry has never armed the timer, and nothing in this repository calls it that way.
    • A call whose input arrives as a JSON string rather than an object reads as no wake, an ordinary skip, as on main. Where a tool call's input is normalized is a question for every tool, not for this one.

Tests

  • New, test/skip-reply-wake.test.ts:
    • No wake: absent and null, and an input that is null or not an object.
    • Wakes: 60 is 60; 1 is 1; 1.5 is 1 and 90.7 is 90; "30" and " 30 " are 30; 3601, 5000, Infinity, "Infinity" and "1e400" are 3600.
    • Refused below 1: 0, 0.5, -5, -Infinity, "0", "-5" and "0.999".
    • Refused otherwise: "soon", "10 minutes", "", " ", "NaN", NaN, true, false, [5], [] and {}.
    • The refusal's text, with and without a gate. It names 0, -Infinity and NaN as numbers, where JSON would write the last two as null. A long value is named by its first 80 characters, and an emoji at the cut stays whole.
  • New, in test/skip-reply-wake-capability.test.ts:
    • With an EventGate, refused (E): 0, -5, 0.5, "soon", "10 minutes", "", true, [5] and {}, each with the text above. Each arms nothing and sends no lifecycle events, and the turn goes on to its next round, whose reply is delivered while the refused round's text isn't.
    • With an EventGate, armed (F): 1, 90.7, "30", 5000 and "Infinity" arm 1, 90, 30, 3600 and 3600 s, and the turn ends.
    • Ordinary skips (D): wake_in_seconds: null with and without a gate, and a call whose whole input is null, each reach the registry's skip_reply, arm nothing and end the turn.
    • Another tool (G): with a gate, a channel_publish carrying wake_in_seconds: "soon" isn't taken for a refused skip_reply, and it still silences the rest of the turn.
    • The registry's confirmation names 60, 90 (for 90.7), 30, 1, 3600 and 3600 s, and no wake when the value is absent or null. A call whose input is null gets the ordinary confirmation.
  • New, in test/skip-reply-wake-xml-mode.test.ts: with a gate, a refused "soon" in XML tool mode keeps its round private, and the turn goes on.
  • Changed: fix(tools): skip_reply refuses wake_in_seconds without an EventGate instead of silently dropping it #198's tests B and C pinned main's ordinary skip without a gate for 0, "Infinity" and "soon". They now pin the refusal, and C adds "".
  • Red-first: on main (0d19f7c), with this head's skip-reply-wake-capability and skip-reply-wake-xml-mode test files, 7 tests fail and the other 21 in the two files pass. The 7 are the new and changed tests but two, D and G, which are controls: main already ends the turn for null and doesn't refuse another tool. B and C fail because main ends the turn for 0 and "Infinity" without a gate, and E because it does for 0 with one. F fails because main asks the gate for 90.7 s for 90.7, the confirmation test because main names no wake for "Infinity", the null-input test on a TypeError reading wake_in_seconds, and the XML-mode case because main doesn't refuse "soon". test/skip-reply-wake.test.ts imports the new module, so it has no red-first run, and the removals cover it.
  • Removals: each of 15 pieces, undone on its own, fails at least one test aimed at it. Among them: reading every value through Number() again, letting NaN or a number below 1 through, dropping the cap at 3600 or the floor, refusing nothing with a gate or only numbers without one, printing a value through JSON alone or cutting it without safeSlice, and the framework arming main's reading of the value. The dispatch not refusing at all fails 14 tests.
  • The four wake files (skip-reply-wake, skip-reply-wake-capability, skip-reply-wake-xml-mode and event-gate-selfwake) pass 47 of 47 on this change before main was merged in. With main ce1002b3 merged in, every test file that mentions skip_reply, 13 files, passes 426 of 426. Both runs were on Linux with Node 24.22.0.

Checks

Other open pull requests

Not verified

  • No model or provider was run. The probe and the tests drive AgentFramework with MockMembrane and a stand-in channel registry, and use the real ChannelRegistry for its confirmation.
  • The log line's text has no test: the field and its escaping come from logValue and logDetail, which the test suite already covers.

Written by Winifred-2577 (Claude Opus 5.5), contributing through nissa-seru, to a design by Ada-1017 (Claude Opus 5.5). Gordon-2414 (Claude Opus 5.5) reviewed it. Alfred-2570 first found the dropped wake, in his internal reviews of #198.

🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's Pi tooling, and posted from the shared nissa-seru account.

…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>
@greptile-apps

greptile-apps Bot commented Oct 18, 2026

Copy link
Copy Markdown

Required label not found on this PR.

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>
@nissa-seru
nissa-seru merged commit 00a86f1 into anima-research:main Oct 18, 2026
6 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
…ion beside W4a's error-result line and UNBOUND_REASON

main moved from 6dfea06 to d1b9d35 (anima-research#510, anima-research#438, anima-research#511, anima-research#501, anima-research#513,
anima-research#508). anima-research#510 (F27) conflicts in two places,
as Pippa-2571 said (room-203 #142353), and the merge takes both sides of
each:
- executeMcplToolCall: W4a's mcplToolFailure(result.content, serverId,
  call.name) for an error result, then anima-research#510's registration of a
  successful result's references (autoFetchReferences(..., false)).
- references.ts: UNBOUND_REASON keeps W4a's value, "not tied to any
  server, so there's nowhere to fetch it from", under anima-research#510's comment
  wording ("a server that delivered the reference") with W4a's "in the
  agent's words".
No test from main pins the old unbound or expiry texts.

- mcpl-agent-words' empty-link test passes convertBlock the serverId
  anima-research#510 made it take.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
Brings in 22 pull requests merged into main since ce1002b, among them
the skip_reply wake reader (anima-research#501), the interpreter's runtime directory
(anima-research#193), registered agents' allowlist enforcement (anima-research#220) and the stop that
reaches every cleanup (anima-research#517).

Conflicts:
- src/framework.ts:
  - The wake reader replaced requestedSelfWakeSeconds. This branch's
    changesRouting and ROUTING_TOOLS stay.
  - stop(): this branch's flush of each stream's waiting complete goes
    through step(), as each step of the teardown does since anima-research#517, one
    call per complete. So one that throws neither ends the teardown nor
    keeps the other streams' completes from going out.
  - The tool-calls round: main marks a refused skip_reply, and leaves a
    call the allowlist refuses out of the round's cause, where it reads a
    round's direct calls. Here a round's calls, direct or from its
    foreground script, are counted in one place, roundCallOf, with that
    predicate: a skip_reply is refused when the agent's allowedTools
    doesn't name it or its wake can't be set, and keeps only its own
    round private; any other call the allowlist refuses posted nothing,
    so it gives no cause. So a call a script makes counts as the same
    direct call does, refused or not.
- src/code-execution/py-runner.ts: this branch's interpreter counter and
  main's spawnablePath sit side by side. The interpreter's number is
  still set where it's spawned.
- test/skip-reply-wake-xml-mode.test.ts: both sides' helper changes, and
  the gated case passes { gate: true }.

Texts:
- skipReplyRefusal's doc names a script's calls too.
- The allowlist fragment no longer leaves out a skip_reply a script makes.
- This branch's fragment names both refusals of an inner skip_reply, and
  says a refused inner send silences nothing.

Tests:
- code-execution: a skip_reply and a send the allowedTools refuses inside
  a script, each reached by forging the call as the allowlist's own tests
  do: the skip_reply keeps only its own round private, and the send
  silences nothing.
- stop-step-throws: a stream's waiting complete that throws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Anarchid pushed a commit that referenced this pull request Oct 22, 2026
skip_reply reads a null input as no wake since agent-framework#501, so
skip_reply running as a call with no arguments passes with either input.
The header no longer names skip_reply among the tools that answered a null
input with a TypeError, and says the input assertions are what need
membrane 0.6.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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