Skip to content

fix(prose): what a drafts round would hold or bounce stays in context (#701) - #716

Merged
nissa-seru merged 8 commits into
anima-research:mainfrom
nissa-seru:fix/701-drafts-round-private
Oct 26, 2026
Merged

nissa-seru merged 8 commits into
anima-research:mainfrom
nissa-seru:fix/701-drafts-round-private

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

For #701, which this addresses in part. In a round that calls drafts, with any action, what the round would hold or bounce for lack of a destination is now held as a draft with no notice mid-turn. It stays listed by drafts and resendable by id, and the turn's receipt or a later turn's catch-up notice names it, but nothing offers it for resend while the turn runs. In a locus or hybrid turn a send has silenced, a round that calls drafts and makes no send of its own keeps its plain speech in the agent's context instead, counted as kept private. Outside a silenced turn, what the round would deliver is still delivered, and a refused target or a failed delivery still bounces. That ends the loop #701 reports inside a cycle of drafts resends, where an agent that writes a line beside each call resent its own narration round after round. Narration beside another tool in a silenced turn is still held and offered each round, unless that round keeps its prose private, as "What this doesn't change" says, so whether #701 is done is the maintainers' call.

Problem

In locus and hybrid mode, once a turn has an explicit send, a drafts resend included, its later rounds' plain speech is held as drafts. That's the sticky explicit-send suppression in src/framework.ts, which its comment says lasts until fresh input arrives. A later round that keeps its prose private itself, such as one calling skip_reply, is the exception. Each round's notice then offers the new draft with a ready-made resend call. So an agent that writes one line beside each call never leaves the cycle. Each resend posts the previous line and holds the one written beside it, and the next notice offers that. In explicit mode, sends don't silence, but the same unprefixed line bounces into a draft.

We reproduced it on main at 8e0bb04e with a scripted agent: a paragraph beside an explicit send, then six rounds of resending its newest draft, each with a line beside it. In locus, hybrid and explicit mode alike, the channel received the paragraph and five narration lines, and one line was still held at the end, as a bounced draft in explicit mode. A line beside drafts dismiss was held too.

Changes

  • A drafts round holds what it would hold or bounce for lack of a destination, with no notice mid-turn. The loop is fed by a notice offering the line written beside a drafts call, so no notice offers such a line while the turn runs. That's drafts: a resend holds the prose written beside it as the next draft — an unbounded loop with a narrating model #701's second suggestion, built on the round's existing 'later' notice, as a turn's final prose is held, rather than on a new field of the draft record. The line stays an open draft, listed by drafts and resendable by id. In locus and hybrid mode the turn's receipt names it. explicit mode writes no receipt, so a later turn's catch-up notice names it: that of the first turn to start after the line is held, which is the next turn unless the hold waits behind a publish still in flight when that turn starts.
    • In explicit mode, an unprefixed line beside a drafts call while the turn has no destination is held as a no-destination draft instead of bouncing, so there's no bounce notice and no wake. Its words are held as the bounce would hold them: {{unsent}} is expanded, and a bare {{unsent}} is the latest bounce itself, already held. If the drafts journal can't hold the line, it bounces as without a drafts call, and the bounce notice says it couldn't be held, since explicit mode writes no receipt to report the failure.
    • Outside a silenced turn in locus and hybrid mode, deliverHybridProse's no-destination hold does the same.
  • In a locus or hybrid turn silenced by a send, a round that calls drafts and makes no send of its own, besides a drafts resend, goes to holdSilencedProse as 'drafts'. Its plain speech is kept in context and counted, since that narration is what the cycle resends. A round that also makes a send of its own holds its prose and offers it as any send's round does, as on main: that's the paragraph a held draft exists for, and the resend round after it has no other send, so its narration is still kept. What a >>> envelope addressed, a refused target's and a malformed envelope's included, is held as a silenced turn holds it on main, but with no notice mid-turn. A hybrid round's envelopes still move the router first, as a send-silenced round's always do. A private silence, such as a skip_reply in the same round, still outranks a drafts round and keeps everything, as before.
  • Outside a silenced turn, what the round would deliver is delivered as on main. A >>/>>> target is a deliberate send, so its words go out and later segments follow it. Narration that main posts, to a locus or a sticky target, is still posted. A refused target, a malformed envelope and a failed delivery still bounce, since each answers a deliberate send.
  • Only the framework's own drafts tool makes a drafts round. The drafts-round check and isSilencingCall's resend check read the call's bare name, so a module's or server's <prefix>--drafts counted as the framework's tool, and a resend through it silenced its round. The framework dispatches its own tool only under the exact name drafts, so both checks now test that. isSilencingCall's over-match predates this PR.
  • A drafts call from a script counts in explicit mode too (changesRouting), so its round waits for the script, as it already did in locus and hybrid mode. The scanner reads tool names from the script's text, so in explicit mode an envelope beside a script that only mentions drafts, in a comment, a string or a name such as mail--drafts, also waits for the script.
  • A drafts call the agent's allowedTools or the script bridge refuses counts for nothing, as a refused send doesn't (RoundCall.permitted). An agent whose allowedTools refuse drafts can't resend through it, so it can't feed the loop, and the bounce's {{unsent}} is its way back to its words. A call the bridge refuses did nothing, so its round is routed as if it weren't there. The script-call key that keeps each kind of call once now compares permitted too, so a refused call recorded first doesn't hide a later permitted one.
  • XML tool mode is unchanged. It routes a turn's prose once, at its end, with no notice mid-turn, so the loop can't form inside a turn there. Across turns, an explicit-mode line beside a drafts call still bounces there, and the bounce-wake cap ends a cycle of drafts resends after two wakes.
  • What an agent meets, against main:
    • A deliberate envelope in a silenced hybrid turn, such as >>>#ops The migration finished. beside a drafts call: on main it's held as a draft "written for >>>#ops", and the round's notice offers it with a ready resend. Here it's held the same way and named in the turn's receipt, but not offered mid-turn.
    • An explicit-mode line with no destination beside a drafts call: on main it bounces, with a notice, a bounced draft, a >>#channel {{unsent}} offer and a wake. Here it's held as a no-destination draft, with no notice and no wake, and a later turn's catch-up notice names it.
    • Plain speech beside a drafts call in a silenced locus or hybrid turn, in a round that makes no send of its own: on main it's held, and the round's notice offers it. Here it stays in context, counted as kept private in the receipt, and isn't held. In a round that also sends, it's held and offered, as on main.
  • The texts: the prose_help reference states the rule. The drafts tool's description is unchanged, so no agent's tool definitions change. The agent meets the rule where the held line shows up: the turn's receipt, a later turn's catch-up notice, and the drafts listing, which shows each held line with its cause. A no-destination draft's cause, as drafts read gives it, now reads "it had no destination: plain speech with no target waits for one". It used to say a routing envelope had failed, which isn't so for a line beside a drafts call, nor for main's own hold of plain speech with no locus or sticky target. docs/explicit-prose-routing.md states it beside the bounce rule it qualifies, and its section on learning by bounce names the one place a first undelivered line doesn't bounce. A changelog fragment says what changes for agents.
  • What we didn't build:
    • Keeping every word of a drafts round private, delivered or not, would also keep narration off the channel, but it would drop a deliberate >>#ops send beside a drafts call, silently in explicit mode.
    • Bouncing an explicit-mode line beside a drafts call once per turn, and holding later ones with no notice: run against the cycle, the agent resends its own bounce, so a narration line is posted.
    • drafts: a resend holds the prose written beside it as the next draft — an unbounded loop with a narrating model #701's third suggestion, a cap on rounds per turn, would bound the cost of any loop, but each round up to the cap would still post a line. It's a feature with its own design, outside this fix.

What this doesn't change

  • The other half of drafts: a resend holds the prose written beside it as the next draft — an unbounded loop with a narrating model #701. In a silenced turn, narration beside a tool other than drafts is still held and offered every round, unless the round keeps its prose private (skip_reply, or a private think in the same round), and a test pins that. A narrating agent in a long silenced turn still gets one held line and one offered resend per work round, so its cost is bounded by the turn's work rounds rather than unbounded. drafts: a resend holds the prose written beside it as the next draft — an unbounded loop with a narrating model #701's per-turn cap is the general bound.
  • An agent that resends its newest stored draft unprompted, rather than what a notice offers. We ran that policy too: a paragraph beside an explicit send, then six rounds, each resending the newest stored draft with a line beside the call. In locus and hybrid mode the paragraph is resent once and no narration is posted, since the silenced turn keeps each line. In explicit mode each round resends the line held beside the previous call, so five narration lines are posted and one is left held, as on main. Nothing offers those lines mid-turn here: each resend is the agent's own choice, made from its drafts.
  • Plain speech under a sticky target in a silenced hybrid turn, after an earlier round's >>>#general: beside a drafts call it's kept, like any plain speech there, where main holds and offers it. Holding it with no notice instead wouldn't re-offer it mid-turn, but it would hold a narration draft for every resend round, and by our reading an agent that resends its newest stored draft would loop on them. So it stays kept.
  • XML tool mode, as above.

Tests

At 992448ca, this PR's head, on Linux x86_64 with Node 24.22.1, after npm install, with main at 0a4a2c69 merged in (npm ls: context-manager 0.22.1, membrane 0.7.2, chronicle 0.4.0):

  • tsc --noEmit and npm run build are clean.
  • test/prose-drafts.test.ts, test/explicit-prose-routing.test.ts and test/code-execution.test.ts pass 332 of 332.
  • npm test: 7,605 tests, 7,560 passing, 44 skipped and 1 failing. The failure is test/code-execution-isolate-scripts.test.ts's "a left script that crashes or reaches its limit wakes its agent": both of its scripts were stopped at the test's 1.5 s limit, the one meant to crash included, on a machine whose load average was between about 67 and 85 at the run's start and end. That test drives src/code-execution/isolate directly, which this PR doesn't change. Run alone at this head three times, the file passed 41 of 41 each time.
  • The new tests:
    • The cycle ends. In each of locus, hybrid and explicit mode, an agent that writes a line beside each resend, and resends what a notice offers it, leaves the cycle after one resend: the paragraph is resent once and no narration line is posted. In locus and hybrid mode the line is kept and the receipt counts it. In explicit mode it's held as a no-destination draft, and one notice names it, a later turn's catch-up. A line beside dismiss is kept too, and a later round beside another tool is still held.
    • A round with a send of its own: in locus and hybrid mode, a round with a paragraph, a send and drafts list holds the paragraph, and a notice offers it, as on main. After such a round, an agent that resends what a notice offers posts the paragraph once, the line beside its resend is neither posted nor held, and the cycle ends.
    • In a turn silenced by a send (hybrid): a target's words beside a drafts call are held with no notice and named in the receipt, and an envelope that only names a destination holds nothing; a refused target's and a malformed envelope's words are held too; a >>>… ! envelope still asks for a continuation, with its words held; a round that calls skip_reply beside drafts stays wholly private.
    • Outside a silenced turn: locus and hybrid prose beside drafts list is posted as on main; a >>>/>> target beside a drafts call is delivered, and later rounds follow it; an explicit-mode line goes to the turn's sticky target; a refused hybrid target still bounces, and so does a deliberate explicit send whose delivery fails; a line left with no destination by a refused target is held with no notice; the explicit gateway holds a line with no destination, and drafts read gives its cause as having no destination, not a failed envelope; it delivers a target and bounces a refused one; {{unsent}} in such a line expands as in a bounce, and a bare one holds nothing new; a line the drafts journal can't hold bounces, and the bounce says it couldn't be held; a line whose hold waits behind a publish still in flight when a tool ends the turn is held as no-destination once the publish ends, doesn't bounce, and a later turn's catch-up names it.
    • Only the framework's drafts tool counts: after a send, a line beside mail--drafts is held as any silenced round's is, and a mail--drafts resend doesn't silence its round.
    • A refused call counts for nothing: in a turn with a send, a line beside a drafts call the agent may not use is held as before; in explicit mode, a script whose only drafts call the bridge refuses leaves its line to bounce.
    • Scripts: an explicit-mode drafts call from a script holds its round's line rather than bouncing it, and a call the bridge refuses doesn't hide a later permitted one in the same script.
    • XML tool mode: a line beside a resend is held as a draft, as on main. In explicit mode, a line beside each resend bounces, and the bounce-wake cap stops the cycle after two wakes.
  • Turned round: the tests that pinned this PR's earlier keep for a silenced turn's envelopes and for a line with no destination now expect them held, and the scripted resident now resends only what a notice offered, since a draft held with no notice isn't offered.
  • Red-first: with main's src/framework.ts under these tests, 23 of 332 fail: the tests of what the change keeps, or holds with no notice, the scripted cycles, the mail--drafts resend, and three older tests whose receipt assertion expects the receipt's new wording. The tests of where the change keeps main's behaviour pass there, as they should: a silenced turn's !, the deliveries, the bounces, the private round, the refused calls, a line beside mail--drafts, a line the journal can't hold, a round with a send of its own holding its paragraph, and XML tool mode. With ffb00ac7's src/framework.ts, merged with main, 4 fail: the four tests of a round with a send of its own.
  • Each piece is pinned. With each of these taken out of src/framework.ts, the three files fail at least one test:
    • the round not recognised as a drafts round (17 fail);
    • a refused drafts call making a drafts round (2);
    • a script's permitted drafts call kept once with a refused one, as the same kind (1);
    • a call the bridge refuses counted as permitted (3);
    • changesRouting not counting a script's drafts call in explicit mode (2);
    • the silenced hybrid path (5), and the silenced locus path (7);
    • a round's own send no longer keeping it from being a drafts round (4);
    • a private silence no longer outranking a drafts round (1);
    • in a silenced turn, an envelope's words kept rather than held (3), or held with the round's notice (2); a target's words, or a malformed envelope's, taken as plain speech (3, 1);
    • a silenced envelope's ! asking for nothing (1);
    • deliverHybridProse's no-destination line kept rather than held (1), or held with the round's notice (1);
    • deliverProseEnvelope's no-destination line kept rather than held (7), or held with the round's notice (2), or held without expanding {{unsent}} (1), or dropped when the journal can't hold it (1);
    • a no-destination draft's cause back to saying a routing envelope failed (1);
    • the drafts-round check, or isSilencingCall's resend check, reading the bare name (1 each);
    • and, for the XML test, the bounce-wake cap lifted (3).

Not verified

  • Against a live model or a live Discord connection: the tests use the repository's end-to-end drafts harness, a real MCPL child with a scripted model, and its explicit-routing harness.

  • Changelog fragment added: changelog.d/701-drafts-round-private.fixed.md.

Written by Hamish-2699 (Claude Opus 5.5). Ada-1017 (Claude Opus 5.5) made the design calls, Kestrel-2409 (Claude Opus 5.5) and Phoebe-2419 (Claude Opus 5.5) read the design, Ezra-2423 (Claude Opus 5.5) reviewed the code, and our internal review read it.

Description checked and posted by Daniel-2612 (Claude Opus 5.5).

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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated review

Recommendation: merge. The review found nothing of medium or high severity.

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

Does this change belong in the project?

It fits the project. This bug fix belongs in the project. It ends a resend loop inside one turn in the prose-routing and drafts feature the project already has.

5 reasons with sources

The code stays in the routing paths that already own that behaviour, and the change comes with the doc update, tests and changelog fragment the project asks for.

  • It fixes a reported loop (#701) in a feature the project already documents, and adds no new scope. The doc already describes the bounce-to-draft flow, the resend notice and a bounce-wake cap 'breaking any loop'. The PR deals with a loop inside a single turn, which that cap doesn't reach. (docs/explicit-prose-routing.md (The bounce))
  • The change sits in the routing code that already owns this decision, and reuses existing ideas. It extends the keepPrivate round flag and the silence cause with a 'drafts' value, mirroring how skip_reply rounds are already handled. It adds no module, dependency or public interface. (src/framework.ts (routeRound, holdSilencedProse, deliverProseEnvelope))
  • The design doc is updated beside the bounce rule this change qualifies. The new paragraph explains the exception. Lines 28-29 of the base doc still say 'a round kept private ... sets no target', and the drafts round is an unstated exception to that. The code review already raised this as a low finding about doc wording, not a departure from the design. (docs/explicit-prose-routing.md)
  • The PR follows the contribution rules: a changelog fragment, tests for the new behaviour, and a body with numbered test results. It adds changelog.d/701-drafts-round-private.fixed.md and new tests for locus, hybrid and explicit mode, and the body gives pass counts. (CONTRIBUTING.md (What a PR should contain; Changelog))
  • It is one coherent change. The other ideas from the issue are explicitly left out of scope. The body explains why a field on the draft record and a cap on rounds per turn were not built. (CONTRIBUTING.md (Out of scope))

Findings

No findings of medium or high severity.

3 more findings, less severe
  • Low severity, documentation, docs/explicit-prose-routing.md:52-56. The doc's earlier "a round kept private sets no target" rule now has an unmentioned exception. Lines 28-29 of the same doc say "A round kept private (one that calls skip_reply, or has a same-round private think) sets no target." The new paragraph calls a drafts round's words "in context only, as a skip_reply round's do", yet it does set a target. The sticky-target rule at line 28 should mention that drafts rounds keep their words but still set the destination, or a reader will get conflicting rules.
  • Low severity, maintainability, test/code-execution.test.ts:1209-1219. The serveWakes test option patches MockMembrane's private fields. serveWakes overrides streamYielding and reads the mock's private responses and responseIndex through casts to pad empty responses. Any internal rename in MockMembrane will silently break this helper. Supporting a default response in MockMembrane itself would be sturdier.
  • Low severity, maintainability, test/prose-drafts.test.ts:1103-1113. whileOffered builds a response object out of property getters that re-run each access. The helper copies the keys of a response built before the turn, then defines a getter for each key that calls now() again. Every property read therefore rebuilds a mock response from the current draft state, so the object's fields can disagree if the drafts change between reads. The approach also relies on the mock reading these fields lazily. A callback-based response hook in the mock would make the test's intent explicit and robust.

@nissa-seru
nissa-seru force-pushed the fix/701-drafts-round-private branch 3 times, most recently from 4eabf03 to 1bc0fae Compare October 23, 2026 01:15
@nissa-seru nissa-seru changed the title fix(prose): a round that calls drafts keeps its own words private (#701) fix(prose): what a drafts round would hold or bounce stays in context (#701) Oct 23, 2026

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated review

Recommendation: needs a maintainer's decision. The change belongs, but part of it needs a maintainer's call.

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

Does this change belong in the project?

It fits, with concerns. This bug fix (#701) belongs: it stops a resend loop inside one turn in the existing drafts and prose-routing feature, and it updates the routing doc and adds a changelog fragment. One part needs a maintainer's decision: in explicit mode, an unprefixed line beside a drafts call is now kept silently, which narrows the documented bounce-and-notice rule.

5 reasons with sources
  • The change fixes a loop in behavior the project already has: drafts, bounces and explicit-send silencing. The doc treats breaking loops as a goal: the bounce-wake cap exists "breaking any loop". The PR targets a loop inside a single turn, which that cap doesn't reach. All edits stay inside the existing routing code in src/framework.ts. It adds no module, dependency or public interface. (docs/explicit-prose-routing.md, "The bounce (unprefixed prose)")
  • In explicit mode, the change makes an exception to the documented bounce rule, and a maintainer should accept that on purpose. The doc says unprefixed prose with no target is held as a draft and a notice tells the agent how to resend it. It presents explicit mode's main idea as turning failure into "non-delivery (bounce, cheap retry)". After this PR, a line beside a drafts call is kept in context with no notice and no resend offer. The PR names this as its cost and documents it, but it is a policy choice for the maintainers, not just a fix. (docs/explicit-prose-routing.md, lines 9-11 and 44-50)
  • In a silenced locus/hybrid turn, deliberate envelopes beside a drafts call are no longer offered for resend. This is a second semantic change for maintainers to weigh. Before this PR, the code held silenced words as resendable drafts so they were never silently lost. Now a >>> target's words, a refused target and a malformed envelope in a drafts round are all kept and counted instead. The review also found that a refused drafts call still turns its round into a drafts round, so this exemption reaches further than intended. (src/framework.ts, holdSilencedProse doc comment ("Never a silent black hole"))
  • The PR follows the project's contribution conventions. It adds changelog.d/701-drafts-round-private.fixed.md, updates the design doc next to the rule it qualifies, and reports test counts with the Problem/Changes/Tests layout. (CONTRIBUTING.md, "What a PR should contain" and "Changelog")
  • The change is one coherent piece of work. It turns down the issue's broader alternatives as separate features. A cap on rounds per turn and a new field on the draft record are both explicitly left out. The fix is limited to drafts rounds in native tool mode. (CONTRIBUTING.md, "What a PR should contain" (Out of scope))

Findings

1 finding of medium or high severity, see the inline comment:

4 more findings, less severe
  • Low severity, test coverage, test/prose-drafts.test.ts:1092. The 'stays in the transcript' assertion cannot fail. assert.ok(h.texts().some((t) => t === 'a postscript')) checks for text the scripted model response itself wrote into the assistant message. That text is in the transcript whether the routing posts it, holds it or keeps it, so the assertion proves nothing about the change. It should check something the change controls, such as the receipt's kept-private count.
  • Low severity, test coverage, test/prose-drafts.test.ts:1098-1346. The claim that XML tool mode is unchanged has no test. The PR, the changelog and the docs all say that in XML tool mode a drafts round is routed as before, and that an explicit-mode line beside drafts still bounces there. The harness already supports xml: true, but none of the new tests use it. A later change to routeRound or to turn-end delivery could start keeping these lines in XML mode, contradicting the documented behavior, and no test would fail.
  • Low severity, test coverage, test/prose-drafts.test.ts:1098-1346. No test that a failed delivery in a drafts round still bounces, or that a refused drafts call doesn't count. The description promises that a failed delivery beside a drafts call still bounces. No test covers that path (deliverWithUnsent failing, or an explicit sticky target whose publish fails) in a drafts round. No test covers a drafts call refused by allowedTools either, a case that currently behaves wrongly (see the draftsRound detection finding).
  • Low severity, maintainability, test/code-execution.test.ts:1209-1219. The serveWakes membrane patch is copied from prose-drafts.test.ts. The new serveWakes option wraps membrane.streamYielding and reaches into MockMembrane's private responses and responseIndex fields. That is a copy of the patch in test/prose-drafts.test.ts lines 290-297, and clock-rows.test.ts does something similar. Every copy relies on MockMembrane's private field names. A shared option or method on test/helpers/mock-membrane.ts would keep them in one place.

Comment thread src/framework.ts Outdated
Comment on lines +18542 to +18543
const draftsRound = calls.some((c) => bareToolName(c.name) === 'drafts');
const roundOpts: ProseRound = draftsRound ? { ...holdOpts, draftsRound } : holdOpts;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity. A refused drafts call still turns its round into a drafts round.

draftsRound checks only the tool name: calls.some((c) => bareToolName(c.name) === 'drafts'). The rest of the round's routing does not work that way. roundCallOf gives no cause to a call that allowedTools refuses ("Any other call the list refuses posted nothing, so it gives no cause"), and the same goes for a script call the bridge refuses before dispatch (refusedHere). Both kinds of call still appear in calls with name drafts. So an agent that may not use drafts, or a script call refused because its arguments weren't JSON or the host is stopping, still keeps its round's prose. In explicit mode the unprefixed line is then dropped without the bounce the agent needed. In a silenced locus or hybrid turn, words that should be held as a resendable draft are discarded. The check should skip refused calls, for example by using canAgentUseTool or by recording the refusal on the RoundCall.

…anima-research#701)

Once a turn has an explicit send, its later rounds' plain speech is held as
drafts, and each round's notice offers the new draft for resend. A resident
that writes a line beside each call therefore resent its own narration round
after round within one turn: each drafts resend posted the previous line and
held the one beside it (anima-research#701). In explicit mode the same unprefixed line
bounced into a draft.

In a round that calls drafts, with any action, words that would be held
because the turn is silenced, or bounced or held for lack of a destination,
now stay in the transcript and are counted as kept private. A silenced turn
holds envelopes too, so there a drafts round keeps all its words while its
envelopes still move the router. Outside a silenced turn, what the round
would deliver is delivered as before, a >> or >>> target and a sticky target
included, and a refused target, a malformed envelope or a failed delivery
still bounces, since each answers a deliberate send.

- routeRound: in a silenced locus or hybrid turn a drafts round goes to
  holdSilencedProse as 'drafts', which counts what it would hold after hybrid
  envelopes have moved the router. Otherwise the round is delivered as
  before, with draftsRound on its ProseRound, so deliverHybridProse's
  no-destination hold and deliverProseEnvelope's no-destination bounce keep
  and count the line instead.
- changesRouting counts drafts in explicit mode too, so a script's drafts
  call makes its round wait and is seen there as well. A drafts call the
  agent's allowedTools or the bridge refuses counts for nothing, as a refused
  send doesn't (RoundCall.permitted), and a script's calls are kept once per
  kind with permitted part of the kind.
- XML tool mode routes a turn's prose once at its end, with no notice
  mid-turn, so the loop can't form inside a turn there; its rounds are
  routed as before, and across turns explicit mode's bounce-wake cap bounds
  a cycle of drafts resends to two wakes.
- The receipt's kept-private note names a drafts call. The prose_help text,
  the docs and a changelog fragment state the rule, and that in explicit mode,
  while the turn has no destination, an unprefixed line beside a drafts call
  is kept with no bounce or resend offer.
- Tests: the resend test that pinned the old hold is turned round, and the
  script tests expect a drafts round's silenced words kept. New tests cover
  the narrated-resend cycle in all three modes, a line beside dismiss, a
  later round still held, a no-send turn delivered as before, a deliberate
  target delivered in hybrid and explicit mode, a hybrid line left without a
  destination by a refused target, an envelope that only names a
  destination, a silenced round's refused and malformed envelopes, its
  ' !' still starting a continuation and a skip_reply round's not, a refused
  target in an unsilenced round, an unprefixed line to an explicit sticky
  target, the explicit gateway's keep, delivery and refused-target bounce,
  an explicit-mode script's drafts call, a refused one before a permitted
  one in the same script, a refused drafts call, a failed
  delivery in a drafts round, and XML tool mode unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru force-pushed the fix/701-drafts-round-private branch from 1bc0fae to 2acf992 Compare October 23, 2026 02:02
@nissa-seru

Copy link
Copy Markdown
Contributor Author

@LariTesserae @antra-tess @Anarchid We're asking you to review this PR at 2acf9926. It answers LariTesserae's report in #701, where a drafts resend holds the line written beside it as the next draft to resend. It changes documented prose routing, so we're asking for your read on whether its trade is the one you want the library to make.

What it changes, in short:

  • The loop ends. In a round that calls drafts, words that main would hold because an explicit send silenced the turn, or would bounce or hold for lack of a destination, stay in the agent's own context instead of becoming a draft. In locus and hybrid mode the turn's receipt counts them. The tests run the narrated-resend cycle in all three modes, and it stops after one resend.
  • Deliveries are unchanged. What main delivers beside a drafts call is still delivered: a >> or >>> target, the turn's sticky target, and prose posted to the locus in a turn with no send. A drafts call the agent isn't allowed to use counts for nothing, and XML tool mode is unchanged.

The cost to an agent is that words beside a drafts call that main would have held or bounced can no longer be resent. In explicit mode, while the turn has no destination, an unprefixed line beside the call is kept with no bounce notice. In a turn silenced by a send, a deliberate envelope beside the call loses the resend offer main gives it, and main doesn't deliver it either. docs/explicit-prose-routing.md states the new rule under "The bounce".

The description lists the tests, and the alternatives from #701 that the PR leaves out: a new field on the draft record, and a cap on rounds per turn.

Written by Hamish-2699 (Claude Opus 5.5).

— Daniel-2612

One conflict, in test/prose-drafts.test.ts's ">>>skip_reply ... sticky
target again" test: main (anima-research#710) now pins a ChannelRef, { serverId, channelId },
and this branch changed the receipt's wording to "skip_reply, a drafts call or
a private think". The resolution keeps main's pin assertion and this branch's
receipt wording.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

@LariTesserae @antra-tess @Anarchid The head moved past 2acf9926 to 8103d0c4, a merge of main (8843507c), so we're asking for the review at 8103d0c4. The merge had one conflict, in a test: main now pins a sticky target as { serverId, channelId } (#710), and this branch changed the receipt's wording, so the merged test asserts main's pin and this branch's wording. Its other changes are main's own.

Written by Hamish-2699 (Claude Opus 5.5).

— Daniel-2612

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nightly dual review · round R1 at 8103d0c · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: CHANGES_REQUESTED (the formal review event is left to the maintainer). Finding ids R1-n are stable; cite them in fix commits.

Reviewed at 8103d0c against main 8843507 (the merge-base; main has since moved to 0aa9f52, onto which this branch merges cleanly)

Verdict: changes requested

The mechanics hold up, and the tests are thorough. The in-turn cycle from #701 ends in locus, hybrid and explicit mode. Deliveries outside a silenced turn are unchanged. A refused drafts call counts for nothing. The branch merges onto today's main with its tests green. The problem is the predicate. Whether words are kept is decided by which tool sits beside them, not by whether the words are narration. That reaches past the loop in two places. In a silenced hybrid turn, deliberate >>> envelopes are now dropped (R1-1). In explicit mode, real replies vanish with no signal (R1-2). The predicate also matches tools that aren't the framework's drafts (R1-3). The loop needs none of these. It is fed by the unaddressed line written beside each call.

This is round 1. There is no earlier review from us on this PR, so there are no dispositions to give.

Major

R1-1 · In a silenced hybrid turn, deliberate envelopes beside a drafts call are dropped. They aren't delivered, held, bounced or named.

  • Evidence: In holdSilencedProse (src/framework.ts:15031-15039), the 'drafts' branch counts every part instead of passing it to holdProseDrafts. That includes:
    • a resolved >>> target's words (pushed at :15012-15016);
    • a refused target's words (:15005-15006, then :15012);
    • a malformed envelope (:15018-15022).
  • The PR's tests pin this:
    • test/prose-drafts.test.ts:1198: >>>#room Kept words. after a send is neither published nor held.
    • :1233: a refused and a malformed envelope are kept.
    • :1250: >>>#general ! Kept words. gets its continuation turn, but its words go nowhere.
  • The inconsistency: In a turn with no send, the same refused target beside the same drafts call still bounces (deliverHybridProse, :16889-16893; test :1288). So whether the agent hears that >>>#nowhere was refused now depends on whether an earlier round of the turn called send_message. The agent doesn't track that state.
  • The promise it breaks: The hybrid primer tells the agent that for a >>> envelope "delivery or failure is reported back to you" (:601-604). Here the only trace is an anonymous count in the turn-end receipt, credited to "skip_reply, a drafts call or a private think".
  • Scenario: A hybrid resident calls send_message to #a, which silences the turn. Next round it answers the held-draft notice with drafts resend d-1 → #b, and writes beside it >>>#ops The migration finished at 14:02.
    • On main, that envelope is held as a draft "written for >>>#ops", with a notice and a ready resend.
    • On this branch it isn't sent, isn't held, and no notice says so. The receipt reads "1 plain-speech segment(s) kept private".
  • Not needed for #701: The PR's own rule for unsilenced rounds already says why: "a refused target, a malformed envelope and a failed delivery still bounce, since each answers a deliberate send". So does the alternative it turned down because it "would drop a deliberate >>#ops send beside a drafts call". Both apply word for word in a silenced turn.
  • Fix:
    • In the 'drafts' branch, keep only the plain-envelope parts (the arm at :15025-15026). Hold target, refused-target and malformed parts with holdProseDrafts, as main does.
    • If an addressed line beside every resend is a loop you want to guard against too, hold those parts with notice: 'later' (already on ProseRound). They stay resendable by id and the receipt names them, but nothing offers them mid-turn, and that offer is what drives the loop.
    • Update the three tests and the doc paragraph to match.

R1-2 · In explicit mode, an unprefixed line beside a drafts call vanishes with no signal, and nothing the agent normally sees says it will.

  • Evidence:
    • deliverProseEnvelope (src/framework.ts:17063-17071) increments keptPrivate and returns.
    • explicit mode writes no receipt; the PR's narrated-cycle test asserts receipt === undefined (test/prose-drafts.test.ts:1117 ff.). So the count reaches no one.
    • Explicit mode teaches by bouncing (docs/explicit-prose-routing.md:125-130; comment at src/framework.ts:609-614). The grammar is never injected, and a bounce is how the agent learns its text went nowhere.
    • The new rule is stated only in PROSE_ROUTING_HELP (:643-645), which reaches the agent only when it calls prose_help. That tool is optional per allowedTools; the primer mentions it only when it's allowed (:618). The rule is also in this repo's docs.
    • The drafts tool description (:9161-9174) is unchanged. It is the one surface every agent with the tool reads, and it still lists only the old reasons words are held.
    • An agent that has learned "undelivered text bounces" will take the missing bounce to mean the text went out.
  • Scenario: A new turn in explicit mode, with no sticky target, since nothing is implicit across turns. The resident tidies up after an earlier bounce and answers in the same round: drafts(action: "dismiss", …) plus an unprefixed "The deploy is green, merging now."
    • On main: a bounce notice, the line held as a draft, and >>#channel {{unsent}} offered.
    • On this branch: no delivery, no draft, no notice and no receipt.
    • Under R1-3, the same happens beside any tool whose name ends in --drafts.
  • Fix:
    • Preferred: keep the hold and drop only the in-turn re-offer for a drafts round. That could be a bounce that holds the draft without the "now" notice or wake, or at most one drafts-round bounce per turn. The words stay recoverable, the agent learns they weren't sent, and the loop has nothing to feed on.
    • If the current trade stays: state it where the agent will read it, in the drafts tool description. Also reconcile the doc's teach-by-bounce section with the new paragraph. Today they contradict each other.

R1-3 · The drafts-round check matches any tool whose bare name is drafts, not the framework's tool.

  • Evidence:
    • src/framework.ts:19101 tests bareToolName(c.name) === 'drafts'.
    • bareToolName is n.split('--').pop() (src/send-tools.ts:11).
    • MCPL and module tools are named ${prefix}--${tool.name} (src/framework.ts:27522, :27579).
    • The framework's own tool is dispatched only under the exact name drafts (:22455; :20795 for script calls).
  • Probe at this head: I added a throwaway test. In locus mode, after a send, a round has a line beside a call to mail--drafts. Result: no draft is held, and the receipt reads "1 plain-speech segment(s) kept private (skip_reply, a drafts call or a private think)". On main the line is held as an explicit-send draft. In explicit mode, this feeds R1-2.
  • The script scanner over-matches the same way:
    • routingToolsNamed (:331-342) reads drafts out of tools["mail--drafts"], out of comments and out of strings.
    • changesRouting now counts drafts in explicit mode (:317). So an explicit round beside such a script waits for it.
    • A deliberate >>#channel envelope beside that script now goes out only when the script returns or yields, where main sent it at once.
    • If the turn is replaced or stopped first, the envelope is withheld (:19167-19171, :19191-19204).
  • Fix:
    • Use c.name === 'drafts' at :19101, the same test dispatch uses.
    • Make the same change at isSilencingCall (:273), whose resend check has had this over-match since before this PR.
    • Add a regression test with an MCPL tool named <prefix>--drafts.
    • The scanner can stay coarse, but the description should say that explicit-mode envelopes beside a script that mentions drafts now wait for it.

Minor

R1-4 · The explicit-mode XML claims have no test. The changelog and doc make two claims about explicit mode in XML tool mode:

  • a line beside a drafts call "still bounces";
  • the bounce-wake cap "ends a cycle of drafts resends after two wakes".

The only XML test (test/prose-drafts.test.ts:1316) uses the harness's default agent. That agent has no proseRouting (:298-301), so it routes in locus mode and checks a held explicit-send draft. Neither claim is covered. By reading the code, both hold: a drafts resend doesn't reset proseBounceStreaks, which resets only at :14800, :16916 and :17097. Fix: add an explicit + xml case: a line beside a resend bounces, and the cycle stops after two wakes.

R1-5 · Nit: the kept count re-parses parsed text. :15039 strips a leading >>> from part texts, but target parts hold already-parsed bodies. A valid >>>#room >>> parses to body >>> and is not counted. Carry a malformed flag on the part instead of re-reading its text.

What this doesn't touch (scope notes, not findings)

  • The stub-posting half of #701 remains. In a silenced turn, narration beside any tool other than drafts is still held and offered every round; the PR's test at test/prose-drafts.test.ts:1372 pins this. A narrating resident in a long silenced turn still gets one held line and one offered resend per work round. Each resend posts a stale line and costs a full-context round. The cycle is now bounded by the number of work rounds rather than unbounded. Whether that closes #701 is for the maintainers to decide. #701's suggested per-turn cap is still the general bound.
  • XML tool mode is untouched, as the PR states.
  • Current main (0aa9f52): Its per-round roundPublished change doesn't interact with this patch. The routing region is otherwise identical.

Verified fine

  • Builds and tests:
    • At this head: npm run build is clean, and the three touched test files pass 313/313 (106 + 207, matching the description).
    • On a scratch merge with current main, with context-manager 0.22.0, they pass 321/321.
  • Red-first claim: With the base src/framework.ts under this branch's tests, 13/106 fail, and 5/98 + 1/109 = 6/207 fail. This matches the description exactly.
  • A refused drafts call counts for nothing:
    • permitted comes from roundCallOf (:22280-22283).
    • Script calls are deduplicated with permitted as part of the key (:21601), so a refused call doesn't hide a later permitted one.
    • The earlier automated review's medium finding about this was fixed in 2acf992.
  • Precedence: skip_reply, a same-round private think and a silent turn still outrank 'drafts' (:14956, :19113, :19150).
  • Router state in a silenced hybrid drafts round: the router still moves, pin and hold included, and ! is still honoured (:14991, :14997-15007).
  • Outside a silenced turn, targets, sticky targets and locus delivery are reached before the new branches (:16887-16951, :17041-17076). Failed deliveries still bounce (:17099-17105; explicit test).
  • XML mode: a round is live only with roundContent (:19028), and routeRound returns before any drafts logic (:19103).
  • Inherited, not raised: The script scanner can't see a tool name built at runtime. That is documented at :338-341 and applies to every routing tool, so it isn't this PR's to fix. The changelog's "a drafts call made from a script too" inherits that blind spot.

Method: two independent passes, merged after re-checking each claim against this head. Codex gpt-5.6-sol (reasoning effort xhigh, read-only sandbox) ran for about 9 minutes and about 80 shell commands, using about 5.0M input tokens (4.6M cached) and 23k output tokens. It could typecheck but not run tests: the test harnesses create temp directories, and its sandbox is read-only. Claude Opus 5.5 ran the builds, the touched tests at this head and on a scratch merge with current main, the red-first check against the base framework.ts, and the mail--drafts probe. Found by both: R1-3. Codex only: R1-4 and R1-5. Codex also raised the scanner's runtime-name blind spot; it was set aside as inherited and documented. Claude only: R1-1, R1-2 and the scope notes.

Hamish-2699 and others added 3 commits October 25, 2026 00:18
… or a silencing resend (R1-3)

The drafts-round check and isSilencingCall's resend check read the call's
bare name, so a module's or server's tool named `<prefix>--drafts` counted
as the framework's drafts tool: a line beside it was kept rather than held,
and a resend through it silenced its round. The framework dispatches its own
tool only under the exact name `drafts`, so both checks now test that.
isSilencingCall's over-match predates anima-research#701's change.

Tests: after a send, a line beside `mail--drafts` is held as any silenced
round's is; a `mail--drafts` resend doesn't silence its round.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o notice, rather than dropping it (R1-1, R1-2, R1-4, R1-5)

A round that calls `drafts` kept in the context what it would hold or
bounce for lack of a destination, so its words went nowhere: in a
silenced hybrid turn, a deliberate `>>>` envelope beside the call, a
refused or malformed one included (R1-1), and in explicit mode an
unprefixed line beside it, with no bounce, receipt or notice to say so
(R1-2). What the anima-research#701 cycle needs is only that nothing offers such a line
back for resend mid-turn. So a drafts round now holds what it would hold
or bounce as a draft with notice 'later': listed by `drafts`, resendable
by id, named in the turn's receipt or the next turn's catch-up notice,
and offered by no notice while the turn runs. An explicit-mode line is
held as a bounce would hold it, `{{unsent}}` expanded and a bare one
taken as the latest bounce itself; if the drafts journal can't hold it,
it bounces as without a drafts call, since explicit mode writes no receipt
to report the failure. In a turn a send silenced, the round's
plain speech is still kept and counted, since that narration is what the
cycle resends; what its envelopes addressed is held. The kept count now
counts plain parts by a flag rather than re-reading their text (R1-5).

A no-destination draft's cause, as `drafts` `read` and a held-draft
notice give it, no longer says a routing envelope failed, since such a
draft is now often a line beside a drafts call, and was already one main
held for lack of a locus or sticky target.

prose_help says the rule, and the docs' teach-by-bounce section names the
one place a line doesn't bounce and how the agent is told of it there.
The `drafts` tool's description is unchanged: the agent meets the rule in
what calling gives back, the receipt, the catch-up notice and the drafts
listing.

Tests turned round: the silenced hybrid drafts round holds a target's, a
refused target's and a malformed envelope's words; an explicit or hybrid
line left with no destination beside a drafts call is held as
no-destination; a ' !' still asks for a continuation, with its words
held. The narrated anima-research#701 cycle still ends after one resend in all three
modes, its scripted resident answering only drafts a notice offered: in
explicit mode the line is held, not posted, and the next turn's catch-up
notice names it. New: an explicit line's `{{unsent}}` expands as in a
bounce, and one the journal can't hold bounces; a script whose only
drafts call the bridge refuses makes no
drafts round; and (R1-4) in XML tool mode, explicit mode, a line beside a
resend bounces and the bounce-wake cap ends the cycle after two wakes.

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

Copy link
Copy Markdown
Contributor Author

@Anarchid Addressed in 8103d0c4..ffb00ac7, which also merges main at 0aa9f525.

  • R1-1: taken. In a silenced hybrid turn, a drafts round now keeps only its plain speech private. What a >>> envelope addressed, a refused target's and a malformed envelope's included, is held as main holds it, but with no notice mid-turn: it's an open draft, resendable by id and named in the turn's receipt. The three tests you named now assert that the words are held.
  • R1-2: taken in your preferred form: the hold stays, and only the in-turn offer goes. In explicit mode, an unprefixed line beside a drafts call with no destination is held as a no-destination draft, with no notice and no wake, and the next turn's catch-up notice names it. If the drafts journal can't hold it, it bounces as without a drafts call, so the failure is reported. We ran your second form, one bounce per turn, against the narrating agent: it resent its own bounce, so a narration line was posted. The prose_help reference states the rule, and the doc's section on learning by bounce names this case. The drafts tool's description stays as it is. The agent meets the rule where the held line shows up: the receipt, the catch-up notice and the drafts listing.
  • R1-3: taken as you said. The drafts-round check and isSilencingCall both test the exact name drafts, with a mail--drafts test of each. The description says that in explicit mode an envelope beside a script that mentions drafts waits for the script.
  • R1-4: added. In XML tool mode, explicit mode, a line beside a resend bounces, and the bounce-wake cap stops the cycle after two wakes.
  • R1-5: the kept count now reads a flag on each part instead of re-reading its text.
  • The stub-posting half of drafts: a resend holds the prose written beside it as the next draft — an unbounded loop with a narrating model #701: agreed that it remains. The description no longer closes drafts: a resend holds the prose written beside it as the next draft — an unbounded loop with a narrating model #701 and says what remains, so closing it is yours to decide.

Written by Hamish-2699 (Claude Opus 5.5). Ada-1017 (Claude Opus 5.5) made the design calls, and Ezra-2423 (Claude Opus 5.5) reviewed the changes.

— Daniel-2612

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nightly dual review · round R2 at ffb00ac · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: APPROVE (the formal review event is left to the maintainer). Finding ids R2-n are stable; cite them in fix commits.

Reviewed at ffb00ac against main 0a4a2c6 (the branch merged main at 0aa9f52; it merges cleanly onto current main, and its tests pass there)

Nightly dual review · round R2 at ffb00ac · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: APPROVE (the formal review event is left to the maintainer). Finding ids R2-n are stable; cite them in fix commits.

Verdict: approve, with two minors

All five R1 findings are fixed. Deliberate envelopes in a silenced hybrid turn are held again. An explicit-mode line with no destination is held and named later instead of vanishing. Only the framework's own drafts counts. The in-turn cycle from #701 still ends in all three modes. The branch builds and passes its tests at this head, and also on a scratch merge with today's main, where the full suite is green. What's left is narrow:

  • In explicit mode, the PR promises the next turn's catch-up names the held line, but a race can push that to the turn after (R2-1).
  • A drafts call in the same round as a send now cancels that send's own hold (R2-2).

Neither loses words, and neither is needed for the fix.

R1 dispositions

  • R1-1 (silenced hybrid drafts round dropped envelopes): fixed. holdSilencedProse flags target, refused-target and malformed parts as deliberate (src/framework.ts:15808-15848). In a drafts round it holds them with notice: 'later' and counts only plain parts as kept (:15857-15866). The three tests R1 named now assert the words are held and named only in the receipt (test/prose-drafts.test.ts, "after a send, a drafts round's envelopes are held with no notice…", "…refused and malformed envelopes are held…", "…its ' !' starts a continuation turn").
  • R1-2 (explicit-mode line beside drafts vanished): fixed, in the form R1 preferred. With no sticky target, deliverProseEnvelope reads the words as a bounce would (unsentWords) and holds them as a no-destination draft with no notice and no wake. It bounces only if the journal refuses the hold (:18129-18154). Turn settlement records only drafts a stored message names (settleTurnDrafts, :16169-16176), so the next turn's catch-up (noticeUnnoticedDrafts, called at :19119) names it. A probe confirms the shape: a fresh explicit turn with an unprefixed reply beside drafts list gets a bounce and a wake on main (2 inference calls). Here it gets a held no-destination draft, no notice and 1 call. Leaving the drafts tool description unchanged is fine now that the words are held; R1 asked for that only if they vanished. A race in this path is R2-1.
  • R1-3 (<prefix>--drafts matched): fixed. isSilencingCall and the drafts-round predicate both test call.name === 'drafts' (:285, :20353), with a mail--drafts test of each. The description now says that in explicit mode an envelope beside a script that mentions drafts waits for the script.
  • R1-4 (no explicit + XML test): fixed. The new test runs an explicit-mode resident in XML tool mode. A line beside each resend bounces, and the bounce-wake cap stops after two wakes.
  • R1-5 (kept count re-parsed text): fixed. The count reads the deliberate flag (:15865).

Minor

R2-1 · In explicit mode, a drafts-round line held behind a slow delivery isn't named by the next turn's catch-up, only by the turn after.

  • Evidence:
    • The hold runs on the agent's speech chain: the delivery is queued at src/framework.ts:20391-20393 and holds at :18129-18143.
    • That chain spans turns (:19762-19770), and an explicit turn that a tool ends doesn't wait for it (:20031-20038).
    • The next turn's catch-up runs once, at its start (:19119), and sees only drafts already held (:16074-16099).
    • A 'later' hold writes no row of its own (holdProseDrafts, :15922).
  • Probe at this head (built on the withQueuedTurns pattern in test/explicit-prose-routing.test.ts):
    • Turn 1 posts >>#alpha first with the publish held open, then ends with skip_reply.
    • Turn 2 writes an unprefixed line beside drafts list, then ends with skip_reply.
    • Turn 3 starts while the publish is still held. Then the publish is released.
    • Result here: the line is held as no-destination only after turn 3's catch-up has run, and no message names it until turn 4's catch-up.
    • On main, same race: the late line bounces when the chain moves on, and its bounce notice names it right away (with a wake).
  • Claims it contradicts:
    • changelog.d/701-drafts-round-private.fixed.md:4-5: "the next turn's catch-up notice (in explicit mode) names it".
    • docs/explicit-prose-routing.md:73 and :144.
    • The comment at src/framework.ts:18132.
    • PROSE_ROUTING_HELP (:704) says "a later turn's catch-up notice", which is accurate.
  • Why it's minor, not major: nothing is lost. The line stays held, drafts lists it, and a later catch-up names it. But in explicit mode that catch-up is the agent's only signal that the line wasn't sent. The window opens exactly when a channel is busy: the next message is queued while a publish is still in flight. In that case the signal comes one turn later than the PR says.
  • Fix:
    • Either, when a drafts-round 'later' hold lands after its turn has settled, append then the same ID-only row the catch-up writes (no wake, no resend offer). That is what a late bounce gets on main.
    • Or say "a later turn's catch-up" in the changelog, the doc and the comment.
    • Either way, add a withQueuedTurns-style test with a held earlier publish, a drafts round and a turn-ending call.

R2-2 · In locus and hybrid mode, a drafts call in the same round as a send cancels that send's own hold.

  • Evidence:
    • draftsRound is true for any permitted drafts call in the round (src/framework.ts:20353).
    • The round's own send silences it (:20331-20335), so its cause becomes 'drafts' (:20365, :20403).
    • holdSilencedProse then keeps every plain part: all segments in locus mode (:15779, :15790), and the non-deliberate parts in hybrid mode (:15865).
  • Probe, against this head and main's framework.ts: one round with "A reply meant for the room.", a channel_publish call and drafts list.
    • On main, in both modes, the paragraph is held as an explicit-send draft, and a notice offers a resend.
    • Here there is no draft. The turn-end receipt reads "1 plain-speech segment(s) kept private (skip_reply, a drafts call or a private think)".
  • The same class, sticky target: in a silenced hybrid turn, an unprefixed line that follows a held >>>#general target from an earlier round is held on main and kept here.
  • Why it matters:
    • The held-draft feature exists for exactly the paragraph written beside a send. A resident that has just been told a draft is held often tidies its drafts in the same round that it sends.
    • Holding that paragraph doesn't feed #701's loop. On main it's offered once. The resend round that follows has no other send, so the narration beside that resend is kept.
    • No test covers a send and a drafts call in one round, and the description's "what an agent meets" doesn't mention this case.
  • Fix:
    • Take the 'drafts' cause only when the round makes no permitted send of its own besides drafts: draftsRound && !calls.some((c) => c.permitted && c.cause === 'send' && c.name !== 'drafts').
    • If you'd rather stay loop-proof even against an agent that sends every round, hold those parts with notice: 'later', as R1-1's envelopes now are. The same treatment fits a plain part written while routing.pin is set.
    • Add a test. If the current behaviour is intended, say so in the changelog.

What this doesn't touch (scope notes, not findings)

  • The other half of #701 remains, as the description says. In a silenced turn, narration beside a tool other than drafts is still held and offered every round, and a test pins that. The cost is now bounded by work rounds per turn. A per-turn cap is still the general bound. The description no longer closes #701, which is right.
  • Across turns in explicit mode, the catch-up notice names a held line and points at drafts to resend or dismiss it. An agent that resends it with a fresh line beside the call posts one narration line per turn and holds the next. The cycle is bounded by turns: external wakes, or autoContinue's maxConsecutive. The held line doesn't set lastEnvelopeBounced, so an autoContinue agent does continue. This is the description's "resends its newest stored draft" case, prompted by a notice. Nothing needs to change here; it's noted so nobody rediscovers it.
  • The coarse script scanner in explicit mode: a script whose code mentions drafts anywhere now holds the round's deliberate >> envelopes until it returns or yields. This is documented in the description and inherited from the scanner's design.

Verified fine

  • Builds and tests:
    • At this head: npm run build is clean, and test/prose-drafts.test.ts, test/explicit-prose-routing.test.ts and test/code-execution.test.ts pass 327/327.
    • On a scratch merge with main at 0a4a2c69 (context-manager 0.22.1, membrane 0.7.2, chronicle 0.4.0): the build is clean, and the same three files pass 327/327. npm test runs 7,600 tests: 7,554 pass, 46 are skipped and none fail.
    • main's changes since the merge-base don't touch holdSilencedProse, holdProseDrafts, bounceProse, deliverProseEnvelope, deliverHybridProse, roundCallOf or isSilencingCall.
  • Red-first claim: with the merge-base's src/framework.ts under this branch's tests, 20 of 327 fail, as the description says.
  • Precedence: skip_reply, a silent turn and a same-round private think still outrank a drafts round. Both call sites keep 'private' (:20365, :20403), and the private branch of holdSilencedProse comes first (:15779). The explicit keepPrivate check precedes the new branch (:18092).
  • locus mode never reaches the envelope parser. Its silenced drafts round takes the private branch, because turnProseRouting equals agent.proseRouting whenever the turn isn't disabled.
  • deliverHybridProse: merging the two no-destination branches into one is behaviour-preserving, and 'later' applies only in a drafts round (:18007-18013).
  • {{unsent}} and the journal fallback: a bare {{unsent}} holds nothing new, and the token expands as in a bounce. A refused hold falls back to the ordinary bounce. The double draftFailures count only feeds a receipt, and explicit mode writes none.
  • Refused calls and scripts: permitted comes from roundCallOf (:23748-23751). The script-call dedupe key includes it (:22959-22960), so a refused call doesn't hide a later permitted one.
  • Bounce-wake cap: a drafts resend doesn't reset proseBounceStreaks. It resets only on a turn with a cause of its own (:15589) or a delivered envelope (:17978, :18180). That matches the doc's "counted from the last turn with a cause of its own or the last delivered envelope".
  • The new no-destination cause text ("it had no destination: plain speech with no target waits for one") is accurate for every place that holds that reason.

Method: two independent passes, merged after re-checking each claim against this head. Codex gpt-5.6-sol (reasoning effort xhigh, read-only sandbox) ran for about 8.5 minutes and 70 shell commands, using about 7.2M input tokens (6.9M cached) and 27k output tokens. It could neither run tests nor typecheck: the sandbox is read-only, and the composite project writes tsbuildinfo even under --noEmit. For about a minute of its run, the worktree it read was switched to the scratch merge by mistake; every Sol citation above was re-checked against this head and matches. Claude Opus 5.5 ran the builds, the touched tests at this head and on a scratch merge with current main, the full suite on that merge, the red-first check, and four throwaway probes against both this head and the merge-base's framework.ts: the same-round send, the sticky continuation, a fresh explicit line, and the R2-1 race. Found by both: the R1-1, R1-3, R1-4 and R1-5 dispositions. On R1-2, Sol called it partial because of the race; Claude calls it fixed and files the race as R2-1. Sol only: R2-1, rated major by Sol and reproduced and downgraded to minor by Claude, since nothing is lost and the delay is one turn. Claude only: R2-2 and the scope notes.

Hamish-2699 and others added 3 commits October 25, 2026 23:30
Removes the explanations this change had added beside the drafts round,
in src/framework.ts and the touched tests, and reverts its additions to
existing comments to their minimal factual form. A comment stays only
where it is a directive, a public API contract, behaviour an outside
dependency forces, or a link that explains a constraint: the drafts-round
check keeps a link to anima-research#701, since nothing else in the code says why a
drafts call changes how its round holds. Existing comments the change
made untrue are corrected in place without adding reasons.

What the removed comments carried, kept here:
- isSilencingCall and the drafts-round check test the exact name `drafts`,
  the name the framework dispatches its own tool under; a module's or
  server's `<prefix>--drafts` is another tool.
- A drafts round holds with notice 'later' what it would hold or bounce for
  lack of a destination: offered mid-turn, such a line would be resent, and
  an agent that writes a line beside each call would resend its narration
  round after round (anima-research#701). In a silenced turn its plain speech is kept and
  counted, and what an envelope addressed is held with no notice.
- An explicit-mode line beside a drafts call is held as a bounce would hold
  it (unsentWords); if the journal refuses the hold, it bounces, since
  explicit mode writes no receipt to report the failure.
- A refused drafts call counts for nothing, as a refused send doesn't: its
  agent may have no other way back to its words than the bounce.
- XML tool mode routes a turn's prose once at its end with no notice
  mid-turn, so the loop can't form inside a turn there.

No behaviour changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd does; a later turn's catch-up names an explicit held line (R2-1, R2-2)

R2-2: in locus and hybrid mode, a drafts call in the same round as a send
gave the round the 'drafts' cause, so the send's own round kept its prose
instead of holding it, and the notice main gives for the paragraph written
beside a send was gone. The 'drafts' cause now applies only when the round
makes no permitted send besides a drafts resend. The resend round that
follows has no other send, so its narration is still kept, and anima-research#701's
loop stays closed.

R2-1: in explicit mode, a drafts-round line whose hold waits behind a
publish still in flight when a tool ends the turn is held after the next
turn has started, so its catch-up is a later turn's, not the next one's.
The changelog fragment and the doc now say "a later turn's catch-up".

Tests: in locus and hybrid mode, a round with a send and a drafts list
holds the paragraph beside the send and a notice offers it; after such a
round, a resident that resends what is offered posts the paragraph once,
its narration is kept, and the cycle ends. In explicit mode, a drafts-round
line held behind a publish in flight past a tool-ended turn doesn't bounce,
is held as no-destination, and a later turn's catch-up names it.

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

Copy link
Copy Markdown
Contributor Author

Replying to this review.

@Anarchid Addressed in ffb00ac7..992448ca, which also merges main at 0a4a2c69:

  • R2-1: the changelog fragment, the doc and the description now say a later turn's catch-up names the line, and the comment you named went with the trim below. A test runs your race: a drafts-round line held behind a publish still in flight as a tool ends the turn doesn't bounce, is held as no-destination, and a later turn's catch-up names it.
  • R2-2: taken as you suggested. The 'drafts' cause applies only when the round makes no permitted send besides a drafts resend. Tests in locus and hybrid mode show the paragraph beside the send held and offered, and that after such a round the resend round's narration is neither posted nor held, so the cycle ends. The sticky-target case stays as built: holding plain speech under a sticky target with no notice wouldn't re-offer it mid-turn, but it would hold a narration draft for every resend round, and by our reading an agent that resends its newest stored draft would loop on them. The description names it.

The round also trims the comments this PR had added to the ones the code needs, with their reasons moved into the commit message.

Written by Hamish-2699 (Claude Opus 5.5). Ada-1017 (Claude Opus 5.5) made the design calls, and Ezra-2423 (Claude Opus 5.5) reviewed the changes.

— Daniel-2612

@nissa-seru
nissa-seru merged commit 896314d into anima-research:main Oct 26, 2026
12 checks passed
nissa-seru pushed a commit to rufalupa666-netizen/agent-framework that referenced this pull request Oct 26, 2026
main moved 112 commits past 0.27.0. Two of them meet this branch's lines:
anima-research#716 (an unprefixed line in a round that calls the drafts tool is held as a
draft) and anima-research#804 (a tool call membrane marks unparseable doesn't run). Nine
blocks conflicted, eight in src/framework.ts and one in
test/code-execution.test.ts. Each keeps both sides:

- The explicit-mode routing help keeps main's drafts-round sentence and this
  branch's private-turn sentence.
- The delivery receipt keeps main's "skip_reply, a drafts call or a private
  think" line and this branch's private-turn line.
- Hybrid speech with no destination: a private turn's is kept private first,
  as before (a private turn's plain speech is never drafted). Main's hold of a
  draft then takes what's left, a failed envelope's hold. Since every caller
  here passes allowLocus as "not private", main's "!allowLocus && !sticky"
  can't reach the hold once the private branch has taken it, so that half of
  main's condition is left out.
- The round's calls are main's `calls`, judged with this branch's private-turn
  argument.
- Mid-turn hybrid delivery takes main's round options (the drafts round) and
  this branch's allowLocus.
- A silenced round takes main's drafts cause, and this branch's private-turn
  branch follows it as before.
- The XML fallback's filter and roundCallOf each refuse a marked unparseable
  call (main) and a private turn's channel_publish with no channel (this
  branch).
- The code-execution test helper's options take both channelPublish and
  serveWakes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to rufalupa666-netizen/agent-framework that referenced this pull request Oct 26, 2026
The rule that a private turn's words are never held as a draft is tested
for an envelope that names no destination or a channel that doesn't
resolve. These add the other ways its words can meet a hold:

- an envelope to a channel that resolves, whose delivery fails, in hybrid
  and in explicit mode, holds no draft;
- a bare `{{unsent}}` resend whose delivery fails keeps the draft it sent,
  and the notice offers it again, as in any turn, since its words aren't
  the private turn's;
- a round that calls the drafts tool, which holds an unprefixed line with
  no destination as a draft in a channel turn (anima-research#716), holds none of a
  private turn's words, in hybrid after a failed envelope and in explicit
  mode.

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.

2 participants