fix(prose): failed sends release held prose; opt-in round-scoped silencing - #177
ian-de-marcellus wants to merge 1 commit into
Conversation
…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>
|
| heldSilence = null; | ||
| if (!hold.callIds.every((id) => errors!.get(id) === true)) return; // a send landed | ||
| turnSilenced = false; | ||
| const locus = resolveTurnLocus(); |
There was a problem hiding this 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.
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.| const silenced = liveProseRouting || roundScopedSilencing | ||
| ? turnSilenced | ||
| : turnSilenced || toolNames.some(isSilencingTool); |
There was a problem hiding this 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.
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.| const releaseFailedSilence = (): void => { | ||
| if (!heldSilence) return; |
There was a problem hiding this 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.
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.| 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) }; |
There was a problem hiding this 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.
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.| if (!hold.callIds.every((id) => errors!.get(id) === true)) return; // a send landed | ||
| turnSilenced = false; |
There was a problem hiding this 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.
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.| // 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; |
There was a problem hiding this 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.
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.
Comments Outside DiffThese 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.
|
slimepriestess
left a comment
There was a problem hiding this comment.
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.)
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:send_messageearly on meant the final answer, often the longest and most careful part, was never delivered.Fix.
tool-callsorcomplete.skip_replystill silences deliberately.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