From ec87bcb4be13158649cbeadb8e0a9d1ce0445b1c Mon Sep 17 00:00:00 2001 From: Ian de Marcellus <50731233+ian-de-marcellus@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:49:11 +0200 Subject: [PATCH] feat(prose): failed sends release held prose; opt-in round-scoped silencing - 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) --- .../fix-failed-send-releases-prose.fixed.md | 5 ++ src/agent.ts | 3 + src/framework.ts | 40 +++++++++++- src/types/agent.ts | 11 ++++ test/present-while-acting.test.ts | 65 ++++++++++++++++++- 5 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 changelog.d/fix-failed-send-releases-prose.fixed.md diff --git a/changelog.d/fix-failed-send-releases-prose.fixed.md b/changelog.d/fix-failed-send-releases-prose.fixed.md new file mode 100644 index 0000000..3c77d86 --- /dev/null +++ b/changelog.d/fix-failed-send-releases-prose.fixed.md @@ -0,0 +1,5 @@ +- A round whose explicit sends all **failed** no longer silences its prose: + the held prose is released to the locus and the turn's silence lifts. + New opt-in `proseSilencing: 'round'` scopes a send's silencing to its own + round, so an early send in a long tool-using turn no longer discards the + turn's closing prose (default `'turn'` is unchanged). diff --git a/src/agent.ts b/src/agent.ts index 0855aaf..7e0824d 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -84,6 +84,8 @@ export class Agent { readonly refusalHandling: AgentConfig['refusalHandling']; /** Prose delivery mode (see AgentConfig.proseRouting). Default 'locus'. */ readonly proseRouting: 'locus' | 'explicit' | 'hybrid' | 'disabled'; + /** Silencing scope (see AgentConfig.proseSilencing). Default 'turn'. */ + readonly proseSilencing: NonNullable; /** Exact whole-response known-tool wrapper containment (default off). */ readonly toolWrapperProseGuard: boolean; /** Prompt-cache TTL forwarded to the provider (see AgentConfig.cacheTtl). */ @@ -144,6 +146,7 @@ export class Agent { this.thinking = config.thinking; this.refusalHandling = config.refusalHandling; this.proseRouting = config.proseRouting ?? 'locus'; + this.proseSilencing = config.proseSilencing ?? 'turn'; this.toolWrapperProseGuard = config.toolWrapperProseGuard ?? false; this.cacheTtl = config.cacheTtl ?? '1h'; this.promptCaching = config.promptCaching ?? true; diff --git a/src/framework.ts b/src/framework.ts index 87f9c2a..b5b0f80 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -1012,6 +1012,9 @@ export class AgentFramework { * author sees the segment's fate one turn later. Cleared each fresh * turn; budget restarts keep it. */ private turnProseSuppressed: Map = new Map(); + /** Per agent: tool call id -> whether its result was an error, for the + * round most recently handed back to the stream (failed-send release). */ + private roundToolErrors: Map> = new Map(); /** A tool boundary injected fresh CONVERSATIONAL input (a real message — * not a reaction or a system marker) into the live stream. Tells * driveStream to clear sticky explicit-send suppression before handling @@ -6589,6 +6592,10 @@ export class AgentFramework { const membraneResults = currentState.toolResults.map(tc => this.toMembraneToolResult(tc.id, tc.result, maxChars, spilled.get(tc.id)) ); + this.roundToolErrors.set(agent.name, new Map(currentState.toolResults.map((tc) => { + const r = tc.result as { success?: boolean; isError?: boolean } | undefined; + return [tc.id, r?.isError === true || r?.success === false]; + }))); currentState.stream.provideToolResults( membraneResults, midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections } : undefined, @@ -8524,6 +8531,28 @@ export class AgentFramework { // to prevent a redundant "sent it" postscript. Fresh injected input // clears it, because the following prose is a reply to a new message. let turnSilenced = false; + // 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; + const roundScopedSilencing = agent.proseSilencing === 'round'; + const releaseFailedSilence = (): void => { + if (!heldSilence) return; + const errors = this.roundToolErrors.get(agent.name); + const known = heldSilence.callIds.filter((id) => errors?.has(id)); + if (known.length < heldSilence.callIds.length) return; // results not in yet + const hold = heldSilence; + heldSilence = null; + if (!hold.callIds.every((id) => errors!.get(id) === true)) return; // a send landed + turnSilenced = false; + const locus = resolveTurnLocus(); + console.error( + `[routing] ${agent.name}: silencing send(s) failed -> releasing ${hold.segments.length} held prose segment(s) -> ${locus ?? '(default)'}`, + ); + const suppressed = this.turnProseSuppressed.get(agent.name) ?? 0; + this.turnProseSuppressed.set(agent.name, Math.max(0, suppressed - hold.segments.length)); + for (const seg of hold.segments) enqueueSpeech(seg, locus); + }; // Live routing is only trusted when the membrane provides verbatim // round-scoped blocks (roundContent, native tool mode, membrane ≥0.5.64). @@ -8691,6 +8720,7 @@ export class AgentFramework { case 'tool-calls': { adoptInjectedRound(); + releaseFailedSilence(); hadToolCalls = true; this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length); this.recordEphemeralToolCalls(agent.name, event.calls.length); @@ -8780,7 +8810,10 @@ export class AgentFramework { const hasSameRoundPrivateThink = roundToolNames.includes('think') && requestSnapshot.sameRoundThinkTextPolicy === 'private'; - if (roundToolNames.some(isSilencingTool)) { + if (roundScopedSilencing) { + // proseSilencing 'round': a send silences only its own round. + turnSilenced = roundToolNames.some(isSilencingTool); + } else if (roundToolNames.some(isSilencingTool)) { turnSilenced = true; } if (roundContent && roundContent.length > 0) { @@ -8821,6 +8854,8 @@ export class AgentFramework { console.error( `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`, ); + 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) }; // Visible in the turn-end receipt — silencing must never // be a silent black hole (n=8: the flying-scene reply). this.recordProseSuppression(agent.name, roundSegments.length); @@ -8846,6 +8881,7 @@ export class AgentFramework { case 'complete': { adoptInjectedRound(); + releaseFailedSilence(); const durationMs = Date.now() - startTime; const response = event.response; @@ -9232,7 +9268,7 @@ export class AgentFramework { .filter((b) => b.type === 'tool_use') .map((b) => (b as unknown as { name?: string }).name) .filter((n): n is string => typeof n === 'string'); - const silenced = liveProseRouting + const silenced = liveProseRouting || roundScopedSilencing ? turnSilenced : turnSilenced || toolNames.some(isSilencingTool); diff --git a/src/types/agent.ts b/src/types/agent.ts index b5cd747..756a838 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -185,6 +185,17 @@ export interface AgentConfig { */ proseRouting?: 'locus' | 'explicit' | 'hybrid' | 'disabled'; + /** + * How far an explicit send (send_message, channel_publish, skip_reply, …) + * silences the turn's auto-routed prose. + * - 'turn' (default): from the round it occurs to the end of the turn. + * - 'round': only that round's prose; later rounds, including the final + * prose, deliver normally. For long tool-using turns where an early send + * should not discard the turn's closing words. + * Either way, a round whose sends all FAILED releases its held prose. + */ + proseSilencing?: 'turn' | 'round'; + /** * Fail-closed containment for a text response whose entire visible prose is * an invocation-shaped wrapper naming a tool registered on that exact turn. diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index c5c3157..86a26ab 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -174,12 +174,17 @@ describe('present while acting', () => { rmSync(tempDir, { recursive: true, force: true }); }); - async function createFramework(): Promise { + async function createFramework(extra: Record = {}): Promise { const framework = await AgentFramework.create({ storePath: join(tempDir, 'test.chronicle'), membrane: membrane.asMembrane(), agents: [ - { name: 'assistant', model: 'test-model', systemPrompt: 'You are a robot pilot.' }, + { + name: 'assistant', + model: 'test-model', + systemPrompt: 'You are a robot pilot.', + ...extra, + }, ], modules: [module], }); @@ -277,6 +282,62 @@ describe('present while acting', () => { await framework.stop(); }); + it('a round whose send FAILED releases its held prose and lifts the silence', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'sending it directly' }, + { type: 'tool_use', id: 'c1', name: 'robot--send_message', input: { text: 'hi' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Narrating round two.' }, + { type: 'tool_use', id: 'c2', name: 'robot--move', input: { dir: 'up' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework); + const origHandle = module.handleToolCall.bind(module); + module.handleToolCall = async (call) => + call.name === 'send_message' + ? { success: false, error: 'connection closed', isError: true } + : origHandle(call); + + trigger(framework); + await framework.runUntilIdle(); + + assert.deepEqual( + routed.map((r) => r.text), + ['sending it directly', 'Narrating round two.'], + 'the failed send did not speak, so its round\'s prose and later prose are delivered', + ); + await framework.stop(); + }); + + it("proseSilencing 'round': an early send silences only its own round", async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'private planning' }, + { type: 'tool_use', id: 'c1', name: 'robot--send_message', input: { text: 'hi' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Narrating round two.' }, + { type: 'tool_use', id: 'c2', name: 'robot--move', input: { dir: 'up' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'The long closing answer.' }, + ] as ContentBlock[])); + + const framework = await createFramework({ proseSilencing: 'round' }); + const routed = stubChannelRegistry(framework); + + trigger(framework); + await framework.runUntilIdle(); + + assert.deepEqual( + routed.map((r) => r.text), + ['Narrating round two.', 'The long closing answer.'], + ); + await framework.stop(); + }); + it('sticky silencing is forward-only: earlier rounds\' prose still routes', async () => { membrane.pushResponse(createMockResponse([ { type: 'text', text: 'Round one narration.' },