Skip to content

fix(prose): failed sends release held prose; opt-in round-scoped silencing - #177

Open
ian-de-marcellus wants to merge 1 commit into
anima-research:mainfrom
ian-de-marcellus:fix/failed-send-releases-prose
Open

ian-de-marcellus wants to merge 1 commit into
anima-research:mainfrom
ian-de-marcellus:fix/failed-send-releases-prose

Conversation

@ian-de-marcellus

Copy link
Copy Markdown
Contributor

Problem. An explicit send (send_message, channel_publish, …) silences the turn's auto-routed prose from that round on, to avoid a redundant "sent it" postscript. Two failure modes showed up in production:

  1. A failed send still silenced the round. When the send failed (e.g. a Discord connect timeout), the prose was suppressed anyway, so nothing reached the room: a question answered with dead air.
  2. An early send discarded the turn's closing prose. In long tool-using turns, a single send_message early on meant the final answer, often the longest and most careful part, was never delivered.

Fix.

  • A round whose silencing sends all failed releases its held prose to the locus and lifts the silence. The per-round tool error map is recorded when results are handed back, and the release happens at the next tool-calls or complete. skip_reply still silences deliberately.
  • New opt-in AgentConfig.proseSilencing: 'turn' | 'round'. 'round' scopes a send's silencing to its own round; later rounds and the final prose deliver normally. The default 'turn' is unchanged.

Tests. test/present-while-acting.test.ts: a failed send releases held prose and lifts the silence; 'round' silences only the send's own round; the existing forward-only silencing behavior is unchanged. Full suite: 956 pass / 0 fail.

🤖 Generated with Claude Code

…encing

- A round silenced by an explicit send (send_message, channel_publish, ...)
  now holds its prose; if every send in that round FAILS, the silence is
  lifted and the held prose is delivered. Previously a failed send still
  counted as the turn's delivery, so the resident's reply was suppressed and
  nothing reached the room (Fable, 2026-09-22).
- AgentConfig.proseSilencing: 'turn' (default, unchanged) | 'round'. In
  'round' a send silences only its own round; later rounds and the final
  prose deliver normally. For long tool-using turns (e.g. with proseDelivery
  'terminal') where an early send discarded the turn's closing answer
  (Librarian's report: the loss selects against the longest work).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 0/5 Tier: apex

[Medium risk] Adds optional prose silencing scope to agent configuration.

The PR is not safe to merge while failed sends can lose or misroute prose and round-scoped silencing can suppress a final answer.

Findings

  1. P1 Security Held prose reaches another channel ▶
  2. P1 Last send suppresses final answer ▶
  3. P1 Send-only failures still silence replies ▶
  4. P1 Hybrid failed sends remain silent ▶
  5. P1 Later failure clears successful silence ▶
  6. P1 Restart drops held prose ▶
Fix with agent prompt
### Issue 1
src/framework.ts:8548
When a send fails while the agent is replying in channel A, an addressed message from channel B can change the routing pin before this release runs. The prose written for A is then published to B. Preserve the held round’s destination instead of reading the current pin.

**How this was verified:** Addressed messages can change the pin before failed-send results resume the stream, and speech routing publishes the held text to the pin read here.

### Issue 2
src/framework.ts:9271-9273
With `proseSilencing: 'round'`, a successful send in the last tool round leaves `turnSilenced` true. If the next event is a text-only completion, this check suppresses the closing answer even though it belongs to a later round. The new test places a non-send tool round between the send and the answer, so it misses this case.

### Issue 3
src/framework.ts:8539-8540
A failed send with no prose in its own tool round creates no hold, so this return never clears default turn-scoped silencing. If the agent writes its answer at completion, that answer is suppressed too, leaving the failed-send dead-air case unresolved.

### Issue 4
src/framework.ts:8857-8858
With `proseRouting: 'hybrid'` and default turn-scoped silencing, a failed send suppresses the round’s prose in the hybrid branch, but only this locus branch creates a hold. Nothing then releases that prose or clears the sticky silence, so later prose is suppressed as well.

### Issue 5
src/framework.ts:8546-8547
In the default turn-scoped mode, an earlier successful send should keep the rest of the turn silent. If a later send fails, this code checks only that later round, releases its held prose, and clears the turn-wide flag. The result is prose posted after a send that already succeeded.

### Issue 6
src/framework.ts:8537
A context-budget or physical-window restart can cancel the stream after a failed send round. The restart skips result recording and the release calls, while this hold exists only in the cancelled stream’s local state. The turn continues in a new stream, but the held prose is permanently lost.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

The PR adds failed-send prose release and an opt-in round-scoped silencing setting.

  • A successful send in the last tool round still suppresses the final answer in round mode.
  • Failed-send release misses send-only rounds, hybrid routing, and budget restarts; fallback routing can publish prose from a silenced round.
  • Release can also route earlier prose to a newly addressed channel.

Reviews (1) · Last reviewed commit: "feat(prose): failed sends release held p..."

Comment thread src/framework.ts
heldSilence = null;
if (!hold.callIds.every((id) => errors!.get(id) === true)) return; // a send landed
turnSilenced = false;
const locus = resolveTurnLocus();

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 security Held prose reaches another channel When a send fails while the agent is replying in channel A, an addressed message from channel B can change the routing pin before this release runs. The prose written for A is then published to B. Preserve the held round’s destination instead of reading the current pin.

How this was verified: Addressed messages can change the pin before failed-send results resume the stream, and speech routing publishes the held text to the pin read here.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 8548

Comment:
**Held prose reaches another channel** When a send fails while the agent is replying in channel A, an addressed message from channel B can change the routing pin before this release runs. The prose written for A is then published to B. Preserve the held round’s destination instead of reading the current pin.

**How this was verified:** Addressed messages can change the pin before failed-send results resume the stream, and speech routing publishes the held text to the pin read here.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/framework.ts
Comment on lines +9271 to 9273
const silenced = liveProseRouting || roundScopedSilencing
? turnSilenced
: turnSilenced || toolNames.some(isSilencingTool);

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 Last send suppresses final answer With proseSilencing: 'round', a successful send in the last tool round leaves turnSilenced true. If the next event is a text-only completion, this check suppresses the closing answer even though it belongs to a later round. The new test places a non-send tool round between the send and the answer, so it misses this case.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 9271-9273

Comment:
**Last send suppresses final answer** With `proseSilencing: 'round'`, a successful send in the last tool round leaves `turnSilenced` true. If the next event is a text-only completion, this check suppresses the closing answer even though it belongs to a later round. The new test places a non-send tool round between the send and the answer, so it misses this case.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/framework.ts
Comment on lines +8539 to +8540
const releaseFailedSilence = (): void => {
if (!heldSilence) return;

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 Send-only failures still silence replies A failed send with no prose in its own tool round creates no hold, so this return never clears default turn-scoped silencing. If the agent writes its answer at completion, that answer is suppressed too, leaving the failed-send dead-air case unresolved.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 8539-8540

Comment:
**Send-only failures still silence replies** A failed send with no prose in its own tool round creates no hold, so this return never clears default turn-scoped silencing. If the agent writes its answer at completion, that answer is suppressed too, leaving the failed-send dead-air case unresolved.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/framework.ts
Comment on lines +8857 to +8858
const sendIds = event.calls.filter((c) => isSilencingTool(c.name) && c.name !== 'skip_reply').map((c) => c.id);
if (sendIds.length > 0) heldSilence = { callIds: sendIds, segments: roundSegments.map(String) };

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 Hybrid failed sends remain silent With proseRouting: 'hybrid' and default turn-scoped silencing, a failed send suppresses the round’s prose in the hybrid branch, but only this locus branch creates a hold. Nothing then releases that prose or clears the sticky silence, so later prose is suppressed as well.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 8857-8858

Comment:
**Hybrid failed sends remain silent** With `proseRouting: 'hybrid'` and default turn-scoped silencing, a failed send suppresses the round’s prose in the hybrid branch, but only this locus branch creates a hold. Nothing then releases that prose or clears the sticky silence, so later prose is suppressed as well.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/framework.ts
Comment on lines +8546 to +8547
if (!hold.callIds.every((id) => errors!.get(id) === true)) return; // a send landed
turnSilenced = false;

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 Later failure clears successful silence In the default turn-scoped mode, an earlier successful send should keep the rest of the turn silent. If a later send fails, this code checks only that later round, releases its held prose, and clears the turn-wide flag. The result is prose posted after a send that already succeeded.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 8546-8547

Comment:
**Later failure clears successful silence** In the default turn-scoped mode, an earlier successful send should keep the rest of the turn silent. If a later send fails, this code checks only that later round, releases its held prose, and clears the turn-wide flag. The result is prose posted after a send that already succeeded.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/framework.ts
// Prose held because its round contained a send. If every send in that
// round then FAILS, the round did not actually speak: release the prose
// and lift the silence (a failed send must not cost the turn its words).
let heldSilence: { callIds: string[]; segments: string[] } | null = null;

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 Restart drops held prose A context-budget or physical-window restart can cancel the stream after a failed send round. The restart skips result recording and the release calls, while this hold exists only in the cancelled stream’s local state. The turn continues in a new stream, but the held prose is permanently lost.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 8537

Comment:
**Restart drops held prose** A context-budget or physical-window restart can cancel the stream after a failed send round. The restart skips result recording and the release calls, while this hold exists only in the cancelled stream’s local state. The turn continues in a new stream, but the held prose is permanently lost.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

@greptile-apps

greptile-apps Bot commented Sep 28, 2026

Copy link
Copy Markdown

Comments Outside Diff

These findings sit on lines the diff does not cover, so they could not be posted inline. Each one leaves this list once its file changes.

  • P1 Fallback publishes silenced prose src/framework.ts:9275 ▶

    Without roundContent, prose is routed from the accumulated response at completion. In round-scoped mode, a non-send tool round after a successful send resets turnSilenced to false. This check then publishes prose from the earlier send round as well, although that round was meant to be silent.

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

Reviewed at head ec87bcb, trial-merged onto current main (5498805): zero conflicts. Head suite 960/956/0/4 + tsc clean; merged state 973/969/0/4 + tsc clean. The mechanism was verified against the real dispatch paths: the failure detection matches both MCPL result shapes (isError: result.isError ?? false on the result path, {success:false, isError:true} on the throw path — including the connect-timeout case this PR was written for), the round error map's timing is sound (results are always handed back before the next tool-calls/complete), release ordering preserves prose order through the turn speech chain, and the suppression-receipt accounting nets out. The problem is real and both halves of the design are right in outline; the blockers below are scope bugs in the release logic, reproduced through the present-while-acting harness against this head.

1. Blocker: a send-only round that fails still produces dead air — in both modes. heldSilence is only assigned inside the roundSegments.length > 0 branch, so a round that is only a failed send (the commonest production shape: the model emits just the tool call and saves the answer for the closing prose) never sets it. turnSilenced stays sticky ('turn') or carries into complete ('round'), releaseFailedSilence no-ops, and the closing prose is suppressed. This is the PR's own failure mode 1 surviving the fix:

'turn':  send-only round FAILS → close   routed: []      (expected: the closing answer)
'round': send-only round FAILS → close   routed: []      (same)
       [ prose + failing send ] → close  routed: ['r1 prose', 'closing']   (works when the round had prose)

Fix shape: record the silencing round's send ids whether or not there was held prose — an empty segments hold should still lift the silence on all-failed. Red test: mock a send_message returning {success:false, error:'connection closed', isError:true} in a tool-only round, closing prose at complete, assert it routes; run it in both modes.

2. Blocker: a later failed send lifts silence an earlier successful send legitimately caused. releaseFailedSilence sets turnSilenced = false unconditionally. In 'turn' mode: round 1's send lands (turn correctly silenced), round 2 holds prose with a send that fails → the release wipes the turn-level silence and the closing "sent it" postscript routes — the exact redundancy sticky silencing exists to prevent:

r1 send lands, r2 prose + send fails → close   routed: ['r2 prose', 'Sent it! (redundant postscript)']
baseline (both sends land)                     routed: []

Releasing round 2's own held prose is defensible; lifting the whole turn's silence is not. Fix shape: track whether any earlier send in the turn succeeded (a per-turn flag set when a silencing round's results come back non-error) and only drop turnSilenced when none did.

3. Doc vs behavior, needs your call: the proseSilencing: 'round' doc says "later rounds, including the final prose, deliver normally" — but when the send is in the last tool round, turnSilenced carries into complete and the closing prose is suppressed (probe: send lands → close → []). That may well be the right behavior (it's the anti-postscript case), but then the doc should say "final prose delivers unless the last tool round sent." The PR's round-mode test only passes because a non-send round sits in between. Whichever you mean, pin it with a test.

4. Fallback/XML mode scoping: heldSilence is only assigned under roundContent, and the fallback final calc still scans the whole response's tool names, so for anthropic-xml recipes a failed send silences the turn exactly as before. The changelog's "a round whose sends all FAILED releases its held prose" is false there. Either make the fallback scan skip calls whose results were errors, or scope the changelog and doc to native/live routing.

Non-blocking: (a) a round holding both skip_reply and a send releases on the send's failure even though the skip was deliberate — sendIds excludes the skip from the keys but the release still fires; consider treating a round containing skip_reply as never-releasing. (b) roundToolErrors entries aren't cleaned up on agent removal — cosmetic leak. (c) mixed-outcome rounds (every = one landed send blocks release) and the subconscious/primary keying (distinct agent names, unique call ids) were checked and are correct as written.

(Weft — reviewed via Ra's account per the usual convention; adversarial second read by a peer session — blocker 1 found here, blocker 2 and the probe matrix from the second read, all reproduced first-hand before posting.)

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