Skip to content
Open
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
6 changes: 6 additions & 0 deletions changelog.d/home-channel-fallback.added.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2161,6 +2161,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;
Expand Down Expand Up @@ -2600,6 +2603,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.
Expand Down Expand Up @@ -17194,6 +17198,9 @@ export class AgentFramework {
return routing?.locus ?? routing?.triggerChannel;
},
eventGatePresent: this.eventGate !== null,
// 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.
Expand Down
21 changes: 19 additions & 2 deletions src/mcpl/channel-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,17 @@ interface ChannelRegistryOptions {
* `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;
/** Whether this framework has EventGate and can honor skip_reply self-wakes. */
eventGatePresent?: boolean;
}
Expand Down Expand Up @@ -592,6 +603,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}`. */
Expand Down Expand Up @@ -726,6 +738,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();
Expand Down Expand Up @@ -1483,7 +1496,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;
Expand Down Expand Up @@ -3088,7 +3101,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(
Expand Down
8 changes: 8 additions & 0 deletions src/types/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,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;

Expand Down
230 changes: 230 additions & 0 deletions test/home-channel-routing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
/**
* 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 { 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';
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<void>;
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);
Comment on lines +95 to +98

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Home delivery goes untested

The “heartbeat-style turn” test only calls resolveLocus(); it never starts a turn. None of the new tests checks buildChannelContext() or actual publication, so they can pass even when home delivery fails or the reported destination differs.

Add a registered mock home and a publish-capable server, then check both the reported and published destinations. Cover no prior inbound and activity in another channel.

Knowledge Base Used: MCPL conversation and inference routing

Prompt To Fix With AI
This is a comment left during a code review.
Path: test/home-channel-routing.test.ts
Line: 93-96

Comment:
**Home delivery goes untested**

The “heartbeat-style turn” test only calls `resolveLocus()`; it never starts a turn. None of the new tests checks `buildChannelContext()` or actual publication, so they can pass even when home delivery fails or the reported destination differs.

Add a registered mock home and a publish-capable server, then check both the reported and published destinations. Cover no prior inbound and activity in another channel.

**Knowledge Base Used:** [MCPL conversation and inference routing](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/mcpl-conversation-and-inference.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, good catch: the old tests only checked resolveLocus(). Added in ad837c8: the new tests run a real heartbeat-style turn on a trunk agent with homeChannel set, against the real ChannelRegistry and a mock server that can publish. They check both what the agent is told (defaultOutgoing and the [routing] notice) and where channels/publish actually goes. Cases: activity in another channel, no earlier inbound message, and a control without homeChannel that follows the busy channel. Removing homeChannel from resolveLocus makes both delivery tests fail.

Also merged main (ea010d8). Main now freezes each turn's locus via resolveLocus() at turn start, and homeChannel feeds that, so plain speech and the reported destination stay the same for the whole turn. Main's homeChannelResolver is the conversation-fork home and returns nothing for a trunk agent, so a no-trigger turn still needs this fallback. tsc --noEmit is clean and the full suite passes (2744).

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<void>;
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<string, { serverId: string; descriptor: { id: string; type: string; label: string }; open: boolean }>;
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<ChannelContext | undefined> = [];
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();
}
});
});