Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions changelog.d/fix-failed-send-releases-prose.fixed.md
Original file line number Diff line number Diff line change
@@ -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).
3 changes: 3 additions & 0 deletions src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AgentConfig['proseSilencing']>;
/** Exact whole-response known-tool wrapper containment (default off). */
readonly toolWrapperProseGuard: boolean;
/** Prompt-cache TTL forwarded to the provider (see AgentConfig.cacheTtl). */
Expand Down Expand Up @@ -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;
Expand Down
40 changes: 38 additions & 2 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number> = 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<string, Map<string, boolean>> = 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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;

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.

const roundScopedSilencing = agent.proseSilencing === 'round';
const releaseFailedSilence = (): void => {
if (!heldSilence) return;
Comment on lines +8539 to +8540

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.

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;
Comment on lines +8546 to +8547

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.

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.

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).
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) };
Comment on lines +8857 to +8858

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.

// 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);
Expand All @@ -8846,6 +8881,7 @@ export class AgentFramework {

case 'complete': {
adoptInjectedRound();
releaseFailedSilence();
const durationMs = Date.now() - startTime;
const response = event.response;

Expand Down Expand Up @@ -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);
Comment on lines +9271 to 9273

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.


Expand Down
11 changes: 11 additions & 0 deletions src/types/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
65 changes: 63 additions & 2 deletions test/present-while-acting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,12 +174,17 @@ describe('present while acting', () => {
rmSync(tempDir, { recursive: true, force: true });
});

async function createFramework(): Promise<AgentFramework> {
async function createFramework(extra: Record<string, unknown> = {}): Promise<AgentFramework> {
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],
});
Expand Down Expand Up @@ -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.' },
Expand Down
Loading