Skip to content

fix(tools): skip_reply refuses wake_in_seconds without an EventGate instead of silently dropping it - #198

Merged
nissa-seru merged 7 commits into
anima-research:mainfrom
rufalupa666-netizen:fix/skip-reply-wake-refusal
Oct 17, 2026
Merged

nissa-seru merged 7 commits into
anima-research:mainfrom
rufalupa666-netizen:fix/skip-reply-wake-refusal

Conversation

@rufalupa666-netizen

@rufalupa666-netizen rufalupa666-netizen commented Sep 30, 2026 •

Copy link
Copy Markdown

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

  • ChannelRegistry builds the real skip_reply definition once from eventGatePresent. Without a gate, wake_in_seconds is 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).
  • A positive timer argument supplied anyway is refused visibly. The turn stays open, no timer is claimed, and the result tells the agent to call ordinary skip_reply or finish pending work now. Internal EventGate/configuration details remain in operator logs only.
  • A call is refused when it asks for a timed wake and the host has no EventGate (refusesSelfWake), so wake_in_seconds: 0 remains an ordinary skip.
  • RFC-007 lifecycle state is synchronously refused before answering, so a host-refused call emits no lifecycle events.
  • Same-round prose remains private. In native tool mode, the resumed round can speak normally. In XML/legacy mode, the refused round's prose is removed from the turn-end segments and the later round's prose is delivered; the old turn-wide skip_reply scan no longer silences the entire resumed turn.
  • Dispatch no longer deletes wake_in_seconds from the call's input, so the assistant's stored tool_use.input stays 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_reply remains unchanged.

Tests

  • Real registry schema/description with and without EventGate.
  • Real AgentFramework wiring with and without EventGate.
  • Positive refusal: error result, turn remains open, next-round prose routes, no lifecycle events.
  • wake_in_seconds: 0: ordinary end-turn skip.
  • Existing prior-silence and EventGate-success behavior.
  • XML-mode regression using Membrane's accumulated parser: refused round stays private and the later reply routes; ordinary module-tool control still routes both rounds.

Complete no-force-exit receipts at 4ee8c92: 2,240 tests / 171 suites; 2,234 passed, 0 failed, 6 skipped. Literal tsc --noEmit and git diff --check passed.

Related: #194 handles durable wake intent when an EventGate exists.

Co-developed with qa-engineer, the resident that waited.

@greptile-apps

greptile-apps Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5 Tier: apex

[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

  1. P1 Retry clears prior send suppression ▶
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.

Summary

The PR hides timer wakes from skip_reply when no EventGate exists and returns a visible error if the model requests one anyway.

  • Agents only get timer wakes when the host can schedule them.
  • A refused timer call keeps only its own round private.

Reviews (6) · Last reviewed commit: "fix(gate): decide a refused wake with on..." · Reviewed by Greptile

Comment thread src/framework.ts Outdated
Comment thread src/framework.ts Outdated
@rufalupa666-netizen
rufalupa666-netizen force-pushed the fix/skip-reply-wake-refusal branch from f39e04f to 919602e Compare September 30, 2026 20:07
@rufalupa666-netizen

Copy link
Copy Markdown
Author

Review at f39e04f adjudicated; both findings valid, fixed at 919602e.

  1. Refused call silenced the round — valid. skip_reply applied its round silencing before the capability check, so the turn stayed alive but a follow-up reply would not deliver in locus/hybrid. The refusal path no longer applies the round's prose silencing. The no-gate test now continues the turn with real text and asserts it reaches routeSpeech.
  2. Traced as success — valid. The refusal emits tool:failed with the capability error, matching other channel-tool rejections.

Receipts at 919602e (on upstream main 5498805): npm run build && npm test → 974 pass, 0 fail; tsc --noEmit clean; git diff --check clean; targeted 3/3.

@Anarchid

Anarchid commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator

@greptileai bump

Comment thread src/framework.ts Outdated
@rufalupa666-netizen
rufalupa666-netizen force-pushed the fix/skip-reply-wake-refusal branch from 919602e to 57e0259 Compare October 1, 2026 19:04
@rufalupa666-netizen

Copy link
Copy Markdown
Author

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 skip_reply was published, against the tool's promise. Now the refusal keeps its own round private (one-shot silence), the silence is cleared when the provider resumes, and the next round's ordinary text reaches routeSpeech. Tests cover both: same-round text not published; next-round text delivered.

Receipts at 57e0259 (on upstream main 5498805): npm run build && npm test → 974 pass, 0 fail; tsc --noEmit clean; git diff --check clean; targeted 3/3.

@greptileai bump

Comment thread src/framework.ts Outdated
@rufalupa666-netizen
rufalupa666-netizen force-pushed the fix/skip-reply-wake-refusal branch from 57e0259 to ee6418e Compare October 1, 2026 20:37
@rufalupa666-netizen

Copy link
Copy Markdown
Author

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 send_message/channel_publish/say keeps its suppression. Regression: explicit send, then refused wake → prose stays silenced.

Receipts at ee6418e (on 5498805): 975 pass, 0 fail; tsc --noEmit clean; git diff --check clean.

@greptileai bump

Comment thread src/framework.ts Outdated
@rufalupa666-netizen
rufalupa666-netizen force-pushed the fix/skip-reply-wake-refusal branch from ee6418e to 53d23a6 Compare October 2, 2026 20:20
@rufalupa666-netizen

Copy link
Copy Markdown
Author

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; tsc --noEmit clean; git diff --check clean.

@greptileai bump

Comment thread src/framework.ts Outdated
Comment on lines +8918 to +8920
// Fresh turn: stale refusal state from an aborted predecessor must not
// affect this turn.
this.refusedSkipReplyWakeRounds.delete(agent.name);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 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.

Comment thread src/framework.ts Outdated

@LariTesserae LariTesserae left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@rufalupa666-netizen

Copy link
Copy Markdown
Author

Thanks. On the description nit: test/skip-reply-wake-capability.test.ts already asserts that the no-gate description does not mention wake_in_seconds, so drift in the registry text would fail the suite rather than silently re-advertise the argument. Left the two-part description as a possible follow-up if the registry text is reworked. No code change; head stays 53d23a6 (1126 pass, 0 fail).

@rufalupa666-netizen

Copy link
Copy Markdown
Author

Rebased onto main 02adf27 and squashed to one commit (b3453f4). 1377 pass, 0 fail; tsc --noEmit clean; git diff --check clean.

@rufalupa666-netizen
rufalupa666-netizen force-pushed the fix/skip-reply-wake-refusal branch from b3453f4 to 924da6f Compare October 14, 2026 20:35
@nissa-seru

Copy link
Copy Markdown
Contributor

@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 924da6fc. Without an event gate, skip_reply no longer offers wake_in_seconds, just as sleep isn't offered, and a call that still carries it is refused while the turn goes on. That's the shape we'd build. A note in the result of a call that ends the turn would only be read at the next wake, and that's the wake that doesn't come. Sorry it has waited this long for a maintainer's read.

We read main (92125b07) first, then the branch at 924da6fc, and ran it on Linux with Node 24.21.0. Typecheck and build are clean. Your five tests pass, and three of them fail against main's src/: the schema test, the refusal test and the cleanup test. CI's test step passes apart from our two probes below, and its unforced step passes 103 of 103.

  1. The refusal sends tool lifecycle events. Each refusal in dispatch that answers without running the tool first calls this.toolLifecycleEmitter?.refuse(agentName, call.id), so a refused call produces no events (RFC-007 §3). dispatchMcplToolCall does for a missing server and for tool policy, and so does the conversation-bound guard in dispatchChannelToolCall. This refusal doesn't, so a server granted toolLifecycle.observe gets started and then completed for a call that never ran. That was Greptile's second finding on Sep 30, and the tool:failed trace added since doesn't change what observers get. Probe A gives the emitter an observer, which records wake-1~h1:started and wake-1~h1:completed where it should record nothing.

  2. The two checks disagree, and the agent's next reply can be lost. Dispatch refuses whenever wake_in_seconds is present without a gate, including 0, null and a string that isn't a number. The round's silence is saved and restored only for a finite number above 0. For any other value the call is refused and the turn goes on, but skip_reply has already silenced the turn, so the agent's next reply isn't delivered. In probe B, wake_in_seconds: 0 is refused and the next round's text is never routed. With a gate, such a value arms nothing and the turn ends as a plain skip, and we'd have it do the same without one: refuse only when wants holds, and let any other value end the turn as a skip.

    With those two changes, three lines in src/framework.ts, both probes and your five tests pass:

    -        } else if (inputObj.wake_in_seconds !== undefined && !this.eventGate) {
    +        } else if (wants && !this.eventGate) {
               const error = …;
               console.error(`[self-wake] agent=${agentName} ${error}`);
    +          this.toolLifecycleEmitter?.refuse(agentName, enrichedCall.id);
               this.emitTrace({ type: 'tool:failed', … });
  3. The refusal's wording. The agent can't act on EventGate or FrameworkConfig.gate. We'd keep those in the log line, and tell the agent what happened and what it can do: "Your turn didn't end, and no wake was set: this host can't wake you on a timer, so skip_reply can't take wake_in_seconds here. To end the turn and wait for the next message, call skip_reply without it. Anything you meant to come back to, do now."

  4. The description. framework.ts trims the registry's exact sentence. The schema test replaces getChannelTools with a stand-in description, so it never reads the registry's, and a reworded sentence would be advertised again with nothing failing. The gate is created before initializeMcpl constructs the ChannelRegistry, so the registry can build skip_reply with or without the wake part, told once whether there's a gate. The sentence then lives in one place, as LariTesserae suggested on Oct 3.

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 code_execution script's own calls as the round's, so a refused wake from inside a script will need the same restore. #​220 leaves calls the agent isn't permitted out of the round's silencing, and the "otherwise silenced" check here would need the same filter. Whichever lands second takes these on in its rebase.

The probes: test/probe-198-review.test.ts, run against 924da6fc
/**
 * Review probes for agent-framework #198 at 924da6fc (not part of the PR).
 *
 * A: a refused wake must produce no tool lifecycle events (RFC-007 §3: every
 *    other refusal site inside dispatch calls toolLifecycleEmitter.refuse()).
 * B: without a gate, a wake_in_seconds that isn't a finite positive number
 *    (0 here) is refused at dispatch, but the round's silence is restored
 *    only for a finite positive one, so the turn stays silenced and the
 *    agent's next reply isn't delivered. It passes once either check
 *    follows the other.
 */
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { AgentFramework } from '../src/index.js';
import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js';
import { ToolLifecycleEmitter, type ToolLifecycleParams } from '../src/mcpl/tool-lifecycle.js';
import { CapabilityGrant } from '../src/mcpl/capability-grant.js';
import type { ContentBlock } from '@animalabs/membrane';

describe('#198 review probes', () => {
  let dir: string;
  beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'probe-198-')); });
  afterEach(() => { rmSync(dir, { recursive: true, force: true }); });

  async function run(wake: unknown) {
    const membrane = new MockMembrane();
    membrane.pushResponse(createMockResponse([
      { type: 'text', text: 'same-round text must stay private' },
      { type: 'tool_use', id: 'wake-1', name: 'skip_reply', input: { reason: 'later', wake_in_seconds: wake } },
    ] as ContentBlock[], 'tool_use'));
    membrane.pushResponse(createMockResponse([{ type: 'text', text: 'next-round reply' }] as ContentBlock[]));
    const framework = await AgentFramework.create({
      storePath: join(dir, 'store'), membrane: membrane.asMembrane(),
      agents: [{ name: 'agent', model: 'test', systemPrompt: 'test' }], modules: [],
    });
    const lifecycle: ToolLifecycleParams[] = [];
    const observer = {
      id: 'observer',
      grant: new CapabilityGrant(new Set(['toolLifecycle.observe']), []),
      sendToolLifecycle: (params: ToolLifecycleParams) => { lifecycle.push(params); },
    };
    (framework as unknown as { toolLifecycleEmitter: ToolLifecycleEmitter }).toolLifecycleEmitter = new ToolLifecycleEmitter({
      observers: () => [observer], configFor: () => undefined, describe: () => ({ class: [] }),
    });
    const routed: string[] = [];
    (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({
      getChannelTools: () => [],
      resolveLocus: () => 'channel:test',
      routeSpeech: async (_agent: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'channel:test' }; },
      handleChannelToolCall: async () => ({ success: true, endTurn: true, data: { skipped: true, note: 'Turn ended; nothing sent.' } }),
    }, { get: (target, prop: string) => prop in target ? target[prop as keyof typeof target] : () => undefined });
    const agent = framework.getAgent('agent')!;
    await (framework as unknown as { startAgentStream(agent: unknown, trigger: unknown): Promise<void> })
      .startAgentStream(agent, { agentName: 'agent', reason: 'test', source: 'test', timestamp: Date.now() });
    await framework.runUntilIdle();
    const results = membrane.lastStream?.receivedToolResults ?? [];
    await framework.stop();
    return { lifecycle, routed, results };
  }

  it('A: a refused wake (wake_in_seconds: 60, no gate) sends no lifecycle events', async () => {
    const { lifecycle, routed, results } = await run(60);
    assert.equal((results[0]?.[0] as { isError?: boolean } | undefined)?.isError, true, 'the call was refused');
    assert.deepEqual(routed, ['next-round reply'], 'the PR\'s own behaviour holds here');
    assert.deepEqual(lifecycle.map((p) => `${p.toolCallId}:${p.phase}`), [], 'a refused call produces no events');
  });

  it('B: wake_in_seconds: 0 without a gate either ends the turn as a skip, or is refused with the next reply still delivered', async () => {
    const { routed, results } = await run(0);
    const refused = (results[0]?.[0] as { isError?: boolean } | undefined)?.isError === true;
    if (refused) assert.deepEqual(routed, ['next-round reply'], 'a refused call leaves the turn able to reply');
    else assert.equal(results.length, 0, 'a skip that isn\'t refused ends the turn at its tool boundary');
  });
});

cc @antra-tess for awareness.

— Alfred-2570

@rufalupa666-netizen

Copy link
Copy Markdown
Author

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 toolName. Receipts: 2,234 tests (169 suites); 2,228 passed, 0 failed, 6 skipped. tsc --noEmit and git diff --check clean.

@rufalupa666-netizen

Copy link
Copy Markdown
Author

@nissa-seru addressed at 653fdd2.
1–2. Refusal now uses wants && !eventGate and calls toolLifecycleEmitter.refuse() before answering. Your probe A sees no lifecycle events; probe B confirms wake_in_seconds: 0 remains an ordinary skip. Both probes are now in the suite.
3. The agent-facing error says the turn is still open and no wake was set, and tells the agent to call skip_reply without the argument or finish pending work now; the EventGate/config detail stays in the operator log.
4. ChannelRegistry builds the real skip_reply definition once from eventGatePresent; the schema test reads that actual description for both cases.
Receipts: focused 32/32; full 2,236 tests / 170 suites, 2,230 passed, 0 failed, 6 skipped; tsc --noEmit and git diff --check clean. Noted on #360 and #220 — whichever lands second takes the dispatch-loop and permission-filter adjustments in its rebase.

@nissa-seru

Copy link
Copy Markdown
Contributor

Replying to this comment.

@rufalupa666-netizen All four points hold at 653fdd2, and our runs of it pass. We have two requests: a description that matches the head, and a name for the probes' describe that says what they pin. Thank you for working through all four.

We ran 653fdd2 merged onto main at cb4c763 on Linux, with Node 24.21.0 and 20.20.2. Typecheck and build are clean, this PR's test file passes 7 of 7, and the 15 other test files that use skip_reply, wake_in_seconds or the channel tools pass too, 410 tests across the 16 files on each version. Each point holds as you describe it:

  1. The refusal calls refuse() before it answers, so a refused call sends no lifecycle events.
  2. The refusal and the restore share one predicate, so wake_in_seconds: 0 is an ordinary skip.
  3. The agent reads that its turn is still open and no wake was set, and what to do instead. The EventGate detail stays in the operator's log.
  4. skip_reply's definition is built once from eventGatePresent. The EventGate is set up before MCPL starts and isn't replaced afterwards, so the flag can't go stale.

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 description. We're asking you to update it to match the head. It still quotes the earlier error text, and it describes the description trim and its "Known" caveat, which eventGatePresent replaced. It also lists three tests, with receipts at f39e04f. Readers take the PR from its description.
  • The probes' describe. It's named after the reviewer and will stay in the suite, so we're asking for a name that says what it pins, for example "refused wake: no lifecycle events; zero is an ordinary skip". This one is a request, not a condition.

The merge decision is now with the maintainers.

Written by Alfred-2570.

— Lottie-2661

@nissa-seru

Copy link
Copy Markdown
Contributor

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 653fdd2, and these are its findings that held when we checked them. Our runs, as before, are of 653fdd2 merged onto main at cb4c763, on Linux.

Corrections.

  • We said eventGatePresent had replaced the description trim and its "Known" caveat. It hadn't. ChannelRegistry still removes the sentence with String.replace on a copy of it (src/mcpl/channel-registry.ts:712-713, beside the definition's own copy at 446). So the "Known" caveat in your description still holds, and it shouldn't come out. What changed is the schema test, which now reads the registry's real description. We reworded the sentence, once keeping the parameter's name and once dropping it, and the test failed both times.
  • We said the refusal and the restore share one predicate. They test the same condition, but in two places: src/framework.ts:12213-12216 and 15001-15002.

Requests.

  1. The changelog. The fragment says the call now returns an error. Please add that without an EventGate, skip_reply no longer offers wake_in_seconds, since every agent on such a host sees that change in its tools.
  2. Two comments. src/framework.ts:14997-14998 and src/mcpl/channel-registry.ts:2978-2979 still say the framework strips the field when there's no gate. It now refuses the call.
  3. The probes' store. runProbe makes its store under tmpdir() (test/skip-reply-wake-capability.test.ts:136) and never removes it, so each run leaves two directories behind. This one goes with the describe rename.
  4. A test of the framework's wiring, eventGatePresent: this.eventGate !== null (src/framework.ts:15982). No test checks that line today: the schema test builds a ChannelRegistry itself, and this PR's framework-level tests replace the registry. We tried two changes to the line, inverting it and removing it. Each time, all 410 tests in the 16 files that use skip_reply, wake_in_seconds or the channel tools still passed. That line decides what every host's agents are offered. A test that creates an AgentFramework with and without a gate, then reads skip_reply from its tools, would pin it.

XML tool mode. In XML tool mode, membrane's tool-calls events carry no roundContent (membrane 0.5.87, src/membrane.ts:3655-3662), and the framework routes a round live only when its event carries it (src/framework.ts:12253-12254). So the turn's text goes out once, at turn end. At that point the framework checks the whole turn's tool_use blocks for a silencing tool (src/framework.ts:12827-12833). The turn-end response holds every round's calls, the refused skip_reply among them. So after a refused wake, the next round's reply isn't sent.

The probe below gives the framework those events: tool-calls without roundContent, and a turn-end response built by membrane 0.5.87's own parser (parseAccumulatedIntoBlocks, which its buildFinalResponse runs in XML mode).

  • With skip_reply and a wake, the wake is refused and the turn goes on, but nothing is routed.
  • The same turn with an ordinary tool in place of skip_reply routes both rounds' text.

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: test/probe-198-xml-mode.test.ts, run against 653fdd2 merged onto main at cb4c763, on Node 24.21.0 and 20.20.2
/**
 * Review probe for agent-framework #198 at 653fdd25 (not part of the PR).
 *
 * XML tool mode: tool-calls events carry no roundContent, so no round is
 * live-routed, and the turn-end response is membrane's own parse of the
 * whole turn's accumulated text (parseAccumulatedIntoBlocks, the parse its
 * buildFinalResponse runs in XML mode). Without an EventGate, a round of
 * text and skip_reply(wake_in_seconds: 60) is refused, and the turn goes on
 * to a round of plain text. The probe records what reaches the channel.
 *
 * Control: the same turn with a module tool in place of skip_reply, so the
 * harness's turn-end routing is seen to deliver in XML mode.
 */
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import type { NormalizedRequest, NormalizedResponse, YieldingStream, StreamEvent } from '@animalabs/membrane';
import type { Module, ModuleContext, ProcessState, ProcessEvent, EventResponse, ToolDefinition, ToolCall, ToolResult } from '../src/index.js';
import { AgentFramework } from '../src/index.js';

type Parsed = { blocks: unknown[]; toolCalls: unknown[]; toolResults: unknown[] };
const MEMBRANE = join(process.cwd(), 'node_modules/@animalabs/membrane');
const membraneVersion = (JSON.parse(readFileSync(join(MEMBRANE, 'package.json'), 'utf8')) as { version: string }).version;
const parseAccumulated = async (text: string): Promise<Parsed> =>
  ((await import(pathToFileURL(join(MEMBRANE, 'dist/utils/tool-parser.js')).href)) as { parseAccumulatedIntoBlocks(t: string): Parsed })
    .parseAccumulatedIntoBlocks(text);

const R1 = 'ROUND_ONE_TEXT the build is running';
const FINAL = 'NEXT_ROUND_REPLY here is the answer';

class XmlStream implements YieldingStream {
  private events: StreamEvent[] = [];
  private _done = false;
  private _waiting = false;
  private _round = 0;
  private pending: (() => void) | null = null;
  readonly received: unknown[][] = [];

  constructor(private call: { id: string; name: string; input: Record<string, unknown>; raw: string }, private finalResponse: NormalizedResponse) {
    this._waiting = true;
    this.events.push({
      type: 'tool-calls',
      calls: [{ id: call.id, name: call.name, input: call.input }],
      context: { rawText: call.raw, preamble: R1 + '\n', depth: 0, previousResults: [], accumulated: R1 + '\n' + call.raw },
    } as StreamEvent);
  }

  provideToolResults(results: unknown[]): void {
    if (!this._waiting) throw new Error('Not waiting for tools');
    this.received.push(results);
    this._waiting = false;
    this._round++;
    this.events.push({ type: 'complete', response: this.finalResponse } as StreamEvent);
    this._done = true;
    if (this.pending) { const r = this.pending; this.pending = null; r(); }
  }

  cancel(): void {
    this._done = true;
    this.events.push({ type: 'aborted', reason: 'user' } as StreamEvent);
    if (this.pending) { const r = this.pending; this.pending = null; r(); }
  }

  get isWaitingForTools() { return this._waiting; }
  get pendingToolCallIds(): string[] { return this._waiting ? [this.call.id] : []; }
  get toolDepth() { return this._round; }

  async *[Symbol.asyncIterator](): AsyncIterator<StreamEvent> {
    while (true) {
      while (this.events.length > 0) {
        const event = this.events.shift()!;
        yield event;
        if (event.type === 'complete' || event.type === 'error' || event.type === 'aborted') return;
      }
      if (this._done) return;
      await new Promise<void>((resolve) => { this.pending = resolve; });
    }
  }
}

class EchoModule implements Module {
  readonly name = 'test';
  async start(_ctx: ModuleContext): Promise<void> {}
  async stop(): Promise<void> {}
  getTools(): ToolDefinition[] {
    return [{ name: 'echo', description: 'Echoes', inputSchema: { type: 'object', properties: { message: { type: 'string', description: 'm' } }, required: ['message'] } }];
  }
  async handleToolCall(call: ToolCall): Promise<ToolResult> { return { success: true, data: { echoed: (call.input as { message: string }).message } }; }
  async onProcess(_event: ProcessEvent, _state: ProcessState): Promise<EventResponse> { return {}; }
}

async function xmlTurn(call: { id: string; name: string; input: Record<string, unknown>; raw: string }, results: string) {
  const accumulated = `${R1}\n${call.raw}\n${results}\n${FINAL}`;
  const parsed = await parseAccumulated(accumulated);
  const finalResponse = {
    content: parsed.blocks, rawAssistantText: accumulated, toolCalls: parsed.toolCalls, toolResults: parsed.toolResults,
    stopReason: 'end_turn', usage: { inputTokens: 10, outputTokens: 5 }, details: { raw: {} },
  } as unknown as NormalizedResponse;
  let stream: XmlStream | undefined;
  const membrane = {
    async complete(_r: NormalizedRequest): Promise<NormalizedResponse> { throw new Error('not used'); },
    streamYielding(_r: NormalizedRequest): YieldingStream { stream = new XmlStream(call, finalResponse); return stream; },
  };
  const dir = mkdtempSync(join(tmpdir(), 'probe-198-xml-'));
  const framework = await AgentFramework.create({
    storePath: join(dir, 'store'), membrane: membrane as unknown as import('@animalabs/membrane').Membrane,
    agents: [{ name: 'agent', model: 'test', systemPrompt: 'test' }], modules: [new EchoModule()],
  });
  const routed: string[] = [];
  (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({
    getChannelTools: () => [], resolveLocus: () => 'channel:test',
    routeSpeech: async (_agent: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'channel:test' }; },
    handleChannelToolCall: async () => ({ success: true, endTurn: true, data: { skipped: true, note: 'Turn ended; nothing sent.' } }),
  }, { get: (target, prop: string) => prop in target ? target[prop as keyof typeof target] : () => undefined });
  try {
    const agent = framework.getAgent('agent')!;
    await (framework as unknown as { startAgentStream(agent: unknown, trigger: unknown): Promise<void> })
      .startAgentStream(agent, { agentName: 'agent', reason: 'test', source: 'test', timestamp: Date.now() });
    await framework.runUntilIdle();
    const toolUseNames = (parsed.blocks as Array<{ type: string; name?: string }>).filter((b) => b.type === 'tool_use').map((b) => b.name);
    return { routed, results: stream?.received ?? [], toolUseNames };
  } finally {
    await framework.stop();
    rmSync(dir, { recursive: true, force: true });
  }
}

describe('#198 probe: XML tool mode', () => {
  it('a refused wake: the next round\'s reply', async () => {
    const r = await xmlTurn({
      id: 'wake-1', name: 'skip_reply', input: { reason: 'later', wake_in_seconds: 60 },
      raw: '<function_calls><invoke name="skip_reply"><parameter name="reason">later</parameter><parameter name="wake_in_seconds">60</parameter></invoke></function_calls>',
    }, '<function_results><error>Your turn did not end, and no wake was set.</error></function_results>');
    console.log(`[probe-198-xml] membrane ${membraneVersion}; refused wake: tool_use blocks in the turn-end response ${JSON.stringify(r.toolUseNames)}; results ${JSON.stringify(r.results)}; routed ${JSON.stringify(r.routed)}`);
    assert.equal((r.results[0]?.[0] as { isError?: boolean } | undefined)?.isError, true, 'the wake was refused and the turn went on');
    assert.deepEqual(r.routed, [], 'by reading: the turn-wide scan finds skip_reply, so nothing is routed, the reply included');
  });

  it('control: a module tool in place of skip_reply', async () => {
    const r = await xmlTurn({
      id: 'echo-1', name: 'test--echo', input: { message: 'x' },
      raw: '<function_calls><invoke name="test--echo"><parameter name="message">x</parameter></invoke></function_calls>',
    }, '<function_results><result><stdout>{"echoed":"x"}</stdout></result></function_results>');
    console.log(`[probe-198-xml] membrane ${membraneVersion}; control: tool_use blocks ${JSON.stringify(r.toolUseNames)}; routed ${JSON.stringify(r.routed)}`);
    assert.ok(r.routed.some((t) => t.includes('NEXT_ROUND_REPLY')), 'the harness routes a tool turn\'s prose at turn end in XML mode');
  });
});

Written by Alfred-2570.

— Lottie-2661

@rufalupa666-netizen

Copy link
Copy Markdown
Author

@nissa-seru both comments addressed at 4ee8c92 (two commits on top of 653fdd2):

  • Changelog says that without an EventGate skip_reply no longer offers wake_in_seconds (changelog.d/skip-reply-wake-no-gate.fixed.md:1-2).
  • The two stale comments now say the call is refused, not stripped (src/framework.ts:15018, src/mcpl/channel-registry.ts:2979).
  • The probes' describe is "refused wake: no lifecycle events; zero is an ordinary skip", and runProbe's store is removed in afterEach (test/skip-reply-wake-capability.test.ts:181, :22).
  • Framework wiring test: creates an AgentFramework with and without a gate and reads skip_reply from its live tools (test/skip-reply-wake-capability.test.ts:76).
  • XML tool mode (f479f83): after a refused wake, the turn-end scan no longer silences the later rounds; the refused round's text stays private. Your probe is in the suite as test/skip-reply-wake-xml-mode.test.ts.
  • 4ee8c92: the shared input object is cloned before the framework touches it, so the model's stored tool_use is never rewritten; closes skip_reply without an EventGate rewrites the model's own stored tool call #394 (same object problem as fix(gate): persist sleep and self-wake intent across restarts; re-arm on backward clock step #194's R2-3). Regression at test/skip-reply-wake-capability.test.ts:115.
  • Description refreshed; the description-trim "Known" caveat stays, as you corrected.
    Receipts: full serial suite 2,240 tests / 171 suites, 2,234 passed, 0 failed, 6 skipped; tsc --noEmit and git diff --check clean.

@nissa-seru

Copy link
Copy Markdown
Contributor

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 f479f83 and 4ee8c92, and these are its findings that held when we checked them. Our runs are of 4ee8c92 merged onto main at 14e25dc, on Linux, Node 24.22.0, with membrane 0.5.87.

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 splitProseSegments of that round's text (src/framework.ts:12227-12229), and at turn end, for each recorded segment, the first equal segment of membrane's parse of the whole turn is removed (src/framework.ts:12551-12562).

  • Thinking. With thinking on, membrane's XML tool loop opens <thinking> after each round's results and starts the next round after that opener (membrane 0.5.87, src/membrane.ts:3816-3830), so the round's roundPreamble begins with its thinking and </thinking>. The turn-end parse puts the thinking in a block of its own (src/membrane.ts:2854-2861), so the two texts differ and the refused round's text is delivered. The same happens when the model writes <thinking> markup itself.
  • Repeated prose. Since the first equal segment in the turn is removed, when an earlier round that wasn't refused said the same thing, that round's copy is withheld and the refused round's copy is delivered in its place, later in the turn.

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:

  • Thinking on; a round with a module tool, then the refused round, then the reply: PUBLIC_ONE, REFUSED_PRIVATE, FINAL_REPLY.
  • Thinking off, the refused round's text holding the model's own <thinking>…</thinking>: REFUSED_PRIVATE, FINAL_REPLY.
  • The same with plain text, as a control: FINAL_REPLY.
  • "Checking now." in round 1, MIDDLE_PUBLIC in round 2, and "Checking now." again in the refused round 3: MIDDLE_PUBLIC, Checking now., FINAL_REPLY. The control, whose refused round says something else, gives Checking now., MIDDLE_PUBLIC, FINAL_REPLY.

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. test/skip-reply-wake-xml-mode.test.ts. It's our probe from the last round, taken in whole, and parts of it were written only for a probe. Its header says it isn't part of the PR (line 2), its describe is "#​198 probe: XML tool mode" (line 131), and it prints [probe-198-xml] lines on every run (lines 137 and 147). Please have its describe say what it pins, and drop the header and the console.log lines. Its control also asserts only that the later round is routed (line 148), while the description says the control routes both rounds. Please assert the first round's text as well, so the test backs that.

3. What closes #394. The description gives the clone the credit. On main, the no-gate branch deletes wake_in_seconds from the call's input (src/framework.ts:15295 at 14e25dc), and this PR's refusal returns without deleting it. In our runs of the two skip-reply-wake test files, "does not mutate the stored assistant tool_use input when refusing a wake" fails on main's src/ and passes at the head. With the clone removed, all 11 tests pass. With the delete put back in the no-gate branch, they all pass while the clone is there, and that test fails once the clone is gone too. So #​394 closes because the field is no longer deleted, and the clone keeps any later edit off the stored input. Please say that in the description.

Optional:

  • A changelog line for #​394: without an EventGate, the stored skip_reply call now keeps its wake_in_seconds.
  • src/framework.ts:160 and 12289-12290 say that skip_reply ends the turn, and a refused one doesn't.

Thank you for taking in the four requests.

The probe, run against 4ee8c92 merged onto main at 14e25dc, Node 24.22.0, membrane 0.5.87 (written for this review, not meant to be added to the suite as it stands)
/**
 * A probe of #198's XML-mode filter of a refused round's prose, at 4ee8c92
 * merged onto main, for this review only.
 *
 * The stream follows membrane 0.5.87's XML tool loop (dist/membrane.js
 * 2776-2790, 3027-3034, 3162-3170, 3215-3217): each tool-calls event's context
 * has rawText (the function_calls block), preamble (all model text so far,
 * after the initial prefill), roundPreamble (this round's text, from where the
 * round started) and accumulated; after a round's results, with thinking on,
 * membrane pushes "\n<thinking>" before the next round starts, and a request
 * with thinking on starts inside a thinking block (the prefill's opener, so the
 * turn-end parse gets startInsideBlock 'thinking'). The turn-end response is
 * membrane's own parseAccumulatedIntoBlocks over the turn's text after the
 * prefill. Each case prints what reached the channel.
 *
 * C1: thinking on; a module-tool round, then a refused-wake round, then the
 *     final round. Does the refused round's visible text reach the channel?
 * C1b: thinking off, but the refused round's text holds the model's own
 *     <thinking> markup before its visible text.
 * C1c: control, as C1b without the markup.
 * C2: identical prose in an earlier public round and in the refused round,
 *     with another public round between them. What order is delivered?
 * C2c: control, the refused round's prose unique.
 */
import { describe, it } from 'node:test';
import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import type { NormalizedRequest, NormalizedResponse, YieldingStream, StreamEvent } from '@animalabs/membrane';
import type { Module, ModuleContext, ProcessState, ProcessEvent, EventResponse, ToolDefinition, ToolCall, ToolResult } from '../src/index.js';
import { AgentFramework } from '../src/index.js';

type Parsed = { blocks: unknown[]; toolCalls: unknown[]; toolResults: unknown[] };
const MEMBRANE = join(process.cwd(), 'node_modules/@animalabs/membrane');
const membraneVersion = (JSON.parse(readFileSync(join(MEMBRANE, 'package.json'), 'utf8')) as { version: string }).version;
const parse = async (text: string, startInsideBlock?: string): Promise<Parsed> =>
  ((await import(pathToFileURL(join(MEMBRANE, 'dist/utils/tool-parser.js')).href)) as { parseAccumulatedIntoBlocks(t: string, o?: unknown): Parsed })
    .parseAccumulatedIntoBlocks(text, startInsideBlock ? { startInsideBlock } : undefined);

type Round = { text: string; call: { id: string; name: string; input: Record<string, unknown> }; results: string };
const raw = (c: Round['call']) => `<function_calls><invoke name="${c.name}">${Object.entries(c.input).map(([k, v]) => `<parameter name="${k}">${String(v)}</parameter>`).join('')}</invoke></function_calls>`;
const echo = (id: string, text: string): Round => ({ text, call: { id, name: 'test--echo', input: { message: 'x' } }, results: '<function_results><result><stdout>{"echoed":"x"}</stdout></result></function_results>' });
const refusedWake = (id: string, text: string): Round => ({ text, call: { id, name: 'skip_reply', input: { reason: 'later', wake_in_seconds: 60 } }, results: '<function_results><error>refused</error></function_results>' });

class Stream implements YieldingStream {
  private events: StreamEvent[] = [];
  private done = false;
  private waiting = false;
  private round = 0;
  private acc = '';
  private roundStart = 0;
  private pending: (() => void) | null = null;
  readonly received: unknown[][] = [];

  constructor(private rounds: Round[], private finalText: string, private thinking: boolean) { this.emitRound(); }

  private emitRound(): void {
    const r = this.rounds[this.round];
    if (!r) { void this.finish(); return; }
    const before = this.acc + r.text;
    this.events.push({
      type: 'tool-calls',
      calls: [{ id: r.call.id, name: r.call.name, input: r.call.input }],
      context: { rawText: raw(r.call), preamble: before, roundPreamble: before.slice(this.roundStart), depth: this.round, previousResults: [], accumulated: before + raw(r.call) },
    } as StreamEvent);
    this.acc = before + raw(r.call);
    this.waiting = true;
    this.wake();
  }

  private async finish(): Promise<void> {
    this.acc += this.finalText;
    const parsed = await parse(this.acc, this.thinking ? 'thinking' : undefined);
    this.events.push({ type: 'complete', response: {
      content: parsed.blocks, rawAssistantText: this.acc, toolCalls: parsed.toolCalls, toolResults: parsed.toolResults,
      stopReason: 'end_turn', usage: { inputTokens: 10, outputTokens: 5 }, details: { raw: {} },
    } as unknown as NormalizedResponse } as StreamEvent);
    this.done = true;
    this.wake();
  }

  provideToolResults(results: unknown[]): void {
    if (!this.waiting) throw new Error('Not waiting for tools');
    this.received.push(results);
    this.waiting = false;
    this.acc += this.rounds[this.round]!.results + (this.thinking ? '\n<thinking>' : '');
    this.roundStart = this.acc.length;
    this.round++;
    this.emitRound();
  }

  cancel(): void { this.done = true; this.events.push({ type: 'aborted', reason: 'user' } as StreamEvent); this.wake(); }
  private wake(): void { if (this.pending) { const r = this.pending; this.pending = null; r(); } }
  get isWaitingForTools() { return this.waiting; }
  get pendingToolCallIds(): string[] { return this.waiting ? [this.rounds[this.round]!.call.id] : []; }
  get toolDepth() { return this.round; }
  async *[Symbol.asyncIterator](): AsyncIterator<StreamEvent> {
    while (true) {
      while (this.events.length > 0) {
        const event = this.events.shift()!;
        yield event;
        if (event.type === 'complete' || event.type === 'error' || event.type === 'aborted') return;
      }
      if (this.done) return;
      await new Promise<void>((resolve) => { this.pending = resolve; });
    }
  }
}

class EchoModule implements Module {
  readonly name = 'test';
  async start(_ctx: ModuleContext): Promise<void> {}
  async stop(): Promise<void> {}
  getTools(): ToolDefinition[] {
    return [{ name: 'echo', description: 'Echoes', inputSchema: { type: 'object', properties: { message: { type: 'string', description: 'm' } }, required: ['message'] } }];
  }
  async handleToolCall(call: ToolCall): Promise<ToolResult> { return { success: true, data: { echoed: (call.input as { message: string }).message } }; }
  async onProcess(_event: ProcessEvent, _state: ProcessState): Promise<EventResponse> { return {}; }
}

async function turn(what: string, rounds: Round[], finalText: string, thinking = false) {
  let stream: Stream | undefined;
  const membrane = {
    async complete(_r: NormalizedRequest): Promise<NormalizedResponse> { throw new Error('not used'); },
    streamYielding(_r: NormalizedRequest): YieldingStream {
      stream = new Stream(rounds, finalText, thinking);
      return stream;
    },
  };
  const dir = mkdtempSync(join(tmpdir(), 'probe-198-r2-'));
  const framework = await AgentFramework.create({
    storePath: join(dir, 'store'), membrane: membrane as unknown as import('@animalabs/membrane').Membrane,
    agents: [{ name: 'agent', model: 'test', systemPrompt: 'test' }], modules: [new EchoModule()],
  });
  const routed: string[] = [];
  (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({
    getChannelTools: () => [], resolveLocus: () => 'channel:test',
    routeSpeech: async (_agent: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'channel:test' }; },
    handleChannelToolCall: async () => ({ success: true, endTurn: true, data: { skipped: true, note: 'Turn ended; nothing sent.' } }),
  }, { get: (target, prop: string) => prop in target ? target[prop as keyof typeof target] : () => undefined });
  try {
    const agent = framework.getAgent('agent')!;
    await (framework as unknown as { startAgentStream(agent: unknown, trigger: unknown): Promise<void> })
      .startAgentStream(agent, { agentName: 'agent', reason: 'test', source: 'test', timestamp: Date.now() });
    await framework.runUntilIdle();
    const errors = (stream?.received ?? []).map((r) => (r[0] as { isError?: boolean } | undefined)?.isError === true);
    console.log(`PROBE ${what} (membrane ${membraneVersion}): routed ${JSON.stringify(routed)}; results isError per round ${JSON.stringify(errors)}`);
  } finally {
    await framework.stop();
    rmSync(dir, { recursive: true, force: true });
  }
}

describe('#198 at 4ee8c92: what reaches the channel around a refused wake in XML mode', () => {
  it('C1: thinking on, a refused round after a public round', async () => {
    await turn('C1 thinking on', [
      echo('e1', 'plan one</thinking>\nPUBLIC_ONE'),
      refusedWake('w1', 'plan two</thinking>\nREFUSED_PRIVATE'),
    ], 'plan three</thinking>\nFINAL_REPLY', true);
  });
  it('C1b: thinking off, the refused round holds the model\'s own thinking markup', async () => {
    await turn('C1b own markup', [
      refusedWake('w1', '<thinking>a plan</thinking>\nREFUSED_PRIVATE'),
    ], '\nFINAL_REPLY');
  });
  it('C1c: control, plain refused round', async () => {
    await turn('C1c plain', [
      refusedWake('w1', 'REFUSED_PRIVATE'),
    ], '\nFINAL_REPLY');
  });
  it('C2: identical prose in an earlier public round and the refused round', async () => {
    await turn('C2 repeated prose', [
      echo('e1', 'Checking now.'),
      echo('e2', 'MIDDLE_PUBLIC'),
      refusedWake('w1', 'Checking now.'),
    ], '\nFINAL_REPLY');
  });
  it('C2c: control, the refused round\'s prose unique', async () => {
    await turn('C2c unique', [
      echo('e1', 'Checking now.'),
      echo('e2', 'MIDDLE_PUBLIC'),
      refusedWake('w1', 'REFUSED_PRIVATE'),
    ], '\nFINAL_REPLY');
  });
});

Written by Alfred-2570.

— Lottie-2661

Alfred-2570 added 3 commits October 17, 2026 03:08
…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.
@nissa-seru

Copy link
Copy Markdown
Contributor

@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.

0bd9650: the refused round is found by its place in XML tool mode. At the turn's end, the delivery now drops a refused round's text by the round's index among the stream's tool rounds (withoutRoundsProse, beside splitProseSegments in src/prose-segments.ts), where it used to match the round's text. A round's prose is its text before its calls, and a round starts at the first block after the previous round's results. Matching by text missed three cases: a round that began with thinking, a round that held the model's own <thinking> markup, and repeated prose, where it withheld the earlier copy. test/skip-reply-wake-xml-mode.test.ts builds each turn through membrane's own parseAccumulatedIntoBlocks. It covers thinking on, the model's own markup, repeated prose, a round with no prose before, between or as the refused one, and a round of two calls. The probe scaffolding is gone. The two comments that said skip_reply always ends the turn now say a refused one doesn't. The changelog has a line for #394.

The input clone is removed too. Nothing writes to the call's input now that the delete is gone, and removing the delete is what closes #​394. Our last comment said the clone keeps later edits off the stored input, but there are none to keep off. The clone also turned a null input into {}, and skip_reply's handler now gets the input as main gives it.

27cc421: the silence saved before a refused wake is a local of driveStream. It was a field of the framework, keyed by agent. It was cleared when a new turn started and when the turn ended, but it was carried into a stream that resumes the turn. It's written as the refused round is dispatched and read at the next round, both inside one driveStream call. So a budget restart now starts from its trigger, as it does after any other round. A test runs a budget restart after a send and a refused wake, and again with a plain tool call in the refused one's place. Both restarted streams deliver their own text.

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 main's does after a plain tool call.

099ef99: one rule for a refused call. refusesSelfWake decides whether a call is refused: it asks for a timed wake, the channel registry handles skip_reply, and there's no EventGate. It's 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. Before, 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, not only the refused call. Neither difference could be observed. A plain skip_reply ends the turn at its round, as the test "B: wake_in_seconds: 0 remains an ordinary skip" shows. Without a registry, the turn-end delivery doesn't run (src/framework.ts:12836 at 099ef99). Now the code does what its comments say. The tests now also assert what the restarted stream says, and that the turn goes on to its last response after a refused wake beside or before a send.

Two sentences for the description, which is yours, so we've left it as it is:

  • In place of the clone line: "Dispatch no longer deletes wake_in_seconds from the call's input, so the assistant's stored tool_use.input stays exactly what the model emitted. Closes #​394."
  • In place of the wants line: "A call is refused when it asks for a timed wake and the host has no EventGate (refusesSelfWake), so wake_in_seconds: 0 remains an ordinary skip."

Our runs are of 099ef99 merged onto main at 2482625, on Linux, Node 24.22.0 and 20.20.2, built with TypeScript 7.0.2, with membrane 0.5.87:

  • The 14 test files that touch skip_reply or prose routing pass 243 of 243 on both Node versions.
  • With your head's src/framework.ts and src/prose-segments.ts merged onto the same main, and a stand-in withoutRoundsProse that drops nothing, 6 of the round's 28 tests fail: the budget restart, thinking on, the model's own markup, repeated prose, and withoutRoundsProse's two unit tests.
  • We removed or broke each of the 20 pieces in turn, and 18 of them fail a test. The two that don't are the gate and registry conditions in refusesSelfWake. Neither can be observed: with a gate, the call is armed and the registry's handler ends the turn (src/mcpl/channel-registry.ts, handleToolSkipReply, endTurn: true). Without a registry, there's no delivery for them to change.

Thank you for the PR and for taking in our earlier requests.

Written by Alfred-2570.

— Lottie-2661

nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 17, 2026
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 17, 2026
@nissa-seru nissa-seru added the no-greptile For maintainers and the team: Greptile skips this PR. Not for general use. label Oct 17, 2026
@nissa-seru
nissa-seru merged commit ceedb77 into anima-research:main Oct 17, 2026
7 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 17, 2026
…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 pushed a commit to rergards/agent-framework that referenced this pull request Oct 17, 2026
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.
nissa-seru pushed a commit to rergards/agent-framework that referenced this pull request Oct 17, 2026
…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.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 17, 2026
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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
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.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
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 pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…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>
@rufalupa666-netizen

Copy link
Copy Markdown
Author

@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 driveStream is the cleaner place for it. Thank you for carrying the XML-mode fix, refusesSelfWake, and the merge; the by-index matching covers the three cases text matching missed. Taking your two sentences into the description now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

greptile no-greptile For maintainers and the team: Greptile skips this PR. Not for general use.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

skip_reply without an EventGate rewrites the model's own stored tool call

5 participants