diff --git a/README.md b/README.md index 9eb22a24f..9edfeb41b 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,10 @@ unread backlog is simply its unread backlog. Receipts and subject history are kept in the `mcpl/coalescing` state (retry window 1 h); audit lines are `mcpl:coalescing` trace events. +#### Per-channel conversation routing (deprecated) + +> **Deprecated: `FrameworkConfig.conversations` (`ConversationRouter`).** Per-channel conversation routing sends each channel's traffic to its own fork agent, spawned from a template agent and closed after an idle TTL. It is being retired ([#235](https://github.com/anima-research/agent-framework/issues/235)). Its `'mention'` bind/trigger rule, the default for channels, reads `metadata.mentioned`, which discord-mcpl does not set, so on Discord channels an @-mention never binds a fork or triggers a bound one. Don't adopt it in new hosts. Existing configurations still route exactly as before, and the framework logs one `[deprecated]` line when it is created with `conversations` set. Removal is a follow-up. + ### Streaming Lifecycle 1. **Start**: Framework calls `agent.startStreamWithInjections()` → `YieldingStream` diff --git a/changelog.d/deprecate-conversation-routing.changed.md b/changelog.d/deprecate-conversation-routing.changed.md new file mode 100644 index 000000000..6049fea82 --- /dev/null +++ b/changelog.d/deprecate-conversation-routing.changed.md @@ -0,0 +1,11 @@ +- **Per-channel conversation routing is deprecated** (`FrameworkConfig.conversations`, + `ConversationRouter`, `ConversationRouterConfig`). Its `'mention'` bind/trigger + rule, the default for channels, reads `metadata.mentioned`, which discord-mcpl + never sets, so on Discord channels an @-mention neither binds a fork nor + triggers a bound one (#235). Behavior is unchanged for now: routing still + works, and the framework logs one `[deprecated]` line when it is created with + `conversations` set. Removal is a follow-up. The `idleTtlMs` docs now note + that expiry is checked at most about once a minute, so the sweep usually + notices an expired binding up to ~60s after the TTL; that is not a deadline + for closing the fork, whose closure turn can be delayed further (e.g. while + the host is quiesced). diff --git a/src/framework.ts b/src/framework.ts index 519e30422..92aedcc1a 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -82,7 +82,7 @@ import { ProseStreamRouter } from './mcpl/prose-stream-router.js'; import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js'; import { InferenceRouter } from './mcpl/inference-router.js'; import { ChannelRegistry, type ChannelToolOrigin } from './mcpl/channel-registry.js'; -import { ConversationRouter } from './mcpl/conversation-router.js'; +import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js'; import { safeSlice } from './safe-slice.js'; import type { WorkspaceModule } from './modules/workspace/index.js'; import { @@ -1623,6 +1623,9 @@ export class AgentFramework { ); } framework.conversationRouter = new ConversationRouter(config.conversations); + // Deprecated feature (agent-framework#235): still wired exactly as + // before, but named once per framework instance on stderr. + console.warn(`[deprecated] ${CONVERSATION_ROUTING_DEPRECATION_NOTICE}`); // Generation counters persist across restarts — reusing generation 1's // agent name after a restart would reopen (and re-seed) the previous diff --git a/src/index.ts b/src/index.ts index b198bcee3..bd07d3547 100644 --- a/src/index.ts +++ b/src/index.ts @@ -107,7 +107,9 @@ export type { } from './mcpl/index.js'; // Per-channel conversation routing +/** @deprecated Per-channel conversation routing is deprecated and will be removed (anima-research/agent-framework#235). */ export { ConversationRouter, DEFAULT_CLOSURE_PROMPT } from './mcpl/index.js'; +/** @deprecated Per-channel conversation routing is deprecated and will be removed (anima-research/agent-framework#235). */ export type { ConversationRouterConfig, ConversationBinding, diff --git a/src/mcpl/conversation-router.ts b/src/mcpl/conversation-router.ts index 1dba1de96..980dbee00 100644 --- a/src/mcpl/conversation-router.ts +++ b/src/mcpl/conversation-router.ts @@ -27,6 +27,11 @@ * a restarted host must NOT reuse generation 1's agent name, or it would * reopen the previous engagement's Chronicle namespace and re-seed the * template context on top of the old history. + * + * DEPRECATED (anima-research/agent-framework#235): per-channel conversation + * routing is being retired. Behavior is unchanged for now; the framework + * logs one `[deprecated]` line when it is configured. Removal is a + * follow-up. */ import type { ContextStrategy } from '@animalabs/context-manager'; @@ -49,6 +54,20 @@ export type TriggerRule = 'always' | 'mention'; * inferring on every message of a four-human group DM is a firehose). */ export type ChannelKind = 'dm' | 'groupDm' | 'channel'; +/** Logged once by the framework when `FrameworkConfig.conversations` is set. */ +export const CONVERSATION_ROUTING_DEPRECATION_NOTICE = + 'Per-channel conversation routing (FrameworkConfig.conversations) is deprecated and will ' + + 'be removed in a future release. Its \'mention\' bind/trigger rule reads ' + + 'metadata.mentioned, which not every channel server sets (discord-mcpl does not), so on ' + + 'such channels an @-mention neither binds a fork nor triggers a bound one. Routing still ' + + 'works for now. See anima-research/agent-framework#235.'; + +/** + * @deprecated Per-channel conversation routing is deprecated and will be + * removed (anima-research/agent-framework#235). Its 'mention' bind/trigger + * rule reads `metadata.mentioned`, which not every channel server sets. + * Behavior is unchanged for now. + */ export interface ConversationRouterConfig { /** Agent whose context seeds new forks (the "trunk"/warm checkpoint). */ templateAgent: string; @@ -61,7 +80,15 @@ export interface ConversationRouterConfig { * Defaults: dm 'always', groupDm 'mention', channel 'mention'. */ trigger?: { dm?: TriggerRule; groupDm?: TriggerRule; channel?: TriggerRule }; - /** Idle time before a binding expires and the fork is closed. Default 12h. */ + /** + * Idle time before a binding expires and the fork is closed. Default 12h. + * + * Expiry is checked at most about once a minute, so the sweep usually + * notices an expired binding up to ~60s after the TTL elapses. That is the + * usual extra delay, not a deadline for closing the fork: the closure turn + * can be delayed further (e.g. while the host is quiesced, it stays queued + * until resume). + */ idleTtlMs?: number; /** Final system-initiated user message sent to a fork on expiry. */ @@ -116,6 +143,12 @@ export const DEFAULT_CLOSURE_PROMPT = // ConversationRouter // ============================================================================ +/** + * @deprecated Per-channel conversation routing is deprecated and will be + * removed (anima-research/agent-framework#235). Behavior is unchanged for + * now; the framework logs one `[deprecated]` line when it constructs a + * router from `FrameworkConfig.conversations`. + */ export class ConversationRouter { private config: ConversationRouterConfig; diff --git a/src/mcpl/index.ts b/src/mcpl/index.ts index cda1d1093..d82790c67 100644 --- a/src/mcpl/index.ts +++ b/src/mcpl/index.ts @@ -158,7 +158,9 @@ export { InferenceRouter } from './inference-router.js'; export { ChannelRegistry } from './channel-registry.js'; // Per-channel conversation routing (fork-per-channel agents) +/** @deprecated Per-channel conversation routing is deprecated and will be removed (anima-research/agent-framework#235). */ export { ConversationRouter, DEFAULT_CLOSURE_PROMPT } from './conversation-router.js'; +/** @deprecated Per-channel conversation routing is deprecated and will be removed (anima-research/agent-framework#235). */ export type { ConversationRouterConfig, ConversationBinding, diff --git a/src/types/framework.ts b/src/types/framework.ts index a0312561a..92d70fe99 100644 --- a/src/types/framework.ts +++ b/src/types/framework.ts @@ -274,6 +274,13 @@ export interface FrameworkConfig { * messages route to per-channel fork agents spawned from the template * agent instead of the primary conversation. If omitted, behavior is * unchanged (all messages go to the primary agent). + * + * @deprecated Per-channel conversation routing is deprecated and will be + * removed (anima-research/agent-framework#235). Its 'mention' bind/trigger + * rule reads `metadata.mentioned`, which not every channel server sets + * (discord-mcpl does not), so on those channels an @-mention neither binds + * a fork nor triggers a bound one. Routing still works for now; the + * framework logs one `[deprecated]` line at creation when this is set. */ conversations?: ConversationRouterConfig; } diff --git a/test/conversation-routing.test.ts b/test/conversation-routing.test.ts index db3239b06..cd55d3c4d 100644 --- a/test/conversation-routing.test.ts +++ b/test/conversation-routing.test.ts @@ -4,7 +4,7 @@ * conversation when FrameworkConfig.conversations is set. */ -import { describe, it, beforeEach, afterEach } from 'node:test'; +import { describe, it, beforeEach, afterEach, mock } from 'node:test'; import assert from 'node:assert/strict'; import { tmpdir } from 'node:os'; import { mkdtempSync, rmSync } from 'node:fs'; @@ -86,6 +86,63 @@ describe('Conversation routing', () => { ); }); + // Deprecation (agent-framework#235): behavior unchanged, one stderr line. + function deprecationWarnings(warn: { mock: { calls: Array<{ arguments: unknown[] }> } }): string[] { + return warn.mock.calls + .map((c) => String(c.arguments[0])) + .filter((line) => line.startsWith('[deprecated]') && line.includes('conversation routing')); + } + + it('configured: logs one [deprecated] line per framework and still routes', async () => { + const warn = mock.method(console, 'warn', () => {}); + let framework: AgentFramework | undefined; + try { + framework = await makeFramework(); + assert.equal(deprecationWarnings(warn).length, 1, 'warned once at creation'); + assert.match(deprecationWarnings(warn)[0]!, /agent-framework#235/); + + // Routing is unchanged: a DM and a channel mention each spawn a fork. + // (One queued response per stream: MockMembrane hands a stream every + // response still queued.) + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Hi alice!' }])); + framework.pushEvent(incomingEvent({ channelId: 'slack:D1', text: 'hello there', channelType: 'im' })); + await framework.runUntilIdle(); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'On it.' }])); + framework.pushEvent(incomingEvent({ channelId: 'slack:C1', text: 'bot, help', mentioned: true })); + await framework.runUntilIdle(); + assert.ok(framework.getAgent('conversation-slack-D1-g1'), 'DM fork spawned'); + assert.ok(framework.getAgent('conversation-slack-C1-g1'), 'channel-mention fork spawned'); + assert.equal(membrane.calls.length, 2, 'both forks ran inference'); + + assert.equal(deprecationWarnings(warn).length, 1, 'spawning forks does not warn again'); + } finally { + warn.mock.restore(); + await framework?.stop(); + } + }); + + it('not configured: no [deprecated] line', async () => { + const warn = mock.method(console, 'warn', () => {}); + let framework: AgentFramework | undefined; + try { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Hi alice!' }])); + framework = await AgentFramework.create({ + storePath: join(tempDir, 'test.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ name: 'trunk', model: 'test-model', systemPrompt: 'You are the trunk.' }], + modules: [], + }); + framework.pushEvent(incomingEvent({ channelId: 'slack:D1', text: 'hello there', channelType: 'im' })); + await framework.runUntilIdle(); + + assert.equal(framework.getConversationRouter(), null); + assert.deepEqual(deprecationWarnings(warn), []); + } finally { + warn.mock.restore(); + await framework?.stop(); + } + }); + it('DM message spawns a fork, routes the message there, and triggers inference', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Hi alice!' }])); const framework = await makeFramework();