From 6c547eaecbcc84ee1c3a27c4cb1ed20cf4543eda Mon Sep 17 00:00:00 2001 From: "Claude (rebuild)" Date: Wed, 7 Oct 2026 00:48:19 +0000 Subject: [PATCH 1/2] feat(routing): configurable homeChannel for no-trigger turn speech Turns with no channel provenance (heartbeats, timers) fall through to defaultPublishChannel, the most-recent inbound across ALL channels, so a heartbeat check-in lands in whatever channel last had activity, including public ones. Add FrameworkConfig.homeChannel, wired into ChannelRegistry and consulted in resolveLocus/buildChannelContext after the fork-home and active-trigger resolvers, before the global fallback. Triggered turns and forks are unaffected; without the option behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- changelog.d/home-channel-fallback.added.md | 6 ++ src/framework.ts | 7 ++ src/mcpl/channel-registry.ts | 21 ++++- src/types/framework.ts | 8 ++ test/home-channel-routing.test.ts | 99 ++++++++++++++++++++++ 5 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 changelog.d/home-channel-fallback.added.md create mode 100644 test/home-channel-routing.test.ts diff --git a/changelog.d/home-channel-fallback.added.md b/changelog.d/home-channel-fallback.added.md new file mode 100644 index 000000000..aa3d0fedd --- /dev/null +++ b/changelog.d/home-channel-fallback.added.md @@ -0,0 +1,6 @@ +- `FrameworkConfig.homeChannel`: where speech goes on a turn with no + triggering channel (heartbeats, timers). Such turns used to fall back to the + most-recent inbound channel across all channels, so a check-in landed + wherever a message last arrived. A conversation fork's home and a turn's + triggering channel still take precedence; without the option, behaviour is + unchanged. diff --git a/src/framework.ts b/src/framework.ts index d9a0597d4..508ad4a26 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -1359,6 +1359,9 @@ export class AgentFramework { * subsystem can be lazily initialized by connectMcplServer when the * framework started with zero configured servers. */ private mcplInferenceRoutingConfig: import('./mcpl/types.js').InferenceRoutingPolicy | null = null; + /** Configured home channel for no-trigger turns (heartbeats, timers) — see + * ChannelRegistryOptions.homeChannel. */ + private mcplHomeChannel: string | null = null; /** Durable, non-Chronicle projection queue for messages removed by a branch. */ private discordAwarenessOutbox: DiscordAwarenessOutbox | null = null; private discordAwarenessEmoji = DEFAULT_DISCORD_AWARENESS_EMOJI; @@ -1713,6 +1716,7 @@ export class AgentFramework { // Stored for lazy MCPL initialization (connectMcplServer on a framework // that started with zero configured servers). framework.mcplInferenceRoutingConfig = config.inferenceRouting ?? null; + framework.mcplHomeChannel = config.homeChannel ?? null; // Client-side programmatic tool calling (code_execution). Config is only // retained when enabled — everything downstream gates on the field. @@ -13035,6 +13039,9 @@ export class AgentFramework { // a concurrent message in another channel hijacks it. Empty for // heartbeat / no-trigger turns → correct global fallback. activeChannelResolver: (agentName) => this.activeTriggerChannels.get(agentName), + // Configured home channel: where a no-trigger turn's speech goes + // instead of the last-active channel (heartbeat containment). + homeChannel: this.mcplHomeChannel ?? undefined, // A text-only turn whose speech couldn't be delivered must not vanish // silently: record a `[discord-send-failed]` marker in chronicle so the // agent sees, on her next turn, that her reply never reached the human. diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 9e31ef7e1..907d206fb 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -524,6 +524,17 @@ interface ChannelRegistryOptions { * wins), BEFORE `defaultPublishChannel`. */ activeChannelResolver?: (agentName: string) => string | undefined; + /** + * Configured HOME channel for turns with no channel provenance (heartbeats, + * timers). Without it, such a turn's plain-text speech falls through to + * `defaultPublishChannel` — the most-recent inbound across ALL channels — so + * a heartbeat check-in lands in whatever channel last had activity, + * including public ones. Consulted AFTER `homeChannelResolver` and + * `activeChannelResolver` (a fork's home / a real triggering channel always + * wins), BEFORE `defaultPublishChannel`. Channel descriptor id form, e.g. + * `discord:{guildId}:{channelId}`. + */ + homeChannel?: string; } // ============================================================================ @@ -571,6 +582,7 @@ export class ChannelRegistry { }) => void; private homeChannelResolver?: (agentName: string) => string | undefined; private activeChannelResolver?: (agentName: string) => string | undefined; + private homeChannel?: string; private store?: JsStore; /** Registered channels, keyed by `{serverId}:{channelId}`. */ @@ -683,6 +695,7 @@ export class ChannelRegistry { this.onChannelAutoOpened = options?.onChannelAutoOpened; this.homeChannelResolver = options?.homeChannelResolver; this.activeChannelResolver = options?.activeChannelResolver; + this.homeChannel = options?.homeChannel; this.store = options?.store; this.initializeLifecycleStore(); this.initializeLabelHistoryStore(); @@ -1346,7 +1359,7 @@ export class ChannelRegistry { // agent was told the wrong channel under concurrency. const home = agentName ? this.homeChannelResolver?.(agentName) : undefined; const active = agentName ? this.activeChannelResolver?.(agentName) : undefined; - const outgoing = home ?? active ?? this.defaultPublishChannel; + const outgoing = home ?? active ?? this.homeChannel ?? this.defaultPublishChannel; if (openChannels.length === 0 && !outgoing) { return undefined; @@ -2937,7 +2950,11 @@ export class ChannelRegistry { resolveLocus(conversationId: string): string | null { const home = this.homeChannelResolver?.(conversationId); - return home ?? this.activeChannelResolver?.(conversationId) ?? this.defaultPublishChannel ?? null; + return home + ?? this.activeChannelResolver?.(conversationId) + ?? this.homeChannel + ?? this.defaultPublishChannel + ?? null; } async routeSpeech( diff --git a/src/types/framework.ts b/src/types/framework.ts index a0312561a..c2190fcc6 100644 --- a/src/types/framework.ts +++ b/src/types/framework.ts @@ -251,6 +251,14 @@ export interface FrameworkConfig { /** Inference routing policy for server-initiated inference (optional). */ inferenceRouting?: InferenceRoutingPolicy; + /** + * Home channel for turns with no channel provenance (heartbeats, timers): + * plain-text speech on such a turn routes here instead of the last-active + * channel. Channel descriptor id form, e.g. `discord:{guildId}:{channelId}`. + * A real triggering channel (or a fork's home) still takes precedence. + */ + homeChannel?: string; + /** EventGate config. If omitted, all events trigger inference (unchanged default). */ gate?: GateOptions; diff --git a/test/home-channel-routing.test.ts b/test/home-channel-routing.test.ts new file mode 100644 index 000000000..9523fc8a4 --- /dev/null +++ b/test/home-channel-routing.test.ts @@ -0,0 +1,99 @@ +/** + * Configured home channel for no-trigger turns (heartbeats, timers). + * + * Without `homeChannel`, a turn with no triggering channel resolves its speech + * locus to `defaultPublishChannel` — the most-recent inbound across ALL + * channels — so a heartbeat check-in lands wherever a message last happened to + * arrive. With it, such turns speak in the configured channel, while a fork's + * home or a real triggering channel still wins. + */ + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { AgentFramework } from '../src/index.js'; +import { ChannelRegistry } from '../src/mcpl/channel-registry.js'; +import type { McplServerRegistry } from '../src/mcpl/server-registry.js'; +import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js'; +import { MockMembrane } from './helpers/mock-membrane.js'; + +const HOME = 'discord:g1:home'; +const BUSY = 'discord:g1:busy'; +const TRIGGER = 'discord:g1:trigger'; +const FORK_HOME = 'discord:g1:fork-home'; + +function makeRegistry(opts: { + homeChannel?: string; + homeChannelResolver?: (agentName: string) => string | undefined; + activeChannelResolver?: (agentName: string) => string | undefined; +}) { + const registry = new ChannelRegistry( + { getServer: () => undefined } as unknown as McplServerRegistry, + {} as FeatureSetManager, + () => {}, + () => {}, + opts, + ); + // The global most-recent-inbound locus: some unrelated channel was busy. + (registry as unknown as { defaultPublishChannel: string | null }).defaultPublishChannel = BUSY; + return registry; +} + +describe('homeChannel: speech locus for no-trigger turns', () => { + it('a no-trigger turn resolves to the configured home channel, not the last-active one', () => { + const registry = makeRegistry({ homeChannel: HOME, activeChannelResolver: () => undefined }); + assert.equal(registry.resolveLocus('agent'), HOME); + }); + + it('without homeChannel the global most-recent-inbound fallback is unchanged', () => { + const registry = makeRegistry({ activeChannelResolver: () => undefined }); + assert.equal(registry.resolveLocus('agent'), BUSY); + }); + + it('a triggering channel still wins over the home channel', () => { + const registry = makeRegistry({ homeChannel: HOME, activeChannelResolver: () => TRIGGER }); + assert.equal(registry.resolveLocus('agent'), TRIGGER); + }); + + it("a conversation fork's home still wins over everything", () => { + const registry = makeRegistry({ + homeChannel: HOME, + homeChannelResolver: () => FORK_HOME, + activeChannelResolver: () => TRIGGER, + }); + assert.equal(registry.resolveLocus('agent'), FORK_HOME); + }); +}); + +describe('FrameworkConfig.homeChannel reaches the ChannelRegistry', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'home-channel-test-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('a heartbeat-style turn (no trigger) on a trunk agent resolves to homeChannel', async () => { + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'test.chronicle'), + membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + modules: [], + homeChannel: HOME, + }); + const internals = framework as unknown as { + initializeMcpl(servers: unknown[], routing?: unknown): Promise; + channelRegistry: ChannelRegistry | null; + }; + await internals.initializeMcpl([]); + const registry = internals.channelRegistry!; + (registry as unknown as { defaultPublishChannel: string | null }).defaultPublishChannel = BUSY; + assert.equal(registry.resolveLocus('scout'), HOME); + await framework.stop(); + }); +}); From ad837c85100a78d378dadb34a58e25fcdf6180ed Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sun, 18 Oct 2026 00:48:03 +0000 Subject: [PATCH 2/2] test(routing): home channel delivery end to end Drive a real heartbeat-style turn on a trunk agent with homeChannel set, against the real ChannelRegistry and a publish-capable mock server, and check both what the agent is told (channel context defaultOutgoing, the [routing] notice) and where channels/publish actually goes. Covers activity in another channel, no prior inbound message, and a control without homeChannel that follows the busy channel. Addresses the greptile review on #244. Co-Authored-By: Claude Opus 5.5 --- test/home-channel-routing.test.ts | 133 +++++++++++++++++++++++++++++- 1 file changed, 132 insertions(+), 1 deletion(-) diff --git a/test/home-channel-routing.test.ts b/test/home-channel-routing.test.ts index 9523fc8a4..b8099cb85 100644 --- a/test/home-channel-routing.test.ts +++ b/test/home-channel-routing.test.ts @@ -17,7 +17,9 @@ import { AgentFramework } from '../src/index.js'; import { ChannelRegistry } from '../src/mcpl/channel-registry.js'; import type { McplServerRegistry } from '../src/mcpl/server-registry.js'; import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js'; -import { MockMembrane } from './helpers/mock-membrane.js'; +import { CapabilityGrant, ALL_CAPABILITY_PATHS } from '../src/mcpl/capability-grant.js'; +import type { ChannelContext } from '../src/mcpl/types.js'; +import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; const HOME = 'discord:g1:home'; const BUSY = 'discord:g1:busy'; @@ -97,3 +99,132 @@ describe('FrameworkConfig.homeChannel reaches the ChannelRegistry', () => { await framework.stop(); }); }); + +describe('homeChannel: a no-trigger turn is told, and published to, the home channel', () => { + let tempDir: string; + let membrane: MockMembrane; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'home-channel-delivery-')); + membrane = new MockMembrane(); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + /** + * A trunk agent on the real ChannelRegistry, with HOME and BUSY registered + * and open on a publish-capable mock server. Records every channels/publish + * and every channel context the turn is given (what the agent is told). + */ + async function setup(homeChannel?: string) { + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'test.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + modules: [], + ...(homeChannel ? { homeChannel } : {}), + }); + const internals = framework as unknown as { + initializeMcpl(servers: unknown[], routing?: unknown): Promise; + channelRegistry: ChannelRegistry | null; + pendingRequests: Array<{ agentName: string; reason: string; source: string; timestamp: number }>; + }; + await internals.initializeMcpl([]); + const registry = internals.channelRegistry!; + + const published: string[] = []; + const server = { + grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []), + sendChannelsPublish: async (params: { channelId?: string }) => { + published.push(params.channelId ?? ''); + return { delivered: true, messageId: `out-${published.length}` }; + }, + }; + const raw = registry as unknown as { + serverRegistry: McplServerRegistry; + channels: Map; + defaultPublishChannel: string | null; + defaultPublishMessageId: string | null; + }; + raw.serverRegistry = { getServer: () => server } as unknown as McplServerRegistry; + for (const id of [HOME, BUSY]) { + raw.channels.set(`discord:${id}`, { serverId: 'discord', descriptor: { id, type: 'discord', label: id }, open: true }); + } + + const told: Array = []; + const build = registry.buildChannelContext.bind(registry); + registry.buildChannelContext = (agentName?: string) => { + const ctx = build(agentName); + if (agentName === 'scout') told.push(ctx); + return ctx; + }; + + /** Run one heartbeat-style turn (no triggering channel) that says `text`. */ + async function heartbeat(text: string) { + membrane.pushResponse(createMockResponse([{ type: 'text', text }])); + internals.pendingRequests.push({ agentName: 'scout', reason: 'heartbeat', source: 'framework', timestamp: Date.now() }); + await framework.runUntilIdle(); + } + + /** Some other channel had activity: the global most-recent-inbound locus. */ + function busyElsewhere() { + raw.defaultPublishChannel = BUSY; + raw.defaultPublishMessageId = 'busy-1'; + } + + function routingNotices(): string[] { + return framework.getAgent('scout')!.getContextManager().queryMessages({}).messages + .flatMap((m) => m.content) + .filter((b): b is { type: 'text'; text: string } => b.type === 'text' && b.text.startsWith('[routing]')) + .map((b) => b.text); + } + + return { framework, published, told, heartbeat, busyElsewhere, routingNotices }; + } + + it('with activity in another channel, the heartbeat speaks in HOME, not the busy channel', async () => { + const t = await setup(HOME); + try { + t.busyElsewhere(); + await t.heartbeat('heartbeat check-in'); + assert.deepEqual(t.published, [HOME], 'published to the home channel only'); + assert.ok(t.told.length > 0, 'the turn was given a channel context'); + for (const ctx of t.told) { + assert.equal(ctx?.defaultOutgoing?.channelId, HOME, 'the agent is told its speech lands in HOME'); + } + assert.ok(t.routingNotices().some((n) => n.includes(HOME)), 'the routing notice names HOME'); + assert.ok(!t.routingNotices().some((n) => n.includes(BUSY)), 'and never the busy channel'); + } finally { + await t.framework.stop(); + } + }); + + it('with no prior inbound message at all, the heartbeat still speaks in HOME', async () => { + const t = await setup(HOME); + try { + await t.heartbeat('first words'); + assert.deepEqual(t.published, [HOME]); + for (const ctx of t.told) { + assert.equal(ctx?.defaultOutgoing?.channelId, HOME); + } + } finally { + await t.framework.stop(); + } + }); + + it('control: without homeChannel the same heartbeat follows the busy channel', async () => { + const t = await setup(); + try { + t.busyElsewhere(); + await t.heartbeat('heartbeat check-in'); + assert.deepEqual(t.published, [BUSY]); + for (const ctx of t.told) { + assert.equal(ctx?.defaultOutgoing?.channelId, BUSY); + } + } finally { + await t.framework.stop(); + } + }); +});