diff --git a/src/framework.ts b/src/framework.ts index 19187bb..e4c9fc8 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -52,6 +52,7 @@ import type { SameRoundThinkTextPolicy, } from './types/index.js'; import { ProcessQueueImpl } from './queue.js'; +import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK } from './refusal-reactions.js'; import { Agent } from './agent.js'; import { ModuleRegistry, isStateExistsError } from './module-registry.js'; import { McplServerRegistry } from './mcpl/server-registry.js'; @@ -2470,15 +2471,6 @@ export class AgentFramework { 'chat:thread': 'Occurred in a thread', }; - /** Refusal category โ†’ Discord reaction emoji. Unknown categories get ๐Ÿ›‘. */ - private static readonly REFUSAL_REACTIONS: Record = { - bio: 'โ˜ฃ๏ธ', - chem: '๐Ÿงช', - nuclear: 'โ˜ข๏ธ', - cyber: '๐Ÿ’ป', - reasoning_extraction: '๐Ÿง ', - }; - /** * Mark an inference refusal visibly: react on the message that holds the * conversational locus (the most recent incoming channel message) with a @@ -2495,7 +2487,7 @@ export class AgentFramework { const parts = incoming.channelId.split(':'); if (parts[0] !== 'discord') return; const channelId = parts[parts.length - 1]; - const emoji = AgentFramework.REFUSAL_REACTIONS[category] ?? '๐Ÿ›‘'; + const emoji = REFUSAL_REACTIONS[category] ?? REFUSAL_REACTION_FALLBACK; // Resolve the MCPL server that owns the locus channel and call // tools/call directly on its connection (bare tool name โ€” no prefix // games), bypassing the agent event queue so no synthetic tool-result diff --git a/src/index.ts b/src/index.ts index 4e565ae..ddd8e89 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ export type { StartStreamResult } from './agent.js'; export { ProcessQueueImpl } from './queue.js'; export { ModuleRegistry } from './module-registry.js'; export { formatZonedDateTime, formatZonedTime, isValidTimeZone, resolveTimeZone } from './timezone.js'; +export { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK, REFUSAL_REACTION_BASELINE } from './refusal-reactions.js'; // Built-in modules export * from './modules/index.js'; diff --git a/src/refusal-reactions.ts b/src/refusal-reactions.ts new file mode 100644 index 0000000..2ea5859 --- /dev/null +++ b/src/refusal-reactions.ts @@ -0,0 +1,37 @@ +/** + * Refusal-reaction markers โ€” the single source of truth for the emoji the + * framework places on a message when inference is refused (see + * AgentFramework.reactToRefusal), exported so host composition can derive + * a protective suppression baseline from the exact set the framework emits. + * + * These annotations are placed by the framework on the resident's account + * and must never re-enter a resident's context as reaction events โ€” that is + * the self-amplifying loop behind the 8/3 Mythos incident. The Discord + * adapter suppresses them when its operator config or the host-injected + * `DISCORD_SUPPRESSED_REACTIONS_BASELINE` names them; keeping the emitted + * set and the exported baseline as one constant is what makes drift between + * "what we stamp" and "what we suppress" structurally impossible, rather + * than a promise kept in two files. + */ + +/** Refusal category โ†’ Discord reaction emoji. Unknown categories get the + * fallback marker. */ +export const REFUSAL_REACTIONS: Readonly> = { + bio: 'โ˜ฃ๏ธ', + chem: '๐Ÿงช', + nuclear: 'โ˜ข๏ธ', + cyber: '๐Ÿ’ป', + reasoning_extraction: '๐Ÿง ', +}; + +/** Marker used when the refusal category has no dedicated emoji. */ +export const REFUSAL_REACTION_FALLBACK = '๐Ÿ›‘'; + +/** Every marker the framework can emit โ€” the category map plus the + * fallback, deduplicated, in stable declaration order. This IS the + * protective baseline: host composition serializes it (comma-joined) into + * `DISCORD_SUPPRESSED_REACTIONS_BASELINE` for adapters that render + * reactions. */ +export const REFUSAL_REACTION_BASELINE: readonly string[] = [ + ...new Set([...Object.values(REFUSAL_REACTIONS), REFUSAL_REACTION_FALLBACK]), +]; diff --git a/test/refusal-reaction-baseline.test.ts b/test/refusal-reaction-baseline.test.ts new file mode 100644 index 0000000..ede230e --- /dev/null +++ b/test/refusal-reaction-baseline.test.ts @@ -0,0 +1,89 @@ +/** + * The exported REFUSAL_REACTION_BASELINE must be exactly the set of markers + * reactToRefusal can emit โ€” every category's emoji plus the unknown-category + * fallback, nothing more, nothing less. Host composition serializes this + * export into DISCORD_SUPPRESSED_REACTIONS_BASELINE; if the emitted set and + * the export could drift, a framework annotation could re-enter a resident's + * context as a reaction event (the 8/3 Mythos self-amplifying refusal loop). + * The implementation shares one constant; these tests pin the contract so a + * refactor that splits them fails here first. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { AgentFramework } from '../src/framework.js'; +import { + REFUSAL_REACTIONS, + REFUSAL_REACTION_FALLBACK, + REFUSAL_REACTION_BASELINE, +} from '../src/refusal-reactions.js'; + +/** Drive the private reactToRefusal against a Discord locus, capturing the + * emoji it stamps. */ +async function emittedFor(category: string): Promise { + const calls: Array<{ tool: string; args: { emoji: string } }> = []; + const fakeThis = { + channelRegistry: { + buildChannelContext: () => ({ + incoming: { channelId: 'discord:g1:c1', messageId: 'm1' }, + }), + getChannelServerId: () => 'srv1', + }, + mcplServerRegistry: { + getServer: () => ({ + sendToolsCall: (tool: string, args: { emoji: string }) => { + calls.push({ tool, args }); + return Promise.resolve({}); + }, + }), + }, + }; + const react = ( + AgentFramework.prototype as unknown as { + reactToRefusal: (agentName: string, category: string) => Promise; + } + ).reactToRefusal; + await react.call(fakeThis, 'tester', category); + assert.equal(calls.length, 1, `exactly one reaction for category "${category}"`); + assert.equal(calls[0].tool, 'add_reaction'); + return calls[0].args.emoji; +} + +describe('refusal-reaction baseline export', () => { + it('baseline = category map values + fallback, deduplicated, no empties', () => { + const expected = new Set([...Object.values(REFUSAL_REACTIONS), REFUSAL_REACTION_FALLBACK]); + assert.deepEqual(new Set(REFUSAL_REACTION_BASELINE), expected); + assert.equal(REFUSAL_REACTION_BASELINE.length, expected.size, 'no duplicates'); + assert.ok(REFUSAL_REACTION_BASELINE.every((e) => e.length > 0), 'no empty entries'); + assert.ok(REFUSAL_REACTION_BASELINE.includes(REFUSAL_REACTION_FALLBACK)); + }); + + it('every emitted annotation is in the baseline โ€” known categories and unknown fallback', async () => { + const emitted = new Set(); + for (const category of Object.keys(REFUSAL_REACTIONS)) { + emitted.add(await emittedFor(category)); + } + emitted.add(await emittedFor('some_future_category')); + + for (const emoji of emitted) { + assert.ok( + REFUSAL_REACTION_BASELINE.includes(emoji), + `emitted ${emoji} must be suppressible via the exported baseline`, + ); + } + // Exactness both ways: the framework can emit everything the baseline + // names โ€” no stale entries suppressing markers nothing stamps anymore. + assert.deepEqual(emitted, new Set(REFUSAL_REACTION_BASELINE)); + }); + + it('serialized baseline survives the Discord adapter env round-trip (comma-join)', () => { + // Host composition joins with ','; discord-mcpl's parseSuppressionEnvTokens + // splits on ',' and trims. Entries therefore must not contain commas or + // leading/trailing whitespace, or the round-trip changes the set. + for (const e of REFUSAL_REACTION_BASELINE) { + assert.ok(!e.includes(','), `"${e}" would split under comma-join`); + assert.equal(e, e.trim(), `"${e}" would change under trim`); + } + const roundTripped = REFUSAL_REACTION_BASELINE.join(',').split(',').map((s) => s.trim()); + assert.deepEqual(roundTripped, [...REFUSAL_REACTION_BASELINE]); + }); +});