From ab408e050dd6d737f83520a75359d00eccf21836 Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Sun, 16 Aug 2026 02:51:09 +0000 Subject: [PATCH 1/3] feat: remember routing preferences in Brain --- .../__tests__/routing-confirmation.test.ts | 15 ++ .../handlers/discord/routing-confirmation.ts | 13 ++ .../routing-preference-memory.test.ts | 99 +++++++++++ apps/api/src/handlers/mcp/roomote.ts | 90 +++++++++- .../handlers/mcp/routing-preference-memory.ts | 135 +++++++++++++++ .../handlers/telegram/__tests__/index.test.ts | 15 ++ .../handlers/telegram/routing-confirmation.ts | 11 ++ .../__tests__/follow-up-service.test.ts | 48 ++++++ .../router/__tests__/router-service.test.ts | 88 ++++++++++ .../src/server/router/context-builders.ts | 68 +++++--- .../src/server/router/follow-up-service.ts | 40 ++++- .../cloud-agents/src/server/router/index.ts | 4 + .../src/server/router/mcp-policy.ts | 4 + .../src/server/router/router-service.ts | 109 +++++++++++++ .../router/routing-preference-memory.ts | 154 ++++++++++++++++++ .../cloud-agents/src/server/router/types.ts | 13 ++ .../__tests__/show-task-configuration.test.ts | 12 ++ packages/slack/src/block-kit.ts | 31 ++++ packages/types/src/brain.ts | 13 ++ 19 files changed, 940 insertions(+), 22 deletions(-) create mode 100644 apps/api/src/handlers/mcp/__tests__/routing-preference-memory.test.ts create mode 100644 apps/api/src/handlers/mcp/routing-preference-memory.ts create mode 100644 packages/cloud-agents/src/server/router/routing-preference-memory.ts diff --git a/apps/api/src/handlers/discord/__tests__/routing-confirmation.test.ts b/apps/api/src/handlers/discord/__tests__/routing-confirmation.test.ts index fe57c1e7b..44a40e1ab 100644 --- a/apps/api/src/handlers/discord/__tests__/routing-confirmation.test.ts +++ b/apps/api/src/handlers/discord/__tests__/routing-confirmation.test.ts @@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({ getTaskUrl: vi.fn(), classifyFollowUp: vi.fn(), resolveRoutingFollowUp: vi.fn(), + recordRoutingPreference: vi.fn(), routeTask: vi.fn(), findMappedUser: vi.fn(), findSourceRun: vi.fn(), @@ -26,6 +27,7 @@ vi.mock('@roomote/cloud-agents/server', () => ({ getTaskUrl: mocks.getTaskUrl, ROUTING_AUTO_CONFIRM_TIMEOUT_MS: 30_000, resolveRoutingFollowUp: mocks.resolveRoutingFollowUp, + recordRoutingPreference: mocks.recordRoutingPreference, routeTask: mocks.routeTask, })); @@ -1351,6 +1353,12 @@ describe('Discord routing confirmation', () => { isDirectMessage: false, isThread: true, }, + routingContext: { + routingActor: { + userId: 'user-1', + apiBaseUrl: 'http://api.test', + }, + }, options: [ { label: 'Sunny Acres', @@ -1361,6 +1369,7 @@ describe('Discord routing confirmation', () => { }, }, ], + suggestedIndex: 0, }), ); @@ -1395,6 +1404,12 @@ describe('Discord routing confirmation', () => { }), ); expect(mocks.reply).not.toHaveBeenCalled(); + expect(mocks.recordRoutingPreference).toHaveBeenCalledWith({ + userId: 'user-1', + apiBaseUrl: 'http://api.test', + environmentId: 'env-1', + signal: 'accepted', + }); }); it('keeps the launch acknowledgement when the card stayed in the channel', async () => { diff --git a/apps/api/src/handlers/discord/routing-confirmation.ts b/apps/api/src/handlers/discord/routing-confirmation.ts index 316dc1fbf..8d738e846 100644 --- a/apps/api/src/handlers/discord/routing-confirmation.ts +++ b/apps/api/src/handlers/discord/routing-confirmation.ts @@ -4,6 +4,7 @@ import { getAvailableEnvironments, getRoutingAutoConfirmDelayMs, getTaskUrl, + recordRoutingPreference, resolveRoutingFollowUp, ROUTING_AUTO_CONFIRM_TIMEOUT_MS, type RoutingDecision, @@ -777,6 +778,7 @@ export async function handleDiscordRoutingReply(input: { : null, userResponse: input.queuedMessage.text, userId: input.launchOwnerUserId, + apiBaseUrl: routingContext.routingActor?.apiBaseUrl, correctionMessage: { user: input.queuedMessage.user, text: input.queuedMessage.text, @@ -1110,6 +1112,17 @@ export async function handleDiscordRoutingCallback(input: { }); return; } + if (option.workspace.type === 'environment') { + await recordRoutingPreference({ + userId: pending.launchOwnerUserId, + apiBaseUrl: pending.routingContext?.routingActor?.apiBaseUrl, + environmentId: option.workspace.id, + signal: + input.callback.selection === pending.suggestedIndex + ? 'accepted' + : 'corrected', + }); + } // The launch already answered a card that lived in the task thread. if (pending.cardChannel) return; await replyToDiscordEvent({ diff --git a/apps/api/src/handlers/mcp/__tests__/routing-preference-memory.test.ts b/apps/api/src/handlers/mcp/__tests__/routing-preference-memory.test.ts new file mode 100644 index 000000000..7c8811130 --- /dev/null +++ b/apps/api/src/handlers/mcp/__tests__/routing-preference-memory.test.ts @@ -0,0 +1,99 @@ +const mocks = vi.hoisted(() => ({ + callMcpTool: vi.fn(), + resolveBrainConnection: vi.fn(), +})); + +vi.mock('@roomote/cloud-agents/server', () => ({ + callMcpTool: mocks.callMcpTool, +})); + +vi.mock('@roomote/sdk/server', () => ({ + resolveBrainConnection: mocks.resolveBrainConnection, +})); + +import { + getRoutingPreferenceMemory, + recordRoutingPreferenceMemory, +} from '../routing-preference-memory'; + +describe('routing preference Brain memory', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveBrainConnection.mockResolvedValue({ + baseUrl: 'http://brain.test', + token: 'brain-token', + }); + }); + + it('reads a preference from an exact Brain page', async () => { + mocks.callMcpTool.mockResolvedValue({ + frontmatter: { + environment_id: 'env-api', + accepted_count: 2, + correction_count: 1, + last_selected_at: '2026-08-16T12:00:00.000Z', + }, + }); + + await expect(getRoutingPreferenceMemory('user-1')).resolves.toEqual({ + environmentId: 'env-api', + acceptedCount: 2, + correctionCount: 1, + lastSelectedAt: '2026-08-16T12:00:00.000Z', + }); + expect(mocks.callMcpTool).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: 'get_page', + args: { slug: 'routing/preferences/users/dXNlci0x' }, + }), + ); + }); + + it('reinforces the same environment and writes the page through Brain ingestion', async () => { + mocks.callMcpTool + .mockResolvedValueOnce({ + frontmatter: { + environment_id: 'env-api', + accepted_count: 2, + correction_count: 1, + last_selected_at: '2026-08-16T12:00:00.000Z', + }, + }) + .mockResolvedValueOnce({ ok: true }); + + const result = await recordRoutingPreferenceMemory({ + userId: 'user-1', + environmentId: 'env-api', + signal: 'corrected', + }); + + expect(result).toMatchObject({ + environmentId: 'env-api', + acceptedCount: 2, + correctionCount: 2, + }); + expect(mocks.callMcpTool).toHaveBeenLastCalledWith( + expect.objectContaining({ + toolName: 'put_page', + args: expect.objectContaining({ + slug: 'routing/preferences/users/dXNlci0x', + content: expect.stringContaining('correction_count: 2'), + }), + }), + ); + }); + + it('fails open when Brain is not configured', async () => { + mocks.resolveBrainConnection.mockResolvedValue(null); + + await expect(getRoutingPreferenceMemory('user-1')).resolves.toBeNull(); + await expect( + recordRoutingPreferenceMemory({ + userId: 'user-1', + environmentId: 'env-api', + signal: 'accepted', + }), + ).resolves.toBeNull(); + expect(mocks.callMcpTool).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/handlers/mcp/roomote.ts b/apps/api/src/handlers/mcp/roomote.ts index 38f972288..d1f9eb2a5 100644 --- a/apps/api/src/handlers/mcp/roomote.ts +++ b/apps/api/src/handlers/mcp/roomote.ts @@ -22,6 +22,8 @@ import { MCP_INTEGRATIONS, isUserToken, PRODUCT_NAME, + ROUTING_PREFERENCE_GET_TOOL, + ROUTING_PREFERENCE_RECORD_TOOL, } from '@roomote/types'; import { Env, getDefaultDocsUrl } from '@roomote/env'; import { @@ -49,6 +51,10 @@ import { import { requireCommunicationLookupTaskRun } from './communication-lookup-run-context'; import type { McpAuth } from './middleware'; import { registerRoomoteMemberTools } from './roomote-member-tools'; +import { + getRoutingPreferenceMemory, + recordRoutingPreferenceMemory, +} from './routing-preference-memory'; const ROOMOTE_MCP_SERVER_INFO = { name: 'roomote-router-mcp', @@ -371,6 +377,7 @@ function createRoomoteMcpServer( auth: McpAuthContext, actingUserId: string | null, memberAuth?: McpAuth, + routerTools = false, ) { const server = new McpServer(ROOMOTE_MCP_SERVER_INFO, { instructions: `Use get_about_me for Roomote platform, integration, and getting-started context. Use ${CHAT_MESSAGE_CONTEXT_TOOL.name} for surrounding context from the task communication channel or a referenced Slack/Discord message. Use ${CHAT_CHANNEL_MESSAGES_TOOL.name} for readable history from the task communication channel or an explicitly linked channel.`, @@ -380,6 +387,79 @@ function createRoomoteMcpServer( registerRoomoteMemberTools(server, memberAuth); } + if (routerTools) { + server.registerTool( + ROUTING_PREFERENCE_GET_TOOL, + { + title: 'Get Routing Preference', + description: + 'Get the current user routing preference from the Brain by exact key.', + inputSchema: {}, + outputSchema: z.object({ + preference: z + .object({ + environmentId: z.string(), + acceptedCount: z.number(), + correctionCount: z.number(), + lastSelectedAt: z.string(), + }) + .nullable(), + }), + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async () => + toMcpToolResult({ + preference: actingUserId + ? await getRoutingPreferenceMemory(actingUserId) + : null, + }), + ); + + server.registerTool( + ROUTING_PREFERENCE_RECORD_TOOL, + { + title: 'Record Routing Preference', + description: + 'Record an accepted or corrected environment choice in the current user routing preference page.', + inputSchema: { + environmentId: z.string().min(1), + signal: z.enum(['accepted', 'corrected']), + }, + outputSchema: z.object({ + preference: z + .object({ + environmentId: z.string(), + acceptedCount: z.number(), + correctionCount: z.number(), + lastSelectedAt: z.string(), + }) + .nullable(), + }), + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + }, + async ({ environmentId, signal }) => + toMcpToolResult({ + preference: actingUserId + ? await recordRoutingPreferenceMemory({ + userId: actingUserId, + environmentId, + signal, + }) + : null, + }), + ); + } + server.registerTool( 'get_about_me', { @@ -509,6 +589,7 @@ function createRoomoteMcpServer( function createRoomoteMcpRouter(options: { memberTools: boolean; allowLegacyAudience: boolean; + routerTools: boolean; }) { const router = new Hono<{ Variables: Variables }>(); @@ -536,7 +617,12 @@ function createRoomoteMcpRouter(options: { : rawAuth, } : undefined; - const server = createRoomoteMcpServer(auth, actingUserId, memberAuth); + const server = createRoomoteMcpServer( + auth, + actingUserId, + memberAuth, + options.routerTools, + ); await server.connect(transport); return await transport.handleRequest(c.req.raw); @@ -578,8 +664,10 @@ function createRoomoteMcpRouter(options: { export const roomoteMcp = createRoomoteMcpRouter({ memberTools: false, allowLegacyAudience: true, + routerTools: true, }); export const publicRoomoteMcp = createRoomoteMcpRouter({ memberTools: true, allowLegacyAudience: false, + routerTools: false, }); diff --git a/apps/api/src/handlers/mcp/routing-preference-memory.ts b/apps/api/src/handlers/mcp/routing-preference-memory.ts new file mode 100644 index 000000000..f4249fa26 --- /dev/null +++ b/apps/api/src/handlers/mcp/routing-preference-memory.ts @@ -0,0 +1,135 @@ +import { resolveBrainConnection } from '@roomote/sdk/server'; +import { + ROUTING_PREFERENCE_GET_TOOL, + ROUTING_PREFERENCE_RECORD_TOOL, + type RoutingPreferenceMemory, + type RoutingPreferenceSignal, +} from '@roomote/types'; +import { callMcpTool } from '@roomote/cloud-agents/server'; +import { z } from 'zod'; + +const preferencePageSchema = z.object({ + frontmatter: z + .object({ + environment_id: z.string().min(1), + accepted_count: z.coerce.number().int().nonnegative().default(0), + correction_count: z.coerce.number().int().nonnegative().default(0), + last_selected_at: z.string().datetime(), + }) + .passthrough(), +}); + +function routingPreferenceSlug(userId: string): string { + return `routing/preferences/users/${Buffer.from(userId).toString('base64url')}`; +} + +async function callBrainTool(options: { + role: 'agent' | 'ingest'; + toolName: 'get_page' | 'put_page'; + args: Record; +}): Promise { + const connection = await resolveBrainConnection(options.role); + if (!connection) { + return null; + } + + return callMcpTool({ + url: `${connection.baseUrl.replace(/\/$/, '')}/mcp`, + headers: { Authorization: `Bearer ${connection.token}` }, + toolName: options.toolName, + args: options.args, + toolCallId: `routing-preference:${options.toolName}`, + }); +} + +function parseRoutingPreference( + value: unknown, +): RoutingPreferenceMemory | null { + const parsed = preferencePageSchema.safeParse(value); + if (!parsed.success) { + return null; + } + + return { + environmentId: parsed.data.frontmatter.environment_id, + acceptedCount: parsed.data.frontmatter.accepted_count, + correctionCount: parsed.data.frontmatter.correction_count, + lastSelectedAt: parsed.data.frontmatter.last_selected_at, + }; +} + +async function readRoutingPreference( + userId: string, + role: 'agent' | 'ingest', +): Promise { + try { + return parseRoutingPreference( + await callBrainTool({ + role, + toolName: 'get_page', + args: { slug: routingPreferenceSlug(userId) }, + }), + ); + } catch { + // A missing page and an unavailable optional Brain both mean no preference. + return null; + } +} + +export async function getRoutingPreferenceMemory( + userId: string, +): Promise { + return readRoutingPreference(userId, 'agent'); +} + +export async function recordRoutingPreferenceMemory(input: { + userId: string; + environmentId: string; + signal: RoutingPreferenceSignal; +}): Promise { + const connection = await resolveBrainConnection('ingest'); + if (!connection) { + return null; + } + + const existing = await readRoutingPreference(input.userId, 'ingest'); + const sameEnvironment = existing?.environmentId === input.environmentId; + const preference: RoutingPreferenceMemory = { + environmentId: input.environmentId, + acceptedCount: + (sameEnvironment ? existing.acceptedCount : 0) + + (input.signal === 'accepted' ? 1 : 0), + correctionCount: + (sameEnvironment ? existing.correctionCount : 0) + + (input.signal === 'corrected' ? 1 : 0), + lastSelectedAt: new Date().toISOString(), + }; + const content = [ + '---', + `environment_id: ${JSON.stringify(preference.environmentId)}`, + `accepted_count: ${preference.acceptedCount}`, + `correction_count: ${preference.correctionCount}`, + `last_selected_at: ${JSON.stringify(preference.lastSelectedAt)}`, + 'provenance: roomote-routing-preference', + '---', + '', + '# Routing preference', + '', + `Preferred environment: ${preference.environmentId}`, + ].join('\n'); + + await callMcpTool({ + url: `${connection.baseUrl.replace(/\/$/, '')}/mcp`, + headers: { Authorization: `Bearer ${connection.token}` }, + toolName: 'put_page', + args: { slug: routingPreferenceSlug(input.userId), content }, + toolCallId: 'routing-preference:put_page', + }); + + return preference; +} + +export const ROUTING_PREFERENCE_TOOL_NAMES = [ + ROUTING_PREFERENCE_GET_TOOL, + ROUTING_PREFERENCE_RECORD_TOOL, +] as const; diff --git a/apps/api/src/handlers/telegram/__tests__/index.test.ts b/apps/api/src/handlers/telegram/__tests__/index.test.ts index 5c33fb1fa..a155653d8 100644 --- a/apps/api/src/handlers/telegram/__tests__/index.test.ts +++ b/apps/api/src/handlers/telegram/__tests__/index.test.ts @@ -30,6 +30,7 @@ const { redisGetMock, redisGetdelMock, redisSetMock, + recordRoutingPreferenceMock, resolveRoutingFollowUpMock, routeTaskMock, setLatestInboundMessageIdMock, @@ -74,6 +75,7 @@ const { redisGetMock: vi.fn(), redisGetdelMock: vi.fn(), redisSetMock: vi.fn(), + recordRoutingPreferenceMock: vi.fn(), resolveRoutingFollowUpMock: vi.fn(), routeTaskMock: vi.fn(), setLatestInboundMessageIdMock: vi.fn(), @@ -301,6 +303,7 @@ vi.mock('@roomote/cloud-agents/server', () => ({ getRoutingAutoConfirmDelayMs: getRoutingAutoConfirmDelayMsMock, getTaskUrl: getTaskUrlMock, resolveRoutingFollowUp: resolveRoutingFollowUpMock, + recordRoutingPreference: recordRoutingPreferenceMock, routeTask: routeTaskMock, })); @@ -2248,6 +2251,12 @@ describe('Telegram webhook handler', () => { ], suggestedIndex: 0, confirmMessageId: '990', + routingContext: { + routingActor: { + userId: 'launch-owner-23', + apiBaseUrl: 'https://api.example.com', + }, + }, }); redisGetMock.mockResolvedValue(pending); redisGetdelMock.mockResolvedValue(pending); @@ -2293,6 +2302,12 @@ describe('Telegram webhook handler', () => { text: 'Starting in Web App.', }), ); + expect(recordRoutingPreferenceMock).toHaveBeenCalledWith({ + userId: 'launch-owner-23', + apiBaseUrl: 'https://api.example.com', + environmentId: 'env-1', + signal: 'accepted', + }); // The card is finalized in place: text swapped, keyboard removed. expect(editMessageTextMock).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/apps/api/src/handlers/telegram/routing-confirmation.ts b/apps/api/src/handlers/telegram/routing-confirmation.ts index d9f53aad3..acda97c28 100644 --- a/apps/api/src/handlers/telegram/routing-confirmation.ts +++ b/apps/api/src/handlers/telegram/routing-confirmation.ts @@ -9,6 +9,7 @@ import { getRedis } from '@roomote/redis'; import { getAvailableEnvironments, getRoutingAutoConfirmDelayMs, + recordRoutingPreference, resolveRoutingFollowUp, type RoutingDecision, type RoutingContext, @@ -600,6 +601,7 @@ export async function handleTelegramRoutingReply(input: { : null, userResponse: input.queuedMessage.text, userId: input.launchOwnerUserId, + apiBaseUrl: routingContext.routingActor?.apiBaseUrl, correctionMessage: { user: input.queuedMessage.user, text: input.queuedMessage.text, @@ -886,6 +888,15 @@ export async function handleTelegramRoutingCallback(params: { return; } + if (option.workspace.type === 'environment') { + await recordRoutingPreference({ + userId: claimed.launchOwnerUserId, + apiBaseUrl: claimed.routingContext?.routingActor?.apiBaseUrl, + environmentId: option.workspace.id, + signal: optionIndex === claimed.suggestedIndex ? 'accepted' : 'corrected', + }); + } + await answerTelegramCallbackQueryBestEffort({ callbackQueryId: query.id, text: `Starting in ${launched.workspaceDisplayName}.`, diff --git a/packages/cloud-agents/src/server/router/__tests__/follow-up-service.test.ts b/packages/cloud-agents/src/server/router/__tests__/follow-up-service.test.ts index 554e3cf89..dd1776309 100644 --- a/packages/cloud-agents/src/server/router/__tests__/follow-up-service.test.ts +++ b/packages/cloud-agents/src/server/router/__tests__/follow-up-service.test.ts @@ -1,5 +1,6 @@ const mocks = vi.hoisted(() => ({ classifyFollowUp: vi.fn(), + recordRoutingPreference: vi.fn(), routeTask: vi.fn(), })); @@ -8,6 +9,21 @@ vi.mock('../router-service', () => ({ routeTask: mocks.routeTask, })); +vi.mock('../routing-preference-memory', () => ({ + normalizeRoutingPreferenceEnvironmentId: (value?: string | null) => { + if ( + !value || + value === 'all_repositories' || + value === '__all_repositories__' || + value.startsWith('repo:') + ) { + return null; + } + return value.startsWith('env:') ? value.slice(4) : value; + }, + recordRoutingPreference: mocks.recordRoutingPreference, +})); + import { resolveRoutingFollowUp } from '../follow-up-service'; const routingContext = { @@ -48,6 +64,12 @@ describe('resolveRoutingFollowUp', () => { ).resolves.toEqual({ intent: 'confirm' }); expect(buildCorrectionContext).not.toHaveBeenCalled(); expect(mocks.routeTask).not.toHaveBeenCalled(); + expect(mocks.recordRoutingPreference).toHaveBeenCalledWith({ + userId: 'user-1', + apiBaseUrl: undefined, + environmentId: 'web', + signal: 'accepted', + }); }); it('cancels without building context or routing again', async () => { @@ -72,6 +94,26 @@ describe('resolveRoutingFollowUp', () => { expect(mocks.routeTask).not.toHaveBeenCalled(); }); + it('does not store all-repositories confirmations as environment memory', async () => { + mocks.classifyFollowUp.mockResolvedValueOnce({ + intent: 'confirm', + reasoning: 'accepted', + }); + + await expect( + resolveRoutingFollowUp({ + suggestion: { + workspaceValue: 'repo:__all_repositories__', + workspaceDisplayName: 'All repositories', + }, + userResponse: 'yes', + userId: 'user-1', + buildCorrectionContext: vi.fn(), + }), + ).resolves.toEqual({ intent: 'confirm' }); + expect(mocks.recordRoutingPreference).not.toHaveBeenCalled(); + }); + it('routes a correction with the previous suggestion and reply in context', async () => { mocks.classifyFollowUp.mockResolvedValueOnce({ intent: 'correct', @@ -112,6 +154,12 @@ describe('resolveRoutingFollowUp', () => { workspaceDisplayName: 'Web App', }, }); + expect(mocks.recordRoutingPreference).toHaveBeenCalledWith({ + userId: 'user-1', + apiBaseUrl: undefined, + environmentId: 'api', + signal: 'corrected', + }); }); it('does not confirm when the card is a picker with no suggestion', async () => { diff --git a/packages/cloud-agents/src/server/router/__tests__/router-service.test.ts b/packages/cloud-agents/src/server/router/__tests__/router-service.test.ts index 45b60f01c..c5967d50d 100644 --- a/packages/cloud-agents/src/server/router/__tests__/router-service.test.ts +++ b/packages/cloud-agents/src/server/router/__tests__/router-service.test.ts @@ -106,6 +106,94 @@ describe('routeTask', () => { }); }); + it('uses repeated Brain preference signals to break an uncertain route', async () => { + mockGenerateTrackedNonTaskObject.mockResolvedValue({ + object: { + workspaceValue: 'Full Stack', + reasoning: 'The request could fit either workspace.', + confidence: 0.55, + kickoffMessage: 'Starting the ambiguous request in Full Stack.', + needsExternalLookup: false, + externalReference: null, + }, + }); + + const result = await routeTask( + createContext({ + availableEnvironments: [ + ...environments, + { + id: 'env-api', + name: 'API', + repositoryNames: ['acme/api'], + }, + ], + environmentPreference: { + environmentId: 'env-api', + acceptedCount: 2, + correctionCount: 0, + lastSelectedAt: new Date(), + }, + }), + ); + + expect(result).toMatchObject({ + status: 'routed', + result: { + workspace: { type: 'environment', id: 'env-api', name: 'API' }, + kickoffMessage: undefined, + debug: { + environmentSource: 'memory', + environmentPreferenceWeight: expect.any(Number), + }, + }, + }); + }); + + it('does not let Brain preference override an explicit environment mention', async () => { + mockGenerateTrackedNonTaskObject.mockResolvedValue({ + object: { + workspaceValue: 'Full Stack', + reasoning: 'The user explicitly requested Full Stack.', + confidence: 0.55, + needsExternalLookup: false, + externalReference: null, + }, + }); + + const result = await routeTask( + createContext({ + taskDescription: 'Run this in Full Stack', + availableEnvironments: [ + ...environments, + { + id: 'env-api', + name: 'API', + repositoryNames: ['acme/api'], + }, + ], + environmentPreference: { + environmentId: 'env-api', + acceptedCount: 0, + correctionCount: 3, + lastSelectedAt: new Date(), + }, + }), + ); + + expect(result).toMatchObject({ + status: 'routed', + result: { + workspace: { + type: 'environment', + id: 'env-full-stack', + name: 'Full Stack', + }, + debug: { environmentSource: 'router' }, + }, + }); + }); + it('fetches pasted GitHub issue context when the precheck asks for it', async () => { mockCallRouterMcpTool.mockResolvedValue({ title: 'Fix the dashboard refresh failure', diff --git a/packages/cloud-agents/src/server/router/context-builders.ts b/packages/cloud-agents/src/server/router/context-builders.ts index 1189bda2b..1fd0d46bc 100644 --- a/packages/cloud-agents/src/server/router/context-builders.ts +++ b/packages/cloud-agents/src/server/router/context-builders.ts @@ -17,9 +17,22 @@ import type { LinearRoutingSource, GitHubRoutingSource, } from './types'; +import { getRoutingPreference } from './routing-preference-memory'; const DEFAULT_DEPLOYMENT_ID = 'default'; +function fetchRoutingPreference(params: { + userId?: string; + apiBaseUrl?: string; +}) { + return params.userId + ? getRoutingPreference({ + userId: params.userId, + apiBaseUrl: params.apiBaseUrl, + }) + : Promise.resolve(null); +} + async function fetchDeploymentRoutingSettings() { const deployment = await db.query.deploymentSettings.findFirst({ where: eq(deploymentSettings.id, DEFAULT_DEPLOYMENT_ID), @@ -123,10 +136,12 @@ export interface GitHubContextParams { export async function buildSlackRoutingContext( params: SlackContextParams, ): Promise { - const [envs, deploymentRoutingSettings] = await Promise.all([ - getAvailableEnvironments(), - fetchDeploymentRoutingSettings(), - ]); + const [envs, deploymentRoutingSettings, environmentPreference] = + await Promise.all([ + getAvailableEnvironments(), + fetchDeploymentRoutingSettings(), + fetchRoutingPreference(params), + ]); const source: SlackRoutingSource = { type: 'slack', @@ -142,6 +157,7 @@ export async function buildSlackRoutingContext( source, availableEnvironments: envs, ...deploymentRoutingSettings, + ...(environmentPreference ? { environmentPreference } : {}), ...(params.userId ? { routingActor: { @@ -159,10 +175,12 @@ export async function buildSlackRoutingContext( export async function buildTeamsRoutingContext( params: TeamsContextParams, ): Promise { - const [envs, deploymentRoutingSettings] = await Promise.all([ - getAvailableEnvironments(), - fetchDeploymentRoutingSettings(), - ]); + const [envs, deploymentRoutingSettings, environmentPreference] = + await Promise.all([ + getAvailableEnvironments(), + fetchDeploymentRoutingSettings(), + fetchRoutingPreference(params), + ]); const source: TeamsRoutingSource = { type: 'teams', @@ -178,6 +196,7 @@ export async function buildTeamsRoutingContext( source, availableEnvironments: envs, ...deploymentRoutingSettings, + ...(environmentPreference ? { environmentPreference } : {}), ...(params.userId ? { routingActor: { @@ -195,10 +214,12 @@ export async function buildTeamsRoutingContext( export async function buildTelegramRoutingContext( params: TelegramContextParams, ): Promise { - const [envs, deploymentRoutingSettings] = await Promise.all([ - getAvailableEnvironments(), - fetchDeploymentRoutingSettings(), - ]); + const [envs, deploymentRoutingSettings, environmentPreference] = + await Promise.all([ + getAvailableEnvironments(), + fetchDeploymentRoutingSettings(), + fetchRoutingPreference(params), + ]); const source: TelegramRoutingSource = { type: 'telegram', @@ -213,6 +234,7 @@ export async function buildTelegramRoutingContext( source, availableEnvironments: envs, ...deploymentRoutingSettings, + ...(environmentPreference ? { environmentPreference } : {}), ...(params.userId ? { routingActor: { @@ -228,10 +250,12 @@ export async function buildTelegramRoutingContext( export async function buildDiscordRoutingContext( params: DiscordContextParams, ): Promise { - const [envs, deploymentRoutingSettings] = await Promise.all([ - getAvailableEnvironments(), - fetchDeploymentRoutingSettings(), - ]); + const [envs, deploymentRoutingSettings, environmentPreference] = + await Promise.all([ + getAvailableEnvironments(), + fetchDeploymentRoutingSettings(), + fetchRoutingPreference(params), + ]); const source: DiscordRoutingSource = { type: 'discord', guildName: params.guildName, @@ -245,6 +269,7 @@ export async function buildDiscordRoutingContext( source, availableEnvironments: envs, ...deploymentRoutingSettings, + ...(environmentPreference ? { environmentPreference } : {}), ...(params.userId ? { routingActor: { @@ -262,10 +287,12 @@ export async function buildDiscordRoutingContext( export async function buildLinearRoutingContext( params: LinearContextParams, ): Promise { - const [envs, deploymentRoutingSettings] = await Promise.all([ - getAvailableEnvironments(), - fetchDeploymentRoutingSettings(), - ]); + const [envs, deploymentRoutingSettings, environmentPreference] = + await Promise.all([ + getAvailableEnvironments(), + fetchDeploymentRoutingSettings(), + fetchRoutingPreference(params), + ]); const source: LinearRoutingSource = { type: 'linear', @@ -284,6 +311,7 @@ export async function buildLinearRoutingContext( source, availableEnvironments: envs, ...deploymentRoutingSettings, + ...(environmentPreference ? { environmentPreference } : {}), ...(params.userId ? { routingActor: { diff --git a/packages/cloud-agents/src/server/router/follow-up-service.ts b/packages/cloud-agents/src/server/router/follow-up-service.ts index 75b456796..1eb220223 100644 --- a/packages/cloud-agents/src/server/router/follow-up-service.ts +++ b/packages/cloud-agents/src/server/router/follow-up-service.ts @@ -1,4 +1,8 @@ import { classifyFollowUp, routeTask } from './router-service'; +import { + normalizeRoutingPreferenceEnvironmentId, + recordRoutingPreference, +} from './routing-preference-memory'; import { MAX_THREAD_MESSAGES } from './types'; import type { RoutingContext, RoutingDecision } from './types'; @@ -7,6 +11,12 @@ type RoutingFollowUpResolution = | { intent: 'cancel' } | { intent: 'correct'; routingDecision: RoutingDecision }; +function getSuggestedEnvironmentId( + suggestion: RoutingContext['previousSuggestion'] | null, +): string | null { + return normalizeRoutingPreferenceEnvironmentId(suggestion?.workspaceValue); +} + /** * Resolve a reply to a pending routing suggestion for every chat surface. * The expensive correction context is lazy because confirms and cancels do @@ -16,6 +26,7 @@ export async function resolveRoutingFollowUp(input: { suggestion: RoutingContext['previousSuggestion'] | null; userResponse: string; userId?: string | null; + apiBaseUrl?: string; correctionMessage?: { user: string; text: string }; buildCorrectionContext: () => Promise; }): Promise { @@ -33,6 +44,15 @@ export async function resolveRoutingFollowUp(input: { // A picker has no proposed choice to affirm. Treat an apparent confirmation // as a correction so the router can interpret the reply safely. if (classification.intent === 'confirm' && input.suggestion) { + const environmentId = getSuggestedEnvironmentId(input.suggestion); + if (input.userId && environmentId) { + await recordRoutingPreference({ + userId: input.userId, + apiBaseUrl: input.apiBaseUrl, + environmentId, + signal: 'accepted', + }); + } return { intent: 'confirm' }; } @@ -68,8 +88,26 @@ export async function resolveRoutingFollowUp(input: { ...(input.suggestion ? { previousSuggestion: input.suggestion } : {}), }; + const routingDecision = await routeTask(routingContext); + const suggestedEnvironmentId = getSuggestedEnvironmentId(input.suggestion); + + if ( + routingDecision.status === 'routed' && + routingDecision.result.workspace.type === 'environment' && + !classification.isFallback && + input.userId && + suggestedEnvironmentId !== routingDecision.result.workspace.id + ) { + await recordRoutingPreference({ + userId: input.userId, + apiBaseUrl: routingContext.routingActor?.apiBaseUrl ?? input.apiBaseUrl, + environmentId: routingDecision.result.workspace.id, + signal: 'corrected', + }); + } + return { intent: 'correct', - routingDecision: await routeTask(routingContext), + routingDecision, }; } diff --git a/packages/cloud-agents/src/server/router/index.ts b/packages/cloud-agents/src/server/router/index.ts index 151eac73c..fb09621f7 100644 --- a/packages/cloud-agents/src/server/router/index.ts +++ b/packages/cloud-agents/src/server/router/index.ts @@ -43,6 +43,10 @@ export { export { routeTask, routeGitHubTask, classifyFollowUp } from './router-service'; export { resolveRoutingFollowUp } from './follow-up-service'; +export { + normalizeRoutingPreferenceEnvironmentId, + recordRoutingPreference, +} from './routing-preference-memory'; export { evaluateChannelLaunchCriteria } from './channel-launch-gate'; export type { ChannelLaunchGateActivityEntry, diff --git a/packages/cloud-agents/src/server/router/mcp-policy.ts b/packages/cloud-agents/src/server/router/mcp-policy.ts index 320962cea..9a4834620 100644 --- a/packages/cloud-agents/src/server/router/mcp-policy.ts +++ b/packages/cloud-agents/src/server/router/mcp-policy.ts @@ -4,6 +4,8 @@ import { parseDiscordMessagePermalink, parseSlackChannelPermalink, parseSlackMessagePermalink, + ROUTING_PREFERENCE_GET_TOOL, + ROUTING_PREFERENCE_RECORD_TOOL, } from '@roomote/types'; export const ROUTER_MCP_ENABLED_SERVER_IDS = [ @@ -69,6 +71,8 @@ const ROUTER_ROOMOTE_ALLOWED_TOOLS = [ 'get_about_me', CHAT_CHANNEL_MESSAGES_TOOL.name, CHAT_MESSAGE_CONTEXT_TOOL.name, + ROUTING_PREFERENCE_GET_TOOL, + ROUTING_PREFERENCE_RECORD_TOOL, ] as const; const ROUTER_LINEAR_ALLOWED_TOOLS = [ diff --git a/packages/cloud-agents/src/server/router/router-service.ts b/packages/cloud-agents/src/server/router/router-service.ts index bab5f11b8..a6b4c0950 100644 --- a/packages/cloud-agents/src/server/router/router-service.ts +++ b/packages/cloud-agents/src/server/router/router-service.ts @@ -121,6 +121,93 @@ function isPlatformWorkspaceSelection(value: string): boolean { */ const MODEL_PREFERENCE_MIN_CONFIDENCE = 0.9; +// Routing memory is a bounded tie-breaker, never a replacement for an explicit +// environment choice or a confident router decision. +const ENVIRONMENT_PREFERENCE_MAX_ROUTER_CONFIDENCE = 0.8; +const ENVIRONMENT_PREFERENCE_HALF_LIFE_DAYS = 30; +const ENVIRONMENT_PREFERENCE_MIN_WEIGHT = 0.45; +const ENVIRONMENT_PREFERENCE_MAX_CORRECTIONS = 3; +const ENVIRONMENT_PREFERENCE_MAX_ACCEPTANCES = 4; +const ENVIRONMENT_PREFERENCE_ACCEPTANCE_WEIGHT = 0.25; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function hasExplicitEnvironmentMention(context: RoutingContext): boolean { + return context.availableEnvironments.some((environment) => { + const name = environment.name.trim(); + return ( + name.length > 0 && + new RegExp(`(^|[^a-z0-9])${escapeRegExp(name)}($|[^a-z0-9])`, 'i').test( + context.taskDescription, + ) + ); + }); +} + +function getEnvironmentPreferenceWeight( + preference: NonNullable, + now = new Date(), +): number { + const ageDays = + Math.max(0, now.getTime() - preference.lastSelectedAt.getTime()) / + (24 * 60 * 60 * 1000); + const recencyWeight = 2 ** (-ageDays / ENVIRONMENT_PREFERENCE_HALF_LIFE_DAYS); + const signalWeight = + Math.min( + preference.correctionCount, + ENVIRONMENT_PREFERENCE_MAX_CORRECTIONS, + ) + + Math.min(preference.acceptedCount, ENVIRONMENT_PREFERENCE_MAX_ACCEPTANCES) * + ENVIRONMENT_PREFERENCE_ACCEPTANCE_WEIGHT; + + return signalWeight * recencyWeight; +} + +function applyEnvironmentPreference( + context: RoutingContext, + result: RoutingResult, + confidence: number | null, +): { result: RoutingResult; weight?: number } { + const preference = context.environmentPreference; + + if ( + !preference || + context.previousSuggestion || + confidence === null || + confidence >= ENVIRONMENT_PREFERENCE_MAX_ROUTER_CONFIDENCE || + result.workspace.type !== 'environment' || + result.workspace.id === preference.environmentId || + hasExplicitEnvironmentMention(context) + ) { + return { result }; + } + + const preferredEnvironment = context.availableEnvironments.find( + (environment) => environment.id === preference.environmentId, + ); + const weight = getEnvironmentPreferenceWeight(preference); + + if (!preferredEnvironment || weight < ENVIRONMENT_PREFERENCE_MIN_WEIGHT) { + return { result }; + } + + return { + result: { + ...result, + workspace: { + type: 'environment', + id: preferredEnvironment.id, + name: preferredEnvironment.name, + }, + // The generated kickoff can name the original environment. + kickoffMessage: undefined, + }, + weight, + }; +} + /** * Resolves the routed task model from the LLM's `requestedModelId` pick, the * previous correction suggestion, and the deployment default. The LLM must @@ -528,12 +615,33 @@ export async function routeTask( return fallbackDecision; } + const preferenceResolution = + decision.status === 'routed' + ? applyEnvironmentPreference(context, decision.result, confidence) + : undefined; + + if ( + decision.status === 'routed' && + preferenceResolution?.weight !== undefined + ) { + decision.result = preferenceResolution.result; + } + const debug: RoutingDebugInfo = { phase, toolsUsed, needsExternalLookup, confidence: decision.status === 'routed' ? confidence : null, workspaceRemapped, + ...(decision.status === 'routed' && + preferenceResolution?.weight !== undefined + ? { + environmentSource: 'memory' as const, + environmentPreferenceWeight: preferenceResolution.weight, + } + : decision.status === 'routed' + ? { environmentSource: 'router' as const } + : {}), }; switch (decision.status) { @@ -723,6 +831,7 @@ export async function classifyFollowUp(params: { return { intent: 'correct', reasoning: `Classification failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + isFallback: true, }; } } diff --git a/packages/cloud-agents/src/server/router/routing-preference-memory.ts b/packages/cloud-agents/src/server/router/routing-preference-memory.ts new file mode 100644 index 000000000..620e2de1b --- /dev/null +++ b/packages/cloud-agents/src/server/router/routing-preference-memory.ts @@ -0,0 +1,154 @@ +import { createAuthToken } from '@roomote/auth'; +import { Env, isBrainConfigured } from '@roomote/env'; +import { + ROUTING_PREFERENCE_GET_TOOL, + ROUTING_PREFERENCE_RECORD_TOOL, + type RoutingPreferenceSignal, +} from '@roomote/types'; +import { z } from 'zod'; + +import { resolveApiBaseUrl } from '../shared-utils'; +import { callMcpTool } from '../mcp-tool-client'; +import type { RoutingEnvironmentPreference } from './types'; + +const routingPreferenceResponseSchema = z.object({ + preference: z + .object({ + environmentId: z.string().min(1), + acceptedCount: z.number().int().nonnegative(), + correctionCount: z.number().int().nonnegative(), + lastSelectedAt: z.string().datetime(), + }) + .nullable(), +}); + +const ROUTING_PREFERENCE_TIMEOUT_MS = 500; + +export function normalizeRoutingPreferenceEnvironmentId( + value: string | null | undefined, +): string | null { + const normalized = value?.trim(); + if ( + !normalized || + normalized === 'all_repositories' || + normalized === '__all_repositories__' || + normalized.startsWith('repo:') + ) { + return null; + } + + if (normalized.startsWith('env:')) { + return normalized.slice('env:'.length).trim() || null; + } + + return normalized; +} + +async function withRoutingPreferenceTimeout( + operation: () => Promise, +): Promise { + let timeout: ReturnType | undefined; + + try { + return await Promise.race([ + operation(), + new Promise((resolve) => { + timeout = setTimeout(resolve, ROUTING_PREFERENCE_TIMEOUT_MS, null); + }), + ]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} + +async function callRoutingPreferenceTool(options: { + userId: string; + apiBaseUrl?: string; + toolName: + | typeof ROUTING_PREFERENCE_GET_TOOL + | typeof ROUTING_PREFERENCE_RECORD_TOOL; + args?: Record; +}): Promise { + const apiBaseUrl = resolveApiBaseUrl(options.apiBaseUrl); + if (!apiBaseUrl) { + return null; + } + + const authToken = await createAuthToken({ + userId: options.userId, + timeoutMs: 2 * 60_000, + }); + + return callMcpTool({ + url: `${apiBaseUrl}/api/mcp-routing/roomote`, + headers: { Authorization: `Bearer ${authToken}` }, + toolName: options.toolName, + args: options.args, + toolCallId: `router-mcp:roomote:${options.toolName}`, + }); +} + +export async function getRoutingPreference(options: { + userId: string; + apiBaseUrl?: string; +}): Promise { + if (!isBrainConfigured(Env)) { + return null; + } + + try { + const parsed = routingPreferenceResponseSchema.safeParse( + await withRoutingPreferenceTimeout(() => + callRoutingPreferenceTool({ + ...options, + toolName: ROUTING_PREFERENCE_GET_TOOL, + }), + ), + ); + + if (!parsed.success || !parsed.data.preference) { + return null; + } + + return { + ...parsed.data.preference, + lastSelectedAt: new Date(parsed.data.preference.lastSelectedAt), + }; + } catch (error) { + console.warn( + `[RoutingPreference] Brain read failed: ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } +} + +export async function recordRoutingPreference(options: { + userId: string; + apiBaseUrl?: string; + environmentId: string; + signal: RoutingPreferenceSignal; +}): Promise { + if (!isBrainConfigured(Env)) { + return; + } + + try { + await withRoutingPreferenceTimeout(() => + callRoutingPreferenceTool({ + userId: options.userId, + apiBaseUrl: options.apiBaseUrl, + toolName: ROUTING_PREFERENCE_RECORD_TOOL, + args: { + environmentId: options.environmentId, + signal: options.signal, + }, + }), + ); + } catch (error) { + console.warn( + `[RoutingPreference] Brain write failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/packages/cloud-agents/src/server/router/types.ts b/packages/cloud-agents/src/server/router/types.ts index bb614ed2c..1a9a81dfa 100644 --- a/packages/cloud-agents/src/server/router/types.ts +++ b/packages/cloud-agents/src/server/router/types.ts @@ -32,6 +32,13 @@ export const MAX_THREAD_MESSAGES = 5; export const PLATFORM_WORKSPACE_VALUE = '__platform__'; +export interface RoutingEnvironmentPreference { + environmentId: string; + acceptedCount: number; + correctionCount: number; + lastSelectedAt: Date; +} + /** * Context provided to the router for making routing decisions. */ @@ -55,6 +62,8 @@ export interface RoutingContext { userId: string; apiBaseUrl?: string; }; + /** Exact Brain-backed preference used only to break uncertain routes. */ + environmentPreference?: RoutingEnvironmentPreference | null; previousSuggestion?: { workspaceValue: string | null; workspaceDisplayName: string; @@ -175,6 +184,8 @@ export interface RoutingDebugInfo { needsExternalLookup: boolean | null; confidence?: number | null; workspaceRemapped?: boolean; + environmentSource?: 'router' | 'memory'; + environmentPreferenceWeight?: number; selectedTaskModel?: RoutingTaskModelSelection; } @@ -276,4 +287,6 @@ export type FollowUpIntent = 'confirm' | 'cancel' | 'correct'; export interface FollowUpClassification { intent: FollowUpIntent; reasoning: string; + /** True when classification fell back after an inference failure. */ + isFallback?: boolean; } diff --git a/packages/slack/src/__tests__/show-task-configuration.test.ts b/packages/slack/src/__tests__/show-task-configuration.test.ts index 3f0693b06..1a1a5965e 100644 --- a/packages/slack/src/__tests__/show-task-configuration.test.ts +++ b/packages/slack/src/__tests__/show-task-configuration.test.ts @@ -5,6 +5,7 @@ const { findActiveSlackTaskRunMock, classifyFollowUpMock, resolveRoutingFollowUpMock, + recordRoutingPreferenceMock, getTaskUrlMock, repositoriesFindManyMock, environmentsFindManyMock, @@ -37,6 +38,7 @@ const { findActiveSlackTaskRunMock: vi.fn(), classifyFollowUpMock: vi.fn(), resolveRoutingFollowUpMock: vi.fn(), + recordRoutingPreferenceMock: vi.fn(), getTaskUrlMock: vi.fn(), repositoriesFindManyMock: vi.fn(), environmentsFindManyMock: vi.fn(), @@ -70,6 +72,9 @@ vi.mock('@roomote/cloud-agents/server', () => ({ enqueueTask: enqueueTaskMock, classifyFollowUp: classifyFollowUpMock, resolveRoutingFollowUp: resolveRoutingFollowUpMock, + normalizeRoutingPreferenceEnvironmentId: (value?: string | null) => + value?.startsWith('env:') ? value.slice(4) : (value ?? null), + recordRoutingPreference: recordRoutingPreferenceMock, detectSlackMcpSetupRequirement: vi.fn().mockResolvedValue(null), getRoutingAutoConfirmDelayMs: vi.fn(() => 0), getTaskUrl: getTaskUrlMock, @@ -420,6 +425,7 @@ describe('Slack deleted-mention suppression', () => { }), expect.anything(), ); + expect(recordRoutingPreferenceMock).not.toHaveBeenCalled(); }); it('includes existing replies when a task starts from the thread root', async () => { @@ -561,6 +567,12 @@ describe('Slack deleted-mention suppression', () => { }), expect.anything(), ); + expect(recordRoutingPreferenceMock).toHaveBeenCalledWith({ + userId: 'user_1', + apiBaseUrl: expect.any(String), + environmentId: 'env_1', + signal: 'accepted', + }); }); it('filters eval environments out of the workspace picker query', async () => { diff --git a/packages/slack/src/block-kit.ts b/packages/slack/src/block-kit.ts index 64f73c634..84e9dbc66 100644 --- a/packages/slack/src/block-kit.ts +++ b/packages/slack/src/block-kit.ts @@ -54,6 +54,8 @@ import { resolveRoutingFollowUp, buildSlackRoutingContext, getRoutingAutoConfirmDelayMs, + normalizeRoutingPreferenceEnvironmentId, + recordRoutingPreference, } from '@roomote/cloud-agents/server'; import type { @@ -1737,6 +1739,21 @@ export async function handleTaskConfiguration( }, }); + if (environmentId) { + const suggestedEnvironmentId = normalizeRoutingPreferenceEnvironmentId( + prefill?.workspaceValue, + ); + await recordRoutingPreference({ + userId: userMapping.userId, + apiBaseUrl: Env.TRPC_URL, + environmentId, + signal: + suggestedEnvironmentId && suggestedEnvironmentId !== environmentId + ? 'corrected' + : 'accepted', + }); + } + if (taskRun.reusedExistingRun) { await postSlackInteractiveResponse(payload.response_url, { replace_original: true, @@ -2437,6 +2454,7 @@ export async function handleSlackRoutingCorrection({ }, userResponse: correctionText, userId: userMapping.userId, + apiBaseUrl: Env.TRPC_URL, correctionMessage: { user: event.user, text: correctionWithoutMention, @@ -2890,6 +2908,19 @@ export async function handleRoutingConfirmOk(payload: SlackInteractivePayload) { return; } + const environmentId = + prefill.workspaceType === 'environment' + ? normalizeRoutingPreferenceEnvironmentId(prefill.workspaceValue) + : null; + if (environmentId) { + await recordRoutingPreference({ + userId: userMapping.userId, + apiBaseUrl: Env.TRPC_URL, + environmentId, + signal: 'accepted', + }); + } + if (result.reusedExistingRun) { await postSlackInteractiveResponse(payload.response_url, { replace_original: true, diff --git a/packages/types/src/brain.ts b/packages/types/src/brain.ts index e08fc8274..069014d39 100644 --- a/packages/types/src/brain.ts +++ b/packages/types/src/brain.ts @@ -16,6 +16,19 @@ export const BRAIN_MCP_ID = 'gbrain'; /** API proxy mount; shared by SDK config delivery and the worker. */ export const BRAIN_PROXY_PATH = '/api/mcp/gbrain'; +/** Router-only Roomote MCP tools backed by exact Brain pages. */ +export const ROUTING_PREFERENCE_GET_TOOL = 'get_routing_preference'; +export const ROUTING_PREFERENCE_RECORD_TOOL = 'record_routing_preference'; + +export type RoutingPreferenceSignal = 'accepted' | 'corrected'; + +export interface RoutingPreferenceMemory { + environmentId: string; + acceptedCount: number; + correctionCount: number; + lastSelectedAt: string; +} + /** * Usage guidance injected into the agent's instruction files when the Brain * MCP server is attached. Prompts are a first-class control surface: both From ce275be21e19fbbd3fc7c24f526c060144bdddc0 Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Sun, 16 Aug 2026 02:52:39 +0000 Subject: [PATCH 2/3] chore: remove unused routing preference export --- apps/api/src/handlers/mcp/routing-preference-memory.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/apps/api/src/handlers/mcp/routing-preference-memory.ts b/apps/api/src/handlers/mcp/routing-preference-memory.ts index f4249fa26..182be8169 100644 --- a/apps/api/src/handlers/mcp/routing-preference-memory.ts +++ b/apps/api/src/handlers/mcp/routing-preference-memory.ts @@ -1,7 +1,5 @@ import { resolveBrainConnection } from '@roomote/sdk/server'; import { - ROUTING_PREFERENCE_GET_TOOL, - ROUTING_PREFERENCE_RECORD_TOOL, type RoutingPreferenceMemory, type RoutingPreferenceSignal, } from '@roomote/types'; @@ -128,8 +126,3 @@ export async function recordRoutingPreferenceMemory(input: { return preference; } - -export const ROUTING_PREFERENCE_TOOL_NAMES = [ - ROUTING_PREFERENCE_GET_TOOL, - ROUTING_PREFERENCE_RECORD_TOOL, -] as const; From 3e7b06be4e9bfebd3d5fb7c344f3faf32ba406a7 Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Sun, 16 Aug 2026 04:18:20 +0000 Subject: [PATCH 3/3] fix: preserve configured repository routing signals --- .../router/__tests__/router-service.test.ts | 50 +++++++++++++++++++ .../src/server/router/router-service.ts | 21 ++++++-- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/packages/cloud-agents/src/server/router/__tests__/router-service.test.ts b/packages/cloud-agents/src/server/router/__tests__/router-service.test.ts index c5967d50d..dd69bc1a9 100644 --- a/packages/cloud-agents/src/server/router/__tests__/router-service.test.ts +++ b/packages/cloud-agents/src/server/router/__tests__/router-service.test.ts @@ -194,6 +194,56 @@ describe('routeTask', () => { }); }); + it.each([ + 'Investigate acme/web#42', + 'Investigate https://github.com/acme/web/issues/42', + ])( + 'does not let Brain preference override configured repository reference: %s', + async (taskDescription) => { + mockGenerateTrackedNonTaskObject.mockResolvedValue({ + object: { + workspaceValue: 'Full Stack', + reasoning: 'The configured repository belongs to Full Stack.', + confidence: 0.55, + needsExternalLookup: false, + externalReference: null, + }, + }); + + const result = await routeTask( + createContext({ + taskDescription, + availableEnvironments: [ + ...environments, + { + id: 'env-api', + name: 'API', + repositoryNames: ['acme/api'], + }, + ], + environmentPreference: { + environmentId: 'env-api', + acceptedCount: 0, + correctionCount: 3, + lastSelectedAt: new Date(), + }, + }), + ); + + expect(result).toMatchObject({ + status: 'routed', + result: { + workspace: { + type: 'environment', + id: 'env-full-stack', + name: 'Full Stack', + }, + debug: { environmentSource: 'router' }, + }, + }); + }, + ); + it('fetches pasted GitHub issue context when the precheck asks for it', async () => { mockCallRouterMcpTool.mockResolvedValue({ title: 'Fix the dashboard refresh failure', diff --git a/packages/cloud-agents/src/server/router/router-service.ts b/packages/cloud-agents/src/server/router/router-service.ts index a6b4c0950..68b5c9240 100644 --- a/packages/cloud-agents/src/server/router/router-service.ts +++ b/packages/cloud-agents/src/server/router/router-service.ts @@ -134,14 +134,27 @@ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -function hasExplicitEnvironmentMention(context: RoutingContext): boolean { +function hasExplicitEnvironmentSignal(context: RoutingContext): boolean { return context.availableEnvironments.some((environment) => { const name = environment.name.trim(); - return ( + const hasEnvironmentMention = name.length > 0 && new RegExp(`(^|[^a-z0-9])${escapeRegExp(name)}($|[^a-z0-9])`, 'i').test( context.taskDescription, - ) + ); + + return ( + hasEnvironmentMention || + environment.repositoryNames.some((repositoryName) => { + const repository = repositoryName.trim(); + return ( + repository.length > 0 && + new RegExp( + `(^|[^a-z0-9_.-])${escapeRegExp(repository)}(?:\\.git)?($|[^a-z0-9_.-])`, + 'i', + ).test(context.taskDescription) + ); + }) ); }); } @@ -179,7 +192,7 @@ function applyEnvironmentPreference( confidence >= ENVIRONMENT_PREFERENCE_MAX_ROUTER_CONFIDENCE || result.workspace.type !== 'environment' || result.workspace.id === preference.environmentId || - hasExplicitEnvironmentMention(context) + hasExplicitEnvironmentSignal(context) ) { return { result }; }