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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,10 @@ it with `discordAwarenessDeadlineMs` (default 10000ms, clamped to 50..60000ms).
Configure the online marker with `discordAwarenessEmoji`; the offline CLI
accepts `--emoji`.

For stdio MCPL children, the framework defaults `DISCORD_SUPPRESSED_REACTIONS_BASELINE` to the reactions it places: its refusal markers, the Discord adapter's default awareness marker, the configured awareness marker, and every retained outbox emoji. This covers later removals and replays after a configuration change. Suppression is by emoji, so members' own reactions with those emoji stop reaching the agent too. The default is derived at each spawn of a child (its first connect, each automatic reconnect, a restart), so the next child spawned gets the markers retained since the last spawn. When the awareness ledger exists but can't be read, a runtime connect reads it first and refuses before anything is registered or spawned (a startup connect, whose routing is staged beforehand, before anything spawns), and a restart refuses before it stops the running child. An automatic reconnect of a child that takes the default fails and retries rather than spawn it without the default. A value with tokens in the server's env (case-insensitive on Windows), or one intentionally inherited with `inheritEnv: true`, takes precedence, and the framework logs that it replaces the default. A value with no tokens, as discord-mcpl's parser reads them, counts as unset, since the adapter would read it as suppression off. The parser splits on commas and keeps a token only when its normalizer leaves something of it, after stripping U+FE0F, trimming, and stripping one leading and one trailing colon: so a blank value, commas alone, `:`, `::` or a lone U+FE0F (say from `${A},${B}` or `:${NAME}:` with the variables empty) has no tokens. The adapter's filters file and legacy operator env still take precedence over this variable, and they are where an operator turns suppression off.

`framework.getPlacedReactionBaseline()` returns a fresh snapshot of that default for other embeddings, including separately managed WebSocket adapters. It returns suppression tokens, with a custom emoji as its bare ID, not marker payloads for adding, removing or replaying a reaction. An unreadable awareness ledger throws instead of producing a partial snapshot. Hosts that previously supplied a refusal-only default must stop supplying it to adopt this framework-derived default; an explicit operator choice should remain intact.

### MCPL (MCP Live)

Optional host-side implementation of the MCP Live protocol. External servers (game engines, dev tools, etc.) can:
Expand Down
1 change: 1 addition & 0 deletions changelog.d/211-placed-reaction-baseline.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Discord reaction suppression defaults include refusal markers, default/configured awareness markers, and retained recovery-outbox emojis. `framework.getPlacedReactionBaseline()` exposes that snapshot as suppression tokens, converting composite custom-emote markers to stable IDs; stored markers and reaction payloads remain unchanged. Stdio MCPL children get it by default, derived at each spawn (first connect, automatic reconnect, restart). Suppression is by emoji, so members' own reactions with πŸ’€, the configured awareness emoji or a retained recovery emoji stop reaching the agent too, as members' πŸ›‘ already did. When the awareness ledger can't be read, a runtime connect refuses before it registers or spawns anything (a startup connect, before it spawns), any restart refuses before it stops the running child, and an automatic reconnect of a child taking the default fails and retries rather than spawn without it. Explicit server env and intentionally inherited baseline values with tokens retain precedence, and the framework logs when one replaces the default; a value with no tokens, as discord-mcpl's parser reads them (blank, commas alone, or only tokens its normalizer reduces to nothing, such as `:`, `::` or a lone U+FE0F), or a null counts as unset, since the adapter would read it as suppression off (its filters file and legacy operator env are where suppression is turned off), and any other non-string value (a number from a JSON config) is read as its string form, as the child sees it, rather than failing the connect. On Windows, duplicate spellings resolve to the first in sorted order within each source, then declared values take precedence over inherited values. Hosts that inject the old refusal-only default must remove that default to adopt the framework-derived one; remote WebSocket adapters still need their own environment configuration (#95).
136 changes: 132 additions & 4 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ import type {
} from './types/index.js';
import { ProcessQueueImpl } from './queue.js';
import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, shutdownErrorMessage } from './shutdown.js';
import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK } from './refusal-reactions.js';
import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK, REFUSAL_REACTION_BASELINE } 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 @@ -392,6 +392,7 @@ import { EventGate, formatShadowWarning } from './gate/event-gate.js';
import { UsageTracker, type PersistedUsageState } from './usage/usage-tracker.js';
import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js';
import type { McplServerConnection } from './mcpl/server-connection.js';
import { isWebSocketTransport } from './mcpl/transport.js';
import type {
McplServerConfig,
McplHostCapabilities,
Expand Down Expand Up @@ -5100,7 +5101,8 @@ export class AgentFramework {
const parts = incoming.channelId.split(':');
if (parts[0] !== 'discord') return;
const channelId = parts[parts.length - 1];
const emoji = REFUSAL_REACTIONS[category] ?? REFUSAL_REACTION_FALLBACK;
// Own names only: an inherited one such as 'constructor' takes the fallback.
const emoji = Object.hasOwn(REFUSAL_REACTIONS, category) ? 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 @@ -10804,7 +10806,7 @@ export class AgentFramework {
// border is close, rather than noise on every near-threshold
// flip. (It must not re-enter any agent's context β€” the emoji
// is suppressed at the surface via
// DISCORD_SUPPRESS_REACTION_EMOJIS; without that, marking a
// DISCORD_SUPPRESSED_REACTIONS_BASELINE; without that, marking a
// refusal feeds the next one.)
if (!handledByRewind) {
void this.reactToRefusal(agent.name, category);
Expand Down Expand Up @@ -14244,6 +14246,14 @@ export class AgentFramework {
throw new Error('MCPL subsystem is not initialized');
}

// Every connect needs a readable awareness ledger: a runtime connect's
// awareness barrier reconciles it, and a stdio child taking the
// protective default derives that from it. Read it first, so an
// unreadable ledger refuses the connect before anything is registered
// under its id or spawned (a reconnecting server would otherwise be left
// registered as a closed stub). With no outbox configured nothing is read.
this.getPlacedReactionBaseline();

// Register prefix + config for tool dispatch routing (idempotent with
// the pre-registration pass in initializeMcpl).
const prefix = config.toolPrefix ?? `mcpl--${config.id}`;
Expand All @@ -14259,7 +14269,33 @@ export class AgentFramework {
);
}

const connection = await this.mcplServerRegistry.addServer(config, this.mcplHostCapabilities);
// A stdio child's protective reaction default, DISCORD_SUPPRESSED_REACTIONS_BASELINE
// (WebSocket servers own their environment outside this process). The
// caller's config stays unchanged, so a runtime restart derives afresh.
// The default is derived at each spawn: the env below is a getter, which
// the transport reads at first connect, at every automatic reconnect and
// at a stub's retry, so retained markers added since the last spawn are
// covered.
let connectionConfig = config;
if (!isWebSocketTransport(config)) {
const declared = this.declaredReactionBaseline(config);
if (declared) {
console.error(
`[mcpl] ${config.id}: DISCORD_SUPPRESSED_REACTIONS_BASELINE ${declared.source === 'env' ? 'from its env' : 'inherited from the host'} ` +
`replaces the framework's protective default`,
);
}
connectionConfig = { ...config };
Object.defineProperty(connectionConfig, 'env', {
enumerable: true,
configurable: true,
get: () => ({
...config.env,
DISCORD_SUPPRESSED_REACTIONS_BASELINE: declared?.value ?? this.getPlacedReactionBaseline().join(','),
}),
});
}
const connection = await this.mcplServerRegistry.addServer(connectionConfig, this.mcplHostCapabilities);

// Wire listeners before either startup staging or the runtime global gate
// releases control traffic needed for registration and marker service.
Expand Down Expand Up @@ -14625,6 +14661,92 @@ export class AgentFramework {
// Runtime MCPL server lifecycle (agent-facing hot deploy/restart/unload)
// ==========================================================================

/**
* Snapshot the protective Discord reaction default for this deployment, as
* suppression tokens: the forms the Discord adapter's suppression matcher
* reads, not marker payloads for an add, a remove or a replay. It covers
* the reactions the framework places: refusal markers, the adapter's own
* default awareness marker, the configured awareness marker, and retained
* outbox markers (including offline recovery choices and marks that can
* later be removed or replayed). A custom emoji appears as its bare ID,
* which suppression matches even after a rename; stored markers and the
* actual add/remove payloads stay unchanged. Explicit adapter or operator
* suppression settings still take precedence. Throws on an unreadable
* ledger rather than reporting a partial baseline.
*/
getPlacedReactionBaseline(): readonly string[] {
let retained: string[];
try {
retained = this.discordAwarenessOutbox?.batches().map((batch) => batch.emoji) ?? [];
} catch (error) {
throw new DiscordAwarenessAccountingError('reaction-baseline ledger read', error);
}
// Discord events carry the custom emoji's name and ID separately. Full
// marker forms belong in add/remove calls, but suppression matches their
// stable ID (including after a rename), rather than the composite token.
const suppressionToken = (emoji: string): string => {
const custom = /^(?:<a?:\w+:(\d{17,20})>|(?:a:)?\w+:(\d{17,20}))$/.exec(emoji.trim());
return custom?.[1] ?? custom?.[2] ?? emoji;
};
return [...new Set([
...REFUSAL_REACTION_BASELINE,
DEFAULT_DISCORD_AWARENESS_EMOJI,
this.discordAwarenessEmoji,
...retained,
].map(suppressionToken))].filter((emoji) => emoji.length > 0);
}

/**
* The reaction baseline an operator chose for a stdio server, if any: a
* value declared in its env, or one inherited from the host with
* `inheritEnv`, the declared one first. A value with no tokens, as
* discord-mcpl's parser reads them, counts as unset: the adapter splits on
* commas and keeps a token only when its normalizer leaves something of it
* (blank, `:`, `::` and a lone U+FE0F leave nothing), so such a value (an
* empty systemd `Environment=`, say, or `${A},${B}` or `:${NAME}:` with the
* variables empty) would otherwise turn the protective default off without
* anyone choosing that.
*
* Windows names are matched in both sources: Workers expose a case-
* sensitive `process.env` copy. Within one source, Node's spawn keeps the
* first spelling in sorted order, so each source is resolved that way.
*
* A value is read as the child will see it. A config loaded from JSON can
* carry a non-string there despite the type, and spawn writes every
* defined value as its string form, so a number is a declared value, not a
* failed connect. A null counts as unset, like a blank: written in JSON it
* means no value, and read as spawn's "null" it would replace the
* protective default with a list that suppresses nothing.
*/
private declaredReactionBaseline(
config: import('./mcpl/types.js').McplServerConfig,
): { value: string; source: 'env' | 'inherited' } | undefined {
// A token as discord-mcpl's parser keeps one: `parseSuppressionEnvTokens`
// trims each comma-separated token and keeps it when
// `normalizeReactionEmoji` (discord-mcpl `src/filters.ts`) leaves
// something, after it strips U+FE0F, trims, and strips one leading and one
// trailing colon. The same steps in the same order, so the two can be
// compared when either changes.
const isToken = (token: string): boolean =>
token.trim().replace(/\uFE0F/g, '').trim().replace(/^:|:$/g, '') !== '';
const valueIn = (env: NodeJS.ProcessEnv | undefined): string | undefined => {
let raw: unknown;
if (process.platform !== 'win32') raw = env?.DISCORD_SUPPRESSED_REACTIONS_BASELINE;
else {
const key = Object.keys(env ?? {}).sort()
.find((name) => name.toUpperCase() === 'DISCORD_SUPPRESSED_REACTIONS_BASELINE');
raw = key === undefined ? undefined : env?.[key];
}
if (raw === undefined || raw === null) return undefined;
const value = String(raw);
return value.split(',').some(isToken) ? value : undefined;
};
const declared = valueIn(config.env);
if (declared !== undefined) return { value: declared, source: 'env' };
const inherited = config.inheritEnv ? valueIn(process.env) : undefined;
return inherited !== undefined ? { value: inherited, source: 'inherited' } : undefined;
}

/**
* Connect a new MCPL server at runtime. Refreshes the tool list and
* notifies the agent of newly available tools. Throws if the MCPL
Expand Down Expand Up @@ -14707,6 +14829,12 @@ export class AgentFramework {
if (!config) {
throw new Error(`MCPL server "${id}" is not configured`);
}
// Every connect reads the awareness ledger: its awareness barrier
// reconciles it, and a stdio child taking the protective default derives
// that from it. Read it before disconnecting, so an unreadable ledger
// refuses the restart and leaves the running child up, whatever the
// server's baseline or transport. (No outbox configured: nothing to read.)
this.getPlacedReactionBaseline();
await this.disconnectMcplServer(id);
await this.connectMcplServer(config);
}
Expand Down
26 changes: 14 additions & 12 deletions src/refusal-reactions.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
/**
* 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.
* AgentFramework.reactToRefusal).
*
* 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.
* adapter suppresses them when its operator config or
* `DISCORD_SUPPRESSED_REACTIONS_BASELINE` names them. That variable's
* protective default, which the framework injects into stdio children, is
* `framework.getPlacedReactionBaseline()` on an instance: these refusal
* markers plus the awareness markers. REFUSAL_REACTION_BASELINE below is the
* refusal set only. Keeping the emitted refusal set and the exported one 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
Expand All @@ -27,11 +30,10 @@ export const REFUSAL_REACTIONS: Readonly<Record<string, string>> = {
/** 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. */
/** Every refusal marker the framework can emit: the category map plus the
* fallback, deduplicated in stable declaration order. For the deployment's
* complete protective default, including awareness markers, use
* `framework.getPlacedReactionBaseline()` on an instance. */
export const REFUSAL_REACTION_BASELINE: readonly string[] = [
...new Set([...Object.values(REFUSAL_REACTIONS), REFUSAL_REACTION_FALLBACK]),
];
29 changes: 29 additions & 0 deletions test/fixtures/reaction-baseline-mcpl-server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// A real stdio child exposing the environment it received from the framework.
import { createInterface } from 'node:readline';

const reply = (id, result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n');
const rl = createInterface({ input: process.stdin });
rl.on('close', () => process.exit(0));
rl.on('line', (line) => {
const msg = JSON.parse(line);
if (msg.id === undefined || msg.id === null || !msg.method) return;
if (msg.method === 'initialize') {
reply(msg.id, {
protocolVersion: '2024-11-05',
capabilities: { tools: {}, experimental: { mcpl: { version: '0.5' } } },
serverInfo: { name: 'reaction-baseline', version: '0.0.0' },
});
} else if (msg.method === 'tools/list') {
reply(msg.id, { tools: [{ name: 'read_env', description: 'Read fixture environment', inputSchema: { type: 'object', properties: {} } }] });
} else if (msg.method === 'tools/call' && msg.params?.name === 'crash') {
// Stands in for a child that dies, so a reconnecting host respawns it.
process.exit(3);
} else if (msg.method === 'tools/call') {
reply(msg.id, { content: [{ type: 'text', text: JSON.stringify({
baseline: process.env.DISCORD_SUPPRESSED_REACTIONS_BASELINE,
extra: process.env.BASELINE_TEST_EXTRA,
}) }] });
} else {
reply(msg.id, { accepted: true });
}
});
Loading
Loading