diff --git a/packages/cloud-agents/scripts/verify-reland.ts b/packages/cloud-agents/scripts/verify-reland.ts new file mode 100644 index 00000000..4c7cac97 --- /dev/null +++ b/packages/cloud-agents/scripts/verify-reland.ts @@ -0,0 +1,43 @@ +#!/usr/bin/env npx tsx +import { routeTask } from '../src/server/router/router-service'; +import type { RoutingContext } from '../src/server/router/types'; + +const context: RoutingContext = { + taskDescription: 'Review the README in the Roomote repo', + source: { type: 'slack', channelName: 'engineering' }, + availableEnvironments: [ + { + id: 'env-roomote', + name: 'Roomote', + description: 'The main Roomote product — web app, API server, workers', + repositoryNames: ['RooCodeInc/Roomote'], + }, + { + id: 'env-website', + name: 'Roomote Website', + description: 'Astro marketing website for Roomote', + repositoryNames: ['RooCodeInc/roomote-website'], + }, + ], +}; + +const decision = await routeTask(context); +console.log( + JSON.stringify( + { + R_ROUTER_MODEL: process.env.R_ROUTER_MODEL ?? null, + status: decision.status, + ...(decision.status === 'routed' + ? { + workspace: decision.result.workspace, + model: decision.result.debug ? undefined : undefined, + debug: decision.result.debug, + } + : {}), + ...(decision.status === 'fallback' ? { reason: decision.reason } : {}), + }, + null, + 2, + ), +); +process.exit(0); 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 0142435f..449a4222 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,20 +106,30 @@ describe('routeTask', () => { }); }); - it('uses pasted GitHub issue context when choosing a workspace', async () => { + it('fetches pasted GitHub issue context when the precheck asks for it', async () => { mockCallRouterMcpTool.mockResolvedValue({ title: 'Fix the dashboard refresh failure', body: 'The dashboard API request belongs to the web application.', }); - mockGenerateTrackedNonTaskObject.mockResolvedValue({ - object: { - workspaceValue: 'Full Stack', - reasoning: 'The linked issue describes the web application.', - confidence: 0.92, - needsExternalLookup: false, - externalReference: null, - }, - }); + mockGenerateTrackedNonTaskObject + .mockResolvedValueOnce({ + object: { + workspaceValue: 'Full Stack', + reasoning: 'The message alone does not identify the workspace.', + confidence: 0.4, + needsExternalLookup: true, + externalReference: 'acme/web#42', + }, + }) + .mockResolvedValueOnce({ + object: { + workspaceValue: 'Full Stack', + reasoning: 'The linked issue describes the web application.', + confidence: 0.92, + needsExternalLookup: false, + externalReference: null, + }, + }); const result = await routeTask( createContext({ @@ -142,7 +152,8 @@ describe('routeTask', () => { issue_number: 42, }, }); - expect(mockGenerateTrackedNonTaskObject).toHaveBeenCalledWith( + expect(mockGenerateTrackedNonTaskObject).toHaveBeenCalledTimes(2); + expect(mockGenerateTrackedNonTaskObject).toHaveBeenLastCalledWith( expect.objectContaining({ prompt: expect.stringContaining('Fix the dashboard refresh failure'), }), @@ -153,6 +164,71 @@ describe('routeTask', () => { debug: { phase: 'mcp', toolsUsed: ['github.issue_read'], + needsExternalLookup: true, + }, + }, + }); + }); + + it('skips the issue fetch when the precheck routes without external context', async () => { + mockGenerateTrackedNonTaskObject.mockResolvedValue({ + object: { + workspaceValue: 'Full Stack', + reasoning: 'The message already identifies the dashboard work.', + confidence: 0.95, + needsExternalLookup: false, + externalReference: null, + }, + }); + + const result = await routeTask( + createContext({ + taskDescription: + 'Fix the dashboard refresh bug, context: https://github.com/acme/web/issues/42', + }), + ); + + expect(mockCallRouterMcpTool).not.toHaveBeenCalled(); + expect(mockGenerateTrackedNonTaskObject).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + status: 'routed', + result: { + debug: { + phase: 'direct', + toolsUsed: [], + needsExternalLookup: false, + }, + }, + }); + }); + + it('keeps the precheck decision when the requested fetch returns nothing', async () => { + mockCallRouterMcpTool.mockRejectedValue(new Error('Not connected')); + mockGenerateTrackedNonTaskObject.mockResolvedValue({ + object: { + workspaceValue: 'Full Stack', + reasoning: 'Best guess without the linked issue.', + confidence: 0.4, + needsExternalLookup: true, + externalReference: 'acme/web#42', + }, + }); + + const result = await routeTask( + createContext({ + taskDescription: + 'Please investigate https://github.com/acme/web/issues/42', + }), + ); + + expect(mockGenerateTrackedNonTaskObject).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + status: 'routed', + result: { + debug: { + phase: 'direct', + toolsUsed: [], + needsExternalLookup: true, }, }, }); diff --git a/packages/cloud-agents/src/server/router/mcp-gather.ts b/packages/cloud-agents/src/server/router/mcp-gather.ts index a769eb7c..1241e049 100644 --- a/packages/cloud-agents/src/server/router/mcp-gather.ts +++ b/packages/cloud-agents/src/server/router/mcp-gather.ts @@ -66,28 +66,48 @@ export async function gatherContextFromConfiguredMcps< z.infer & LookupAwareRoutingResponse > > { + const generateRoutingDecision = async (messages: ModelMessage[]) => { + const { object } = await generateTrackedNonTaskObject({ + userId: context.routingActor?.userId, + surface: NON_TASK_INFERENCE_SURFACES.routerTaskRouting, + model: routingModel, + schema: submitRoutingDecisionSchema, + system: routingPrompt, + prompt: serializeContextMessages(messages), + }); + + return object as z.infer & + LookupAwareRoutingResponse; + }; + + const response = await generateRoutingDecision(contextMessages); + const needsExternalLookup = + typeof response.needsExternalLookup === 'boolean' + ? response.needsExternalLookup + : null; + + if (needsExternalLookup !== true) { + return { response, toolsUsed: [], phase: 'direct', needsExternalLookup }; + } + + // The precheck asked for the linked issue, so the fetch deadline is only + // paid when it can change the decision. Fail-open: with nothing fetched, + // the precheck decision stands. const externalIssueContext = await gatherExternalIssueContext(context); - const { object } = await generateTrackedNonTaskObject({ - userId: context.routingActor?.userId, - surface: NON_TASK_INFERENCE_SURFACES.routerTaskRouting, - model: routingModel, - schema: submitRoutingDecisionSchema, - system: routingPrompt, - prompt: serializeContextMessages([ - ...contextMessages, - ...externalIssueContext.contextMessages, - ]), - }); - const response = object as z.infer & - LookupAwareRoutingResponse; + + if (externalIssueContext.contextMessages.length === 0) { + return { response, toolsUsed: [], phase: 'direct', needsExternalLookup }; + } + + const informedResponse = await generateRoutingDecision([ + ...contextMessages, + ...externalIssueContext.contextMessages, + ]); return { - response, + response: informedResponse, toolsUsed: externalIssueContext.toolsUsed, - phase: externalIssueContext.toolsUsed.length > 0 ? 'mcp' : 'direct', - needsExternalLookup: - typeof response.needsExternalLookup === 'boolean' - ? response.needsExternalLookup - : null, + phase: 'mcp', + needsExternalLookup: true, }; }