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
12 changes: 2 additions & 10 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
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';
Expand Down Expand Up @@ -2023,7 +2024,7 @@
const namespace = `subagent/${config.name}`;

const contextManager = await ContextManager.open({
store: this.store,

Check failure on line 2027 in src/framework.ts

View workflow job for this annotation

GitHub Actions / Build & Test (macos-latest)

Type 'JsStore' is missing the following properties from type 'JsStore': getStateUpdateJson, updateStateStrategy

Check failure on line 2027 in src/framework.ts

View workflow job for this annotation

GitHub Actions / Build & Test (ubuntu-latest)

Type 'JsStore' is missing the following properties from type 'JsStore': getStateUpdateJson, updateStateStrategy
namespace,
isolate: true,
strategy: config.strategy ?? new PassthroughStrategy(),
Expand Down Expand Up @@ -2470,15 +2471,6 @@
'chat:thread': 'Occurred in a thread',
};

/** Refusal category β†’ Discord reaction emoji. Unknown categories get πŸ›‘. */
private static readonly REFUSAL_REACTIONS: Record<string, string> = {
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
Expand All @@ -2495,7 +2487,7 @@
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
Expand Down Expand Up @@ -3587,7 +3579,7 @@
private async createAgent(config: AgentConfig): Promise<Agent> {
// Create context manager for this agent
const contextManager = await ContextManager.open({
store: this.store,

Check failure on line 3582 in src/framework.ts

View workflow job for this annotation

GitHub Actions / Build & Test (macos-latest)

Type 'JsStore' is missing the following properties from type 'JsStore': getStateUpdateJson, updateStateStrategy

Check failure on line 3582 in src/framework.ts

View workflow job for this annotation

GitHub Actions / Build & Test (ubuntu-latest)

Type 'JsStore' is missing the following properties from type 'JsStore': getStateUpdateJson, updateStateStrategy
namespace: `agents/${config.name}`,
strategy: config.strategy ?? new PassthroughStrategy(),
membrane: this.membrane,
Expand Down Expand Up @@ -4296,7 +4288,7 @@
}

const contextManager = await ContextManager.open({
store: this.store,

Check failure on line 4291 in src/framework.ts

View workflow job for this annotation

GitHub Actions / Build & Test (macos-latest)

Type 'JsStore' is missing the following properties from type 'JsStore': getStateUpdateJson, updateStateStrategy

Check failure on line 4291 in src/framework.ts

View workflow job for this annotation

GitHub Actions / Build & Test (ubuntu-latest)

Type 'JsStore' is missing the following properties from type 'JsStore': getStateUpdateJson, updateStateStrategy
namespace: `conversations/${name}`,
isolate: true,
// Strategy instances are stateful β€” never share the template's.
Expand Down Expand Up @@ -5552,7 +5544,7 @@
}
break;

case 'retrying': {

Check failure on line 5547 in src/framework.ts

View workflow job for this annotation

GitHub Actions / Build & Test (macos-latest)

Type '"retrying"' is not comparable to type '"aborted" | "error" | "usage" | "tokens" | "block" | "tool-calls" | "complete"'.

Check failure on line 5547 in src/framework.ts

View workflow job for this annotation

GitHub Actions / Build & Test (ubuntu-latest)

Type '"retrying"' is not comparable to type '"aborted" | "error" | "usage" | "tokens" | "block" | "tool-calls" | "complete"'.
// Membrane is re-issuing after a content-policy refusal. Per the
// RetryingEvent contract we must DISCARD everything this call has
// emitted: the tokens above were already streamed to the surface
Expand All @@ -5566,8 +5558,8 @@
// destination bookkeeping starts clean for the new attempt.
console.error(
`[refusal-retry] agent=${agent.name} membrane retry ` +
`${event.attempt}/${event.maxAttempts}` +

Check failure on line 5561 in src/framework.ts

View workflow job for this annotation

GitHub Actions / Build & Test (macos-latest)

Property 'maxAttempts' does not exist on type 'never'.

Check failure on line 5561 in src/framework.ts

View workflow job for this annotation

GitHub Actions / Build & Test (macos-latest)

Property 'attempt' does not exist on type 'never'.

Check failure on line 5561 in src/framework.ts

View workflow job for this annotation

GitHub Actions / Build & Test (ubuntu-latest)

Property 'maxAttempts' does not exist on type 'never'.

Check failure on line 5561 in src/framework.ts

View workflow job for this annotation

GitHub Actions / Build & Test (ubuntu-latest)

Property 'attempt' does not exist on type 'never'.
(event.category ? ` category=${event.category}` : '') +

Check failure on line 5562 in src/framework.ts

View workflow job for this annotation

GitHub Actions / Build & Test (macos-latest)

Property 'category' does not exist on type 'never'.

Check failure on line 5562 in src/framework.ts

View workflow job for this annotation

GitHub Actions / Build & Test (macos-latest)

Property 'category' does not exist on type 'never'.

Check failure on line 5562 in src/framework.ts

View workflow job for this annotation

GitHub Actions / Build & Test (ubuntu-latest)

Property 'category' does not exist on type 'never'.

Check failure on line 5562 in src/framework.ts

View workflow job for this annotation

GitHub Actions / Build & Test (ubuntu-latest)

Property 'category' does not exist on type 'never'.
' β€” discarding the refused attempt',
);
outgoingInferenceId = newOutgoingInferenceId();
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
37 changes: 37 additions & 0 deletions src/refusal-reactions.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>> = {
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]),
];
89 changes: 89 additions & 0 deletions test/refusal-reaction-baseline.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<void>;
}
).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<string>();
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]);
});
});
Loading