Skip to content
Merged
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
9 changes: 9 additions & 0 deletions changelog.d/world-say-silences-prose.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
- **World `say` / `whisper` now silence adjacent auto-routed prose in every
prose-routing mode**, not only `hybrid`. In `locus` mode a round of ordinary
text plus an explicit Eidoverse `say` published twice — the say text, then the
adjacent prose auto-routed to the same world locus (Cairn, 2026-09-01, world
seq 15146/15147 byte-for-byte). An explicit world utterance is the resident's
chosen public speech for that round and is treated exactly like a channel
send: sticky silencing from that round on, suppression visible in the
`[delivered]` receipt. Discord send/reply/DM, `skip_reply`, `think()` privacy,
text-only turns and non-publishing tool rounds are unchanged.
30 changes: 20 additions & 10 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,24 @@ const SILENCING_TOOLS = new Set([
'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
]);

/** World-surface publication names that outrank hybrid prose envelopes. */
const HYBRID_PUBLICATION_TOOLS = new Set(['say', 'whisper']);
/**
* World-surface publication tools (Eidoverse `say` / `whisper`). An explicit
* world utterance is the resident's chosen public speech for that round, so
* it silences adjacent auto-routed prose exactly like a channel send — in
* EVERY prose-routing mode. Until 2026-09-01 this set was consulted only in
* hybrid mode; in locus mode a round of ordinary text + `say` published twice
* (Cairn world seq 15146 = the say text, 15147 = the adjacent prose,
* byte-for-byte). Hybrid additionally lets these outrank a same-round
* `>>>destination` envelope.
*/
const WORLD_PUBLICATION_TOOLS = new Set(['say', 'whisper']);

/** True when a tool call (possibly MCPL-prefixed) is an explicit delivery
* that silences the round's auto-routed prose, regardless of mode. */
const isSilencingTool = (name: string): boolean => {
const bare = bareToolName(name);
return SILENCING_TOOLS.has(bare) || WORLD_PUBLICATION_TOOLS.has(bare);
};

/**
* True when an injected message is real conversational input — something the
Expand Down Expand Up @@ -6257,10 +6273,7 @@ export class AgentFramework {
const hasSameRoundPrivateThink =
roundToolNames.includes('think') &&
requestSnapshot.sameRoundThinkTextPolicy === 'private';
if (roundToolNames.some((n) =>
SILENCING_TOOLS.has(bareToolName(n)) ||
(agent.proseRouting === 'hybrid' && HYBRID_PUBLICATION_TOOLS.has(bareToolName(n)))
)) {
if (roundToolNames.some(isSilencingTool)) {
turnSilenced = true;
}
if (roundContent && roundContent.length > 0) {
Expand Down Expand Up @@ -6675,10 +6688,7 @@ export class AgentFramework {
.filter((n): n is string => typeof n === 'string');
const silenced = liveProseRouting
? turnSilenced
: turnSilenced || toolNames.some((n) =>
SILENCING_TOOLS.has(bareToolName(n)) ||
(agent.proseRouting === 'hybrid' && HYBRID_PUBLICATION_TOOLS.has(bareToolName(n)))
);
: turnSilenced || toolNames.some(isSilencingTool);

const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content);

Expand Down
76 changes: 76 additions & 0 deletions test/present-while-acting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@ class RobotModule implements Module {
description: 'Explicitly send a message',
inputSchema: { type: 'object', properties: { text: { type: 'string' } } },
},
// World-surface publication verbs (Eidoverse). Bare names matter: the
// framework strips the `robot--` prefix before consulting its sets.
{
name: 'say',
description: 'Say something aloud in the world',
inputSchema: { type: 'object', properties: { text: { type: 'string' } } },
},
{
name: 'whisper',
description: 'Whisper to someone in the world',
inputSchema: { type: 'object', properties: { to: { type: 'string' }, text: { type: 'string' } } },
},
];
}

Expand Down Expand Up @@ -619,6 +631,70 @@ describe('present while acting', () => {
await framework.stop();
});

for (const verb of ['say', 'whisper'] as const) {
it(`world \`${verb}\` silences adjacent auto-routed prose in locus mode (no double-publish)`, async () => {
// 2026-09-01 (Cairn, locus mode): a round of ordinary text + explicit
// world `say` published TWICE — seq 15146 was the say text, seq 15147
// the adjacent prose auto-routed to the same world locus, byte-for-byte.
// World publication verbs silenced only in hybrid mode; Discord sends
// silenced everywhere. An explicit world utterance is the resident's
// chosen speech for the round in every mode.
const input = verb === 'say'
? { text: 'the intended utterance' }
: { to: 'sill', text: 'the intended utterance' };
membrane.pushResponse(createMockResponse([
{ type: 'text', text: 'adjacent prose that must NOT auto-publish' },
{ type: 'tool_use', id: 'c1', name: `robot--${verb}`, input },
] as ContentBlock[], 'tool_use'));
membrane.pushResponse(createMockResponse([
{ type: 'text', text: 'trailing prose, also suppressed (sticky)' },
] as ContentBlock[]));

const framework = await createFramework();
const routed = stubChannelRegistry(framework);

trigger(framework);
await framework.runUntilIdle();

assert.deepEqual(routed, [], `${verb}: nothing auto-routed beside the explicit world utterance`);
const cm = (framework as unknown as {
agents: Map<string, { getContextManager(): { getAllMessages(): Array<{ content: Array<{ type: string; text?: string }> }> } }>;
}).agents.get('assistant')!.getContextManager();
const receipts = cm.getAllMessages()
.flatMap((m) => m.content)
.filter((b) => b.type === 'text')
.map((b) => b.text ?? '')
.filter((t) => t.startsWith('[delivered]'));
assert.deepEqual(receipts, [
'[delivered] nothing — 2 plain-speech segment(s) suppressed (explicit send in the same round — resend with a send tool if it was meant to be heard)',
], `${verb}: suppression is visible in the receipt`);

await framework.stop();
});
}

it('a non-publishing world tool (move) does not silence — speak-while-acting unchanged', async () => {
// Negative control for the world-verb silencing: only publication verbs
// silence. Ordinary acting tools still narrate live to the locus.
membrane.pushResponse(createMockResponse([
{ type: 'text', text: 'walking over' },
{ type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } },
] as ContentBlock[], 'tool_use'));
membrane.pushResponse(createMockResponse([
{ type: 'text', text: 'there' },
] as ContentBlock[]));

const framework = await createFramework();
const routed = stubChannelRegistry(framework);

trigger(framework);
await framework.runUntilIdle();

assert.deepEqual(routed.map((r) => r.text), ['walking over', 'there']);

await framework.stop();
});

it('channel_open moves the pin mid-turn and announces in its own tool result', async () => {
// The agent's own deliberate open is the strongest "my next words go
// here" signal — stronger than any injection. The original 2026-07-21
Expand Down