Repository navigation
feat(routing): configurable homeChannel for no-trigger turn speech #244
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
HO-git
wants to merge
3
commits into
anima-research:main
Choose a base branch
from
HO-git:feat/home-channel-fallback
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| 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(); | ||
| } | ||
| }); | ||
| }); | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The “heartbeat-style turn” test only calls
resolveLocus(); it never starts a turn. None of the new tests checksbuildChannelContext()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
There was a problem hiding this comment.
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 withhomeChannelset, against the realChannelRegistryand a mock server that can publish. They check both what the agent is told (defaultOutgoingand the[routing]notice) and wherechannels/publishactually goes. Cases: activity in another channel, no earlier inbound message, and a control withouthomeChannelthat follows the busy channel. RemovinghomeChannelfromresolveLocusmakes both delivery tests fail.Also merged main (ea010d8). Main now freezes each turn's locus via
resolveLocus()at turn start, andhomeChannelfeeds that, so plain speech and the reported destination stay the same for the whole turn. Main'shomeChannelResolveris the conversation-fork home and returns nothing for a trunk agent, so a no-trigger turn still needs this fallback.tsc --noEmitis clean and the full suite passes (2744).