From 025341e80e1b89afef54280d607e796ad6ec1e83 Mon Sep 17 00:00:00 2001 From: Daniel Weis Date: Sun, 10 May 2026 02:28:35 +0000 Subject: [PATCH 01/12] add failing test demonstrating naive slicing --- .../numericalClassifierStrategy.test.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts index dcfdff786b4..4aeaf3905ad 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts @@ -505,6 +505,56 @@ describe('NumericalClassifierStrategy', () => { expect(contents).toHaveLength(9); }); + it('should adjust slice boundary to avoid severing a functionResponse from its preceding functionCall', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'initial request' }] }, + { role: 'model', parts: [{ functionCall: { name: 'test_tool' } }] }, + { + role: 'user', + parts: [ + { functionResponse: { name: 'test_tool', response: { ok: true } } }, + ], + }, + { role: 'model', parts: [{ text: 'tool output analyzed' }] }, + { role: 'user', parts: [{ text: 'next step' }] }, + { role: 'model', parts: [{ text: 'working on it' }] }, + { role: 'user', parts: [{ text: 'almost done?' }] }, + { role: 'model', parts: [{ text: 'yes' }] }, + { role: 'user', parts: [{ text: 'final check' }] }, + { role: 'model', parts: [{ text: 'all good' }] }, + ]; + mockContext.history = history; + const mockApiResponse = { + complexity_reasoning: 'Simple.', + complexity_score: 10, + }; + vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue( + mockApiResponse, + ); + + await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + mockLocalLiteRtLmClient, + ); + + const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock + .calls[0][0]; + const contents = generateJsonCall.contents; + + // Expect it to start at index 1 (functionCall) rather than index 2 (functionResponse) + const expectedContents = [ + ...history.slice(1), + { + role: 'user', + parts: [{ text: 'simple task' }], + }, + ]; + + expect(contents).toEqual(expectedContents); + }); + it('should use a fallback promptId if not found in context', async () => { const consoleWarnSpy = vi .spyOn(debugLogger, 'warn') From 15dc64e5909248361993203fdfc60c56d293cddc Mon Sep 17 00:00:00 2001 From: Daniel Weis Date: Sun, 10 May 2026 02:32:23 +0000 Subject: [PATCH 02/12] Add a fix for severing function calls midstream --- .../strategies/numericalClassifierStrategy.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.ts index 8bcfb3da678..2769e47b014 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.ts @@ -5,6 +5,10 @@ */ import { z } from 'zod'; +import { + isFunctionCall, + isFunctionResponse, +} from '../../utils/messageInspectors.js'; import type { BaseLlmClient } from '../../core/baseLlmClient.js'; import { getPromptIdWithFallback } from '../../utils/promptIdContext.js'; import type { @@ -115,7 +119,20 @@ export class NumericalClassifierStrategy implements RoutingStrategy { const promptId = getPromptIdWithFallback('classifier-router'); - const finalHistory = context.history.slice(-HISTORY_TURNS_FOR_CONTEXT); + let startIndex = Math.max( + 0, + context.history.length - HISTORY_TURNS_FOR_CONTEXT, + ); + // Ensure we don't sever a functionResponse from its preceding functionCall + while ( + startIndex > 0 && + startIndex < context.history.length && + isFunctionResponse(context.history[startIndex]) && + isFunctionCall(context.history[startIndex - 1]) + ) { + startIndex--; + } + const finalHistory = context.history.slice(startIndex); // Wrap the user's request in tags to prevent prompt injection const requestParts = Array.isArray(context.request) From 4091571a06ad862f03104e545b77dac21250a9ad Mon Sep 17 00:00:00 2001 From: Daniel Weis Date: Sun, 10 May 2026 02:34:33 +0000 Subject: [PATCH 03/12] add support for mixed parts and a test to cover that case --- .../numericalClassifierStrategy.test.ts | 56 +++++++++++++++++++ .../strategies/numericalClassifierStrategy.ts | 10 ++-- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts index 4aeaf3905ad..a4cf70aa96d 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts @@ -555,6 +555,62 @@ describe('NumericalClassifierStrategy', () => { expect(contents).toEqual(expectedContents); }); + it('should adjust slice boundary correctly even when the preceding functionCall turn contains mixed parts (text + functionCall)', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'initial request' }] }, + { + role: 'model', + parts: [ + { text: 'thinking about which tool to call...' }, + { functionCall: { name: 'test_tool' } }, + ], + }, + { + role: 'user', + parts: [ + { functionResponse: { name: 'test_tool', response: { ok: true } } }, + ], + }, + { role: 'model', parts: [{ text: 'tool output analyzed' }] }, + { role: 'user', parts: [{ text: 'next step' }] }, + { role: 'model', parts: [{ text: 'working on it' }] }, + { role: 'user', parts: [{ text: 'almost done?' }] }, + { role: 'model', parts: [{ text: 'yes' }] }, + { role: 'user', parts: [{ text: 'final check' }] }, + { role: 'model', parts: [{ text: 'all good' }] }, + ]; + mockContext.history = history; + const mockApiResponse = { + complexity_reasoning: 'Simple.', + complexity_score: 10, + }; + vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue( + mockApiResponse, + ); + + await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + mockLocalLiteRtLmClient, + ); + + const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock + .calls[0][0]; + const contents = generateJsonCall.contents; + + // Expect it to start at index 1 (mixed functionCall turn) rather than index 2 (functionResponse) + const expectedContents = [ + ...history.slice(1), + { + role: 'user', + parts: [{ text: 'simple task' }], + }, + ]; + + expect(contents).toEqual(expectedContents); + }); + it('should use a fallback promptId if not found in context', async () => { const consoleWarnSpy = vi .spyOn(debugLogger, 'warn') diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.ts index 2769e47b014..e2cf57ae10b 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.ts @@ -5,10 +5,6 @@ */ import { z } from 'zod'; -import { - isFunctionCall, - isFunctionResponse, -} from '../../utils/messageInspectors.js'; import type { BaseLlmClient } from '../../core/baseLlmClient.js'; import { getPromptIdWithFallback } from '../../utils/promptIdContext.js'; import type { @@ -127,8 +123,10 @@ export class NumericalClassifierStrategy implements RoutingStrategy { while ( startIndex > 0 && startIndex < context.history.length && - isFunctionResponse(context.history[startIndex]) && - isFunctionCall(context.history[startIndex - 1]) + context.history[startIndex].role === 'user' && + context.history[startIndex].parts?.some((p) => !!p.functionResponse) && + context.history[startIndex - 1].role === 'model' && + context.history[startIndex - 1].parts?.some((p) => !!p.functionCall) ) { startIndex--; } From c02057333aad40d309f872a678ec2c7f29d50036 Mon Sep 17 00:00:00 2001 From: Daniel Weis Date: Sun, 10 May 2026 04:56:04 +0000 Subject: [PATCH 04/12] Update to filter out all tool-related turns just like classifierStrategy does. In fact, share the code between them. Also leave a comment for anyone who wants to change this behavior in the future. --- .../routing/strategies/classifierStrategy.ts | 17 +++------- .../numericalClassifierStrategy.test.ts | 14 ++++---- .../strategies/numericalClassifierStrategy.ts | 20 +++--------- .../src/routing/strategies/strategyUtils.ts | 32 +++++++++++++++++++ 4 files changed, 48 insertions(+), 35 deletions(-) create mode 100644 packages/core/src/routing/strategies/strategyUtils.ts diff --git a/packages/core/src/routing/strategies/classifierStrategy.ts b/packages/core/src/routing/strategies/classifierStrategy.ts index 1dd09f45961..089c021b287 100644 --- a/packages/core/src/routing/strategies/classifierStrategy.ts +++ b/packages/core/src/routing/strategies/classifierStrategy.ts @@ -15,10 +15,7 @@ import type { import { resolveClassifierModel, isGemini3Model } from '../../config/models.js'; import { createUserContent, Type } from '@google/genai'; import type { Config } from '../../config/config.js'; -import { - isFunctionCall, - isFunctionResponse, -} from '../../utils/messageInspectors.js'; +import { getCleanHistorySlice } from './strategyUtils.js'; import { debugLogger } from '../../utils/debugLogger.js'; import type { LocalLiteRtLmClient } from '../../core/localLiteRtLmClient.js'; import { LlmRole } from '../../telemetry/types.js'; @@ -146,17 +143,11 @@ export class ClassifierStrategy implements RoutingStrategy { const promptId = getPromptIdWithFallback('classifier-router'); - const historySlice = context.history.slice(-HISTORY_SEARCH_WINDOW); - - // Filter out tool-related turns. - // TODO - Consider using function req/res if they help accuracy. - const cleanHistory = historySlice.filter( - (content) => !isFunctionCall(content) && !isFunctionResponse(content), + const finalHistory = getCleanHistorySlice( + context.history, + HISTORY_TURNS_FOR_CONTEXT, ); - // Take the last N turns from the *cleaned* history. - const finalHistory = cleanHistory.slice(-HISTORY_TURNS_FOR_CONTEXT); - const jsonResponse = await baseLlmClient.generateJson({ modelConfigKey: { model: 'classifier' }, contents: [...finalHistory, createUserContent(context.request)], diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts index a4cf70aa96d..c1899b06a17 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts @@ -505,7 +505,7 @@ describe('NumericalClassifierStrategy', () => { expect(contents).toHaveLength(9); }); - it('should adjust slice boundary to avoid severing a functionResponse from its preceding functionCall', async () => { + it('should completely filter out tool-related turns from history before slicing', async () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'initial request' }] }, { role: 'model', parts: [{ functionCall: { name: 'test_tool' } }] }, @@ -543,9 +543,10 @@ describe('NumericalClassifierStrategy', () => { .calls[0][0]; const contents = generateJsonCall.contents; - // Expect it to start at index 1 (functionCall) rather than index 2 (functionResponse) + // Expect tool turns (index 1 and 2) to be fully filtered out const expectedContents = [ - ...history.slice(1), + history[0], + ...history.slice(3), { role: 'user', parts: [{ text: 'simple task' }], @@ -555,7 +556,7 @@ describe('NumericalClassifierStrategy', () => { expect(contents).toEqual(expectedContents); }); - it('should adjust slice boundary correctly even when the preceding functionCall turn contains mixed parts (text + functionCall)', async () => { + it('should completely filter out tool-related turns correctly even when mixed parts are present', async () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'initial request' }] }, { @@ -599,9 +600,10 @@ describe('NumericalClassifierStrategy', () => { .calls[0][0]; const contents = generateJsonCall.contents; - // Expect it to start at index 1 (mixed functionCall turn) rather than index 2 (functionResponse) + // Expect tool turns (index 1 and 2) to be fully filtered out const expectedContents = [ - ...history.slice(1), + history[0], + ...history.slice(3), { role: 'user', parts: [{ text: 'simple task' }], diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.ts index e2cf57ae10b..9543858c13a 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.ts @@ -14,7 +14,7 @@ import type { } from '../routingStrategy.js'; import { resolveClassifierModel, isGemini3Model } from '../../config/models.js'; import { createUserContent, Type } from '@google/genai'; -import type { Config } from '../../config/config.js'; +import { getCleanHistorySlice } from './strategyUtils.js'; import { debugLogger } from '../../utils/debugLogger.js'; import type { LocalLiteRtLmClient } from '../../core/localLiteRtLmClient.js'; import { LlmRole } from '../../telemetry/types.js'; @@ -115,22 +115,10 @@ export class NumericalClassifierStrategy implements RoutingStrategy { const promptId = getPromptIdWithFallback('classifier-router'); - let startIndex = Math.max( - 0, - context.history.length - HISTORY_TURNS_FOR_CONTEXT, + const finalHistory = getCleanHistorySlice( + context.history, + HISTORY_TURNS_FOR_CONTEXT, ); - // Ensure we don't sever a functionResponse from its preceding functionCall - while ( - startIndex > 0 && - startIndex < context.history.length && - context.history[startIndex].role === 'user' && - context.history[startIndex].parts?.some((p) => !!p.functionResponse) && - context.history[startIndex - 1].role === 'model' && - context.history[startIndex - 1].parts?.some((p) => !!p.functionCall) - ) { - startIndex--; - } - const finalHistory = context.history.slice(startIndex); // Wrap the user's request in tags to prevent prompt injection const requestParts = Array.isArray(context.request) diff --git a/packages/core/src/routing/strategies/strategyUtils.ts b/packages/core/src/routing/strategies/strategyUtils.ts new file mode 100644 index 00000000000..6d74b4f8ab2 --- /dev/null +++ b/packages/core/src/routing/strategies/strategyUtils.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Content } from '@google/genai'; +import { + isFunctionCall, + isFunctionResponse, +} from '../../utils/messageInspectors.js'; + +/** + * Returns a cleaned slice of conversation history for routing classifiers. + * It strips out all tool-related turns to guarantee the context is text-only, + * avoiding backend validation failures on orphaned calls/responses, and takes + * exactly the last `maxTurns` turns. + * + * IMPORTANT: If we ever want to change this to include tool-related turns, + * we need to be extremely careful to ensure that they are not the very first + * parts in the history we send in the classifier request, as the backend explicitly + * rejects payloads where `contents[0]` is a function call or response. + */ +export function getCleanHistorySlice( + history: readonly Content[], + maxTurns: number, +): Content[] { + const cleanHistory = history.filter( + (content) => !isFunctionCall(content) && !isFunctionResponse(content), + ); + return cleanHistory.slice(-maxTurns); +} From 010917492fc86f64bff2ad35169af5b768c47a44 Mon Sep 17 00:00:00 2001 From: Daniel Weis Date: Sun, 10 May 2026 05:07:57 +0000 Subject: [PATCH 05/12] Add tests for middle and end boundary conditions of tool call-related turns in numerical classifier strategy test --- .../numericalClassifierStrategy.test.ts | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts index c1899b06a17..8bf65b7b0d5 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts @@ -613,6 +613,107 @@ describe('NumericalClassifierStrategy', () => { expect(contents).toEqual(expectedContents); }); + it('should preserve text turns both before and after tool-related turns when tools are in the middle', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'turn 0 (before)' }] }, + { role: 'model', parts: [{ text: 'turn 1 (before)' }] }, + { role: 'user', parts: [{ text: 'turn 2 (before)' }] }, + { role: 'model', parts: [{ text: 'turn 3 (before)' }] }, + { role: 'model', parts: [{ functionCall: { name: 'middle_tool' } }] }, + { + role: 'user', + parts: [ + { functionResponse: { name: 'middle_tool', response: { ok: true } } }, + ], + }, + { role: 'model', parts: [{ text: 'turn 6 (after)' }] }, + { role: 'user', parts: [{ text: 'turn 7 (after)' }] }, + { role: 'model', parts: [{ text: 'turn 8 (after)' }] }, + { role: 'user', parts: [{ text: 'turn 9 (after)' }] }, + ]; + mockContext.history = history; + const mockApiResponse = { + complexity_reasoning: 'Simple.', + complexity_score: 10, + }; + vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue( + mockApiResponse, + ); + + await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + mockLocalLiteRtLmClient, + ); + + const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock + .calls[0][0]; + const contents = generateJsonCall.contents; + + // Expect exactly the 4 turns before and 4 turns after to be preserved + const expectedContents = [ + ...history.slice(0, 4), + ...history.slice(6), + { + role: 'user', + parts: [{ text: 'simple task' }], + }, + ]; + + expect(contents).toEqual(expectedContents); + }); + + it('should preserve preceding text turns when tool-related turns are at the very end of history', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'turn 0' }] }, + { role: 'model', parts: [{ text: 'turn 1' }] }, + { role: 'user', parts: [{ text: 'turn 2' }] }, + { role: 'model', parts: [{ text: 'turn 3' }] }, + { role: 'user', parts: [{ text: 'turn 4' }] }, + { role: 'model', parts: [{ text: 'turn 5' }] }, + { role: 'user', parts: [{ text: 'turn 6' }] }, + { role: 'model', parts: [{ text: 'turn 7' }] }, + { role: 'model', parts: [{ functionCall: { name: 'end_tool' } }] }, + { + role: 'user', + parts: [ + { functionResponse: { name: 'end_tool', response: { ok: true } } }, + ], + }, + ]; + mockContext.history = history; + const mockApiResponse = { + complexity_reasoning: 'Simple.', + complexity_score: 10, + }; + vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue( + mockApiResponse, + ); + + await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + mockLocalLiteRtLmClient, + ); + + const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock + .calls[0][0]; + const contents = generateJsonCall.contents; + + // Expect exactly the 8 text turns before the tools to be preserved + const expectedContents = [ + ...history.slice(0, 8), + { + role: 'user', + parts: [{ text: 'simple task' }], + }, + ]; + + expect(contents).toEqual(expectedContents); + }); + it('should use a fallback promptId if not found in context', async () => { const consoleWarnSpy = vi .spyOn(debugLogger, 'warn') From 6fb8e8699a86040696231d319ef8d12489c98781 Mon Sep 17 00:00:00 2001 From: Daniel Weis Date: Sun, 10 May 2026 05:24:22 +0000 Subject: [PATCH 06/12] Add a test showing that the how the classifier handles filtering out all history if it's all tool-related turns --- .../numericalClassifierStrategy.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts index 8bf65b7b0d5..796ac837b43 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts @@ -714,6 +714,54 @@ describe('NumericalClassifierStrategy', () => { expect(contents).toEqual(expectedContents); }); + it('should send only the new request prompt if the entire history consists of tool-related turns', async () => { + const history: Content[] = [ + { role: 'model', parts: [{ functionCall: { name: 'tool_A' } }] }, + { + role: 'user', + parts: [ + { functionResponse: { name: 'tool_A', response: { ok: true } } }, + ], + }, + { role: 'model', parts: [{ functionCall: { name: 'tool_B' } }] }, + { + role: 'user', + parts: [ + { functionResponse: { name: 'tool_B', response: { ok: true } } }, + ], + }, + ]; + mockContext.history = history; + const mockApiResponse = { + complexity_reasoning: 'Simple standalone task.', + complexity_score: 10, + }; + vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue( + mockApiResponse, + ); + + await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + mockLocalLiteRtLmClient, + ); + + const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock + .calls[0][0]; + const contents = generateJsonCall.contents; + + // Expect all history turns to be filtered out, leaving exactly just the new request + const expectedContents = [ + { + role: 'user', + parts: [{ text: 'simple task' }], + }, + ]; + + expect(contents).toEqual(expectedContents); + }); + it('should use a fallback promptId if not found in context', async () => { const consoleWarnSpy = vi .spyOn(debugLogger, 'warn') From 3b0efd5767fe43e1f7a5a6b6ba5b86d6e9d46eb2 Mon Sep 17 00:00:00 2001 From: Daniel Weis Date: Sun, 10 May 2026 05:36:08 +0000 Subject: [PATCH 07/12] Preserve the HISTORY_TURNS_FOR_CONTEXT behavior and add some tests for that --- .../routing/strategies/classifierStrategy.ts | 1 - .../numericalClassifierStrategy.test.ts | 54 +++++++++++++++++-- .../strategies/numericalClassifierStrategy.ts | 2 +- .../src/routing/strategies/strategyUtils.ts | 6 ++- 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/packages/core/src/routing/strategies/classifierStrategy.ts b/packages/core/src/routing/strategies/classifierStrategy.ts index 089c021b287..89f3e2d66e9 100644 --- a/packages/core/src/routing/strategies/classifierStrategy.ts +++ b/packages/core/src/routing/strategies/classifierStrategy.ts @@ -22,7 +22,6 @@ import { LlmRole } from '../../telemetry/types.js'; // The number of recent history turns to provide to the router for context. const HISTORY_TURNS_FOR_CONTEXT = 4; -const HISTORY_SEARCH_WINDOW = 20; const FLASH_MODEL = 'flash'; const PRO_MODEL = 'pro'; diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts index 796ac837b43..dba9d3c576f 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { NumericalClassifierStrategy } from './numericalClassifierStrategy.js'; +import { NumericalClassifierStrategy, HISTORY_TURNS_FOR_CONTEXT } from './numericalClassifierStrategy.js'; import type { RoutingContext } from '../routingStrategy.js'; import type { Config } from '../../config/config.js'; import type { BaseLlmClient } from '../../core/baseLlmClient.js'; @@ -18,6 +18,7 @@ import { DEFAULT_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_MODEL, } from '../../config/models.js'; +import { HISTORY_SEARCH_WINDOW } from './strategyUtils.js'; import { promptIdContext } from '../../utils/promptIdContext.js'; import type { Content } from '@google/genai'; import type { ResolvedModelConfig } from '../../services/modelConfigService.js'; @@ -491,8 +492,6 @@ describe('NumericalClassifierStrategy', () => { .calls[0][0]; const contents = generateJsonCall.contents; - // Manually calculate what the history should be - const HISTORY_TURNS_FOR_CONTEXT = 8; const finalHistory = longHistory.slice(-HISTORY_TURNS_FOR_CONTEXT); // Last part is the request @@ -502,7 +501,7 @@ describe('NumericalClassifierStrategy', () => { }; expect(contents).toEqual([...finalHistory, requestPart]); - expect(contents).toHaveLength(9); + expect(contents).toHaveLength(HISTORY_TURNS_FOR_CONTEXT + 1); }); it('should completely filter out tool-related turns from history before slicing', async () => { @@ -762,6 +761,53 @@ describe('NumericalClassifierStrategy', () => { expect(contents).toEqual(expectedContents); }); + it('should respect HISTORY_SEARCH_WINDOW and HISTORY_TURNS_FOR_CONTEXT correctly', async () => { + const longHistory: Content[] = []; + for (let i = 0; i < 40; i++) { + longHistory.push({ role: 'user', parts: [{ text: `Message ${i}` }] }); + // Add noise that should be filtered + if (i % 2 === 0) { + longHistory.push({ + role: 'model', + parts: [{ functionCall: { name: 'noise', args: {} } }], + }); + } + } + mockContext.history = longHistory; + const mockApiResponse = { + complexity_reasoning: 'Simple.', + complexity_score: 10, + }; + vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue( + mockApiResponse, + ); + + await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + mockLocalLiteRtLmClient, + ); + + const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock + .calls[0][0]; + const contents = generateJsonCall.contents; + + // Manually calculate what the history should be + const historySlice = longHistory.slice(-HISTORY_SEARCH_WINDOW); + const cleanHistory = historySlice.filter( + (content) => !content.parts?.some((p) => !!p.functionCall || !!p.functionResponse), + ); + const finalHistory = cleanHistory.slice(-HISTORY_TURNS_FOR_CONTEXT); + + expect(contents).toEqual([ + ...finalHistory, + { role: 'user', parts: [{ text: 'simple task' }] }, + ]); + // Exactly HISTORY_TURNS_FOR_CONTEXT history turns plus the current request turn + expect(contents).toHaveLength(HISTORY_TURNS_FOR_CONTEXT + 1); + }); + it('should use a fallback promptId if not found in context', async () => { const consoleWarnSpy = vi .spyOn(debugLogger, 'warn') diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.ts index 9543858c13a..d591baea4ef 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.ts @@ -20,7 +20,7 @@ import type { LocalLiteRtLmClient } from '../../core/localLiteRtLmClient.js'; import { LlmRole } from '../../telemetry/types.js'; // The number of recent history turns to provide to the router for context. -const HISTORY_TURNS_FOR_CONTEXT = 8; +export const HISTORY_TURNS_FOR_CONTEXT = 8; const FLASH_MODEL = 'flash'; const PRO_MODEL = 'pro'; diff --git a/packages/core/src/routing/strategies/strategyUtils.ts b/packages/core/src/routing/strategies/strategyUtils.ts index 6d74b4f8ab2..2f90401f083 100644 --- a/packages/core/src/routing/strategies/strategyUtils.ts +++ b/packages/core/src/routing/strategies/strategyUtils.ts @@ -10,6 +10,9 @@ import { isFunctionResponse, } from '../../utils/messageInspectors.js'; +// The maximum number of recent history turns to scan before filtering. +export const HISTORY_SEARCH_WINDOW = 20; + /** * Returns a cleaned slice of conversation history for routing classifiers. * It strips out all tool-related turns to guarantee the context is text-only, @@ -25,7 +28,8 @@ export function getCleanHistorySlice( history: readonly Content[], maxTurns: number, ): Content[] { - const cleanHistory = history.filter( + const historySlice = history.slice(-HISTORY_SEARCH_WINDOW); + const cleanHistory = historySlice.filter( (content) => !isFunctionCall(content) && !isFunctionResponse(content), ); return cleanHistory.slice(-maxTurns); From 5253958202d2247c7c89a35167d8101b528bfb9b Mon Sep 17 00:00:00 2001 From: Daniel Weis Date: Sun, 10 May 2026 05:53:01 +0000 Subject: [PATCH 08/12] Update tests to pass. --- .../numericalClassifierStrategy.test.ts | 100 ------------------ .../src/routing/strategies/strategyUtils.ts | 2 + 2 files changed, 2 insertions(+), 100 deletions(-) diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts index dba9d3c576f..4e66fb71219 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts @@ -424,49 +424,6 @@ describe('NumericalClassifierStrategy', () => { expect(consoleWarnSpy).toHaveBeenCalled(); }); - it('should include tool-related history when sending to classifier', async () => { - mockContext.history = [ - { role: 'user', parts: [{ text: 'call a tool' }] }, - { role: 'model', parts: [{ functionCall: { name: 'test_tool' } }] }, - { - role: 'user', - parts: [ - { functionResponse: { name: 'test_tool', response: { ok: true } } }, - ], - }, - { role: 'user', parts: [{ text: 'another user turn' }] }, - ]; - const mockApiResponse = { - complexity_reasoning: 'Simple.', - complexity_score: 10, - }; - vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue( - mockApiResponse, - ); - - await strategy.route( - mockContext, - mockConfig, - mockBaseLlmClient, - mockLocalLiteRtLmClient, - ); - - const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock - .calls[0][0]; - const contents = generateJsonCall.contents; - - const expectedContents = [ - ...mockContext.history, - // The last user turn is the request part - { - role: 'user', - parts: [{ text: 'simple task' }], - }, - ]; - - expect(contents).toEqual(expectedContents); - }); - it('should respect HISTORY_TURNS_FOR_CONTEXT', async () => { const longHistory: Content[] = []; for (let i = 0; i < 30; i++) { @@ -555,63 +512,6 @@ describe('NumericalClassifierStrategy', () => { expect(contents).toEqual(expectedContents); }); - it('should completely filter out tool-related turns correctly even when mixed parts are present', async () => { - const history: Content[] = [ - { role: 'user', parts: [{ text: 'initial request' }] }, - { - role: 'model', - parts: [ - { text: 'thinking about which tool to call...' }, - { functionCall: { name: 'test_tool' } }, - ], - }, - { - role: 'user', - parts: [ - { functionResponse: { name: 'test_tool', response: { ok: true } } }, - ], - }, - { role: 'model', parts: [{ text: 'tool output analyzed' }] }, - { role: 'user', parts: [{ text: 'next step' }] }, - { role: 'model', parts: [{ text: 'working on it' }] }, - { role: 'user', parts: [{ text: 'almost done?' }] }, - { role: 'model', parts: [{ text: 'yes' }] }, - { role: 'user', parts: [{ text: 'final check' }] }, - { role: 'model', parts: [{ text: 'all good' }] }, - ]; - mockContext.history = history; - const mockApiResponse = { - complexity_reasoning: 'Simple.', - complexity_score: 10, - }; - vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue( - mockApiResponse, - ); - - await strategy.route( - mockContext, - mockConfig, - mockBaseLlmClient, - mockLocalLiteRtLmClient, - ); - - const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock - .calls[0][0]; - const contents = generateJsonCall.contents; - - // Expect tool turns (index 1 and 2) to be fully filtered out - const expectedContents = [ - history[0], - ...history.slice(3), - { - role: 'user', - parts: [{ text: 'simple task' }], - }, - ]; - - expect(contents).toEqual(expectedContents); - }); - it('should preserve text turns both before and after tool-related turns when tools are in the middle', async () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'turn 0 (before)' }] }, diff --git a/packages/core/src/routing/strategies/strategyUtils.ts b/packages/core/src/routing/strategies/strategyUtils.ts index 2f90401f083..5e2713c06ba 100644 --- a/packages/core/src/routing/strategies/strategyUtils.ts +++ b/packages/core/src/routing/strategies/strategyUtils.ts @@ -5,6 +5,8 @@ */ import type { Content } from '@google/genai'; + + import { isFunctionCall, isFunctionResponse, From e2a440e4430acf4272d56c4871ecf20369d5b0cd Mon Sep 17 00:00:00 2001 From: Daniel Weis Date: Sun, 10 May 2026 06:08:18 +0000 Subject: [PATCH 09/12] Looks like numericalClassifierStrategy was intentionally sending function calls so changing tack a bit --- .../routing/strategies/classifierStrategy.ts | 18 ++++- .../numericalClassifierStrategy.test.ts | 76 ++++++------------- .../strategies/numericalClassifierStrategy.ts | 22 ++++-- .../src/routing/strategies/strategyUtils.ts | 38 ---------- 4 files changed, 53 insertions(+), 101 deletions(-) delete mode 100644 packages/core/src/routing/strategies/strategyUtils.ts diff --git a/packages/core/src/routing/strategies/classifierStrategy.ts b/packages/core/src/routing/strategies/classifierStrategy.ts index 89f3e2d66e9..1dd09f45961 100644 --- a/packages/core/src/routing/strategies/classifierStrategy.ts +++ b/packages/core/src/routing/strategies/classifierStrategy.ts @@ -15,13 +15,17 @@ import type { import { resolveClassifierModel, isGemini3Model } from '../../config/models.js'; import { createUserContent, Type } from '@google/genai'; import type { Config } from '../../config/config.js'; -import { getCleanHistorySlice } from './strategyUtils.js'; +import { + isFunctionCall, + isFunctionResponse, +} from '../../utils/messageInspectors.js'; import { debugLogger } from '../../utils/debugLogger.js'; import type { LocalLiteRtLmClient } from '../../core/localLiteRtLmClient.js'; import { LlmRole } from '../../telemetry/types.js'; // The number of recent history turns to provide to the router for context. const HISTORY_TURNS_FOR_CONTEXT = 4; +const HISTORY_SEARCH_WINDOW = 20; const FLASH_MODEL = 'flash'; const PRO_MODEL = 'pro'; @@ -142,11 +146,17 @@ export class ClassifierStrategy implements RoutingStrategy { const promptId = getPromptIdWithFallback('classifier-router'); - const finalHistory = getCleanHistorySlice( - context.history, - HISTORY_TURNS_FOR_CONTEXT, + const historySlice = context.history.slice(-HISTORY_SEARCH_WINDOW); + + // Filter out tool-related turns. + // TODO - Consider using function req/res if they help accuracy. + const cleanHistory = historySlice.filter( + (content) => !isFunctionCall(content) && !isFunctionResponse(content), ); + // Take the last N turns from the *cleaned* history. + const finalHistory = cleanHistory.slice(-HISTORY_TURNS_FOR_CONTEXT); + const jsonResponse = await baseLlmClient.generateJson({ modelConfigKey: { model: 'classifier' }, contents: [...finalHistory, createUserContent(context.request)], diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts index 4e66fb71219..52c1fdf10d0 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts @@ -18,7 +18,6 @@ import { DEFAULT_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_MODEL, } from '../../config/models.js'; -import { HISTORY_SEARCH_WINDOW } from './strategyUtils.js'; import { promptIdContext } from '../../utils/promptIdContext.js'; import type { Content } from '@google/genai'; import type { ResolvedModelConfig } from '../../services/modelConfigService.js'; @@ -424,44 +423,7 @@ describe('NumericalClassifierStrategy', () => { expect(consoleWarnSpy).toHaveBeenCalled(); }); - it('should respect HISTORY_TURNS_FOR_CONTEXT', async () => { - const longHistory: Content[] = []; - for (let i = 0; i < 30; i++) { - longHistory.push({ role: 'user', parts: [{ text: `Message ${i}` }] }); - } - mockContext.history = longHistory; - const mockApiResponse = { - complexity_reasoning: 'Simple.', - complexity_score: 10, - }; - vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue( - mockApiResponse, - ); - - await strategy.route( - mockContext, - mockConfig, - mockBaseLlmClient, - mockLocalLiteRtLmClient, - ); - - const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock - .calls[0][0]; - const contents = generateJsonCall.contents; - - const finalHistory = longHistory.slice(-HISTORY_TURNS_FOR_CONTEXT); - - // Last part is the request - const requestPart = { - role: 'user', - parts: [{ text: 'simple task' }], - }; - - expect(contents).toEqual([...finalHistory, requestPart]); - expect(contents).toHaveLength(HISTORY_TURNS_FOR_CONTEXT + 1); - }); - - it('should completely filter out tool-related turns from history before slicing', async () => { + it('should strip leading tool turns when the candidate slice starts with tool-related turns', async () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'initial request' }] }, { role: 'model', parts: [{ functionCall: { name: 'test_tool' } }] }, @@ -499,9 +461,8 @@ describe('NumericalClassifierStrategy', () => { .calls[0][0]; const contents = generateJsonCall.contents; - // Expect tool turns (index 1 and 2) to be fully filtered out + // Expect leading tool turns (index 1 and 2) to be stripped because index 0 was sliced off const expectedContents = [ - history[0], ...history.slice(3), { role: 'user', @@ -512,7 +473,7 @@ describe('NumericalClassifierStrategy', () => { expect(contents).toEqual(expectedContents); }); - it('should preserve text turns both before and after tool-related turns when tools are in the middle', async () => { + it('should preserve tool turns when they appear after a non-tool turn in the middle of history', async () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'turn 0 (before)' }] }, { role: 'model', parts: [{ text: 'turn 1 (before)' }] }, @@ -550,10 +511,9 @@ describe('NumericalClassifierStrategy', () => { .calls[0][0]; const contents = generateJsonCall.contents; - // Expect exactly the 4 turns before and 4 turns after to be preserved + // Expect all 8 sliced turns (starting from non-tool turn 2) to be preserved const expectedContents = [ - ...history.slice(0, 4), - ...history.slice(6), + ...history.slice(2), { role: 'user', parts: [{ text: 'simple task' }], @@ -563,7 +523,7 @@ describe('NumericalClassifierStrategy', () => { expect(contents).toEqual(expectedContents); }); - it('should preserve preceding text turns when tool-related turns are at the very end of history', async () => { + it('should preserve tool turns when they appear at the very end of history following a non-tool turn', async () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'turn 0' }] }, { role: 'model', parts: [{ text: 'turn 1' }] }, @@ -601,9 +561,9 @@ describe('NumericalClassifierStrategy', () => { .calls[0][0]; const contents = generateJsonCall.contents; - // Expect exactly the 8 text turns before the tools to be preserved + // Expect all 8 sliced turns to be preserved because index 2 is a non-tool turn const expectedContents = [ - ...history.slice(0, 8), + ...history.slice(2), { role: 'user', parts: [{ text: 'simple task' }], @@ -661,7 +621,7 @@ describe('NumericalClassifierStrategy', () => { expect(contents).toEqual(expectedContents); }); - it('should respect HISTORY_SEARCH_WINDOW and HISTORY_TURNS_FOR_CONTEXT correctly', async () => { + it('should respect HISTORY_TURNS_FOR_CONTEXT correctly on long history arrays', async () => { const longHistory: Content[] = []; for (let i = 0; i < 40; i++) { longHistory.push({ role: 'user', parts: [{ text: `Message ${i}` }] }); @@ -694,11 +654,19 @@ describe('NumericalClassifierStrategy', () => { const contents = generateJsonCall.contents; // Manually calculate what the history should be - const historySlice = longHistory.slice(-HISTORY_SEARCH_WINDOW); - const cleanHistory = historySlice.filter( - (content) => !content.parts?.some((p) => !!p.functionCall || !!p.functionResponse), - ); - const finalHistory = cleanHistory.slice(-HISTORY_TURNS_FOR_CONTEXT); + const candidateSlice = longHistory.slice(-HISTORY_TURNS_FOR_CONTEXT); + let firstTextIndex = -1; + for (let i = 0; i < candidateSlice.length; i++) { + if ( + !candidateSlice[i].parts?.every((p) => !!p.functionCall) && + !candidateSlice[i].parts?.every((p) => !!p.functionResponse) + ) { + firstTextIndex = i; + break; + } + } + const finalHistory = + firstTextIndex === -1 ? [] : candidateSlice.slice(firstTextIndex); expect(contents).toEqual([ ...finalHistory, diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.ts index d591baea4ef..eb701fa0828 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.ts @@ -14,7 +14,10 @@ import type { } from '../routingStrategy.js'; import { resolveClassifierModel, isGemini3Model } from '../../config/models.js'; import { createUserContent, Type } from '@google/genai'; -import { getCleanHistorySlice } from './strategyUtils.js'; +import { + isFunctionCall, + isFunctionResponse, +} from '../../utils/messageInspectors.js'; import { debugLogger } from '../../utils/debugLogger.js'; import type { LocalLiteRtLmClient } from '../../core/localLiteRtLmClient.js'; import { LlmRole } from '../../telemetry/types.js'; @@ -115,10 +118,19 @@ export class NumericalClassifierStrategy implements RoutingStrategy { const promptId = getPromptIdWithFallback('classifier-router'); - const finalHistory = getCleanHistorySlice( - context.history, - HISTORY_TURNS_FOR_CONTEXT, - ); + const candidateSlice = context.history.slice(-HISTORY_TURNS_FOR_CONTEXT); + let firstTextIndex = -1; + for (let i = 0; i < candidateSlice.length; i++) { + if ( + !isFunctionCall(candidateSlice[i]) && + !isFunctionResponse(candidateSlice[i]) + ) { + firstTextIndex = i; + break; + } + } + const finalHistory = + firstTextIndex === -1 ? [] : candidateSlice.slice(firstTextIndex); // Wrap the user's request in tags to prevent prompt injection const requestParts = Array.isArray(context.request) diff --git a/packages/core/src/routing/strategies/strategyUtils.ts b/packages/core/src/routing/strategies/strategyUtils.ts deleted file mode 100644 index 5e2713c06ba..00000000000 --- a/packages/core/src/routing/strategies/strategyUtils.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { Content } from '@google/genai'; - - -import { - isFunctionCall, - isFunctionResponse, -} from '../../utils/messageInspectors.js'; - -// The maximum number of recent history turns to scan before filtering. -export const HISTORY_SEARCH_WINDOW = 20; - -/** - * Returns a cleaned slice of conversation history for routing classifiers. - * It strips out all tool-related turns to guarantee the context is text-only, - * avoiding backend validation failures on orphaned calls/responses, and takes - * exactly the last `maxTurns` turns. - * - * IMPORTANT: If we ever want to change this to include tool-related turns, - * we need to be extremely careful to ensure that they are not the very first - * parts in the history we send in the classifier request, as the backend explicitly - * rejects payloads where `contents[0]` is a function call or response. - */ -export function getCleanHistorySlice( - history: readonly Content[], - maxTurns: number, -): Content[] { - const historySlice = history.slice(-HISTORY_SEARCH_WINDOW); - const cleanHistory = historySlice.filter( - (content) => !isFunctionCall(content) && !isFunctionResponse(content), - ); - return cleanHistory.slice(-maxTurns); -} From f56be67985258aaba0aa386f1a9705b9ae87909b Mon Sep 17 00:00:00 2001 From: Daniel Weis Date: Sun, 10 May 2026 06:11:07 +0000 Subject: [PATCH 10/12] Remove no longer relevant test --- .../numericalClassifierStrategy.test.ts | 53 +------------------ 1 file changed, 2 insertions(+), 51 deletions(-) diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts index 52c1fdf10d0..cce02a3802a 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts @@ -423,55 +423,6 @@ describe('NumericalClassifierStrategy', () => { expect(consoleWarnSpy).toHaveBeenCalled(); }); - it('should strip leading tool turns when the candidate slice starts with tool-related turns', async () => { - const history: Content[] = [ - { role: 'user', parts: [{ text: 'initial request' }] }, - { role: 'model', parts: [{ functionCall: { name: 'test_tool' } }] }, - { - role: 'user', - parts: [ - { functionResponse: { name: 'test_tool', response: { ok: true } } }, - ], - }, - { role: 'model', parts: [{ text: 'tool output analyzed' }] }, - { role: 'user', parts: [{ text: 'next step' }] }, - { role: 'model', parts: [{ text: 'working on it' }] }, - { role: 'user', parts: [{ text: 'almost done?' }] }, - { role: 'model', parts: [{ text: 'yes' }] }, - { role: 'user', parts: [{ text: 'final check' }] }, - { role: 'model', parts: [{ text: 'all good' }] }, - ]; - mockContext.history = history; - const mockApiResponse = { - complexity_reasoning: 'Simple.', - complexity_score: 10, - }; - vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue( - mockApiResponse, - ); - - await strategy.route( - mockContext, - mockConfig, - mockBaseLlmClient, - mockLocalLiteRtLmClient, - ); - - const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock - .calls[0][0]; - const contents = generateJsonCall.contents; - - // Expect leading tool turns (index 1 and 2) to be stripped because index 0 was sliced off - const expectedContents = [ - ...history.slice(3), - { - role: 'user', - parts: [{ text: 'simple task' }], - }, - ]; - - expect(contents).toEqual(expectedContents); - }); it('should preserve tool turns when they appear after a non-tool turn in the middle of history', async () => { const history: Content[] = [ @@ -672,8 +623,8 @@ describe('NumericalClassifierStrategy', () => { ...finalHistory, { role: 'user', parts: [{ text: 'simple task' }] }, ]); - // Exactly HISTORY_TURNS_FOR_CONTEXT history turns plus the current request turn - expect(contents).toHaveLength(HISTORY_TURNS_FOR_CONTEXT + 1); + // Expect finalHistory length plus the current request turn + expect(contents).toHaveLength(finalHistory.length + 1); }); it('should use a fallback promptId if not found in context', async () => { From f175164bb7cdca96da1aace6b27e51c6a307d623 Mon Sep 17 00:00:00 2001 From: Daniel Weis Date: Sun, 10 May 2026 06:17:54 +0000 Subject: [PATCH 11/12] Some test clean up and adding a comment for context --- .../numericalClassifierStrategy.test.ts | 117 +++++++++++++----- .../strategies/numericalClassifierStrategy.ts | 3 + 2 files changed, 92 insertions(+), 28 deletions(-) diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts index cce02a3802a..297a507bf20 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts @@ -424,6 +424,50 @@ describe('NumericalClassifierStrategy', () => { }); + it('should strip leading tool turns when history starts with tool calls', async () => { + const history: Content[] = [ + { role: 'model', parts: [{ functionCall: { name: 'leading_tool' } }] }, + { + role: 'user', + parts: [ + { functionResponse: { name: 'leading_tool', response: { ok: true } } }, + ], + }, + { role: 'model', parts: [{ text: 'text response 1' }] }, + { role: 'user', parts: [{ text: 'text request 2' }] }, + ]; + mockContext.history = history; + const mockApiResponse = { + complexity_reasoning: 'Simple.', + complexity_score: 10, + }; + vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue( + mockApiResponse, + ); + + await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + mockLocalLiteRtLmClient, + ); + + const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock + .calls[0][0]; + const contents = generateJsonCall.contents; + + // Expect leading tool turns (index 0 and 1) to be stripped, keeping only text turns (index 2 and 3) + const expectedContents = [ + ...history.slice(2), + { + role: 'user', + parts: [{ text: 'simple task' }], + }, + ]; + + expect(contents).toEqual(expectedContents); + }); + it('should preserve tool turns when they appear after a non-tool turn in the middle of history', async () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'turn 0 (before)' }] }, @@ -572,19 +616,12 @@ describe('NumericalClassifierStrategy', () => { expect(contents).toEqual(expectedContents); }); - it('should respect HISTORY_TURNS_FOR_CONTEXT correctly on long history arrays', async () => { - const longHistory: Content[] = []; - for (let i = 0; i < 40; i++) { - longHistory.push({ role: 'user', parts: [{ text: `Message ${i}` }] }); - // Add noise that should be filtered - if (i % 2 === 0) { - longHistory.push({ - role: 'model', - parts: [{ functionCall: { name: 'noise', args: {} } }], - }); - } + it('should respect HISTORY_TURNS_FOR_CONTEXT correctly when history has only text turns', async () => { + const history: Content[] = []; + for (let i = 0; i < HISTORY_TURNS_FOR_CONTEXT + 2; i++) { + history.push({ role: 'user', parts: [{ text: `Message ${i}` }] }); } - mockContext.history = longHistory; + mockContext.history = history; const mockApiResponse = { complexity_reasoning: 'Simple.', complexity_score: 10, @@ -604,27 +641,51 @@ describe('NumericalClassifierStrategy', () => { .calls[0][0]; const contents = generateJsonCall.contents; - // Manually calculate what the history should be - const candidateSlice = longHistory.slice(-HISTORY_TURNS_FOR_CONTEXT); - let firstTextIndex = -1; - for (let i = 0; i < candidateSlice.length; i++) { - if ( - !candidateSlice[i].parts?.every((p) => !!p.functionCall) && - !candidateSlice[i].parts?.every((p) => !!p.functionResponse) - ) { - firstTextIndex = i; - break; - } + // Expect exactly the last 8 turns (history.slice(2)) + expect(contents).toEqual([ + ...history.slice(2), + { role: 'user', parts: [{ text: 'simple task' }] }, + ]); + expect(contents).toHaveLength(HISTORY_TURNS_FOR_CONTEXT + 1); + }); + + it('should respect HISTORY_TURNS_FOR_CONTEXT correctly when history starts with tool calls', async () => { + const history: Content[] = [ + { role: 'model', parts: [{ functionCall: { name: 'tool_0' } }] }, + { + role: 'user', + parts: [{ functionResponse: { name: 'tool_0', response: { ok: true } } }], + }, + ]; + for (let i = 0; i < HISTORY_TURNS_FOR_CONTEXT; i++) { + history.push({ role: 'user', parts: [{ text: `Message ${i}` }] }); } - const finalHistory = - firstTextIndex === -1 ? [] : candidateSlice.slice(firstTextIndex); + mockContext.history = history; + const mockApiResponse = { + complexity_reasoning: 'Simple.', + complexity_score: 10, + }; + vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue( + mockApiResponse, + ); + await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + mockLocalLiteRtLmClient, + ); + + const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock + .calls[0][0]; + const contents = generateJsonCall.contents; + + // Expect exactly the last 8 text turns (history.slice(2)) expect(contents).toEqual([ - ...finalHistory, + ...history.slice(2), { role: 'user', parts: [{ text: 'simple task' }] }, ]); - // Expect finalHistory length plus the current request turn - expect(contents).toHaveLength(finalHistory.length + 1); + expect(contents).toHaveLength(HISTORY_TURNS_FOR_CONTEXT + 1); }); it('should use a fallback promptId if not found in context', async () => { diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.ts index eb701fa0828..721d71864ee 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.ts @@ -119,6 +119,9 @@ export class NumericalClassifierStrategy implements RoutingStrategy { const promptId = getPromptIdWithFallback('classifier-router'); const candidateSlice = context.history.slice(-HISTORY_TURNS_FOR_CONTEXT); + + // Find the first non-tool turn. The server cannot always handle tool-related + // turns in the first slots of the contents array, so we strip them if they appear at the start. let firstTextIndex = -1; for (let i = 0; i < candidateSlice.length; i++) { if ( From 129de2297ff567b79b19a973a9af48870a587d6a Mon Sep 17 00:00:00 2001 From: Daniel Weis Date: Mon, 11 May 2026 14:58:05 -0400 Subject: [PATCH 12/12] Fix import error and run lint updates. --- .../strategies/numericalClassifierStrategy.test.ts | 14 ++++++++++---- .../strategies/numericalClassifierStrategy.ts | 1 + 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts index 297a507bf20..f400dfc51ba 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts @@ -5,7 +5,10 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { NumericalClassifierStrategy, HISTORY_TURNS_FOR_CONTEXT } from './numericalClassifierStrategy.js'; +import { + NumericalClassifierStrategy, + HISTORY_TURNS_FOR_CONTEXT, +} from './numericalClassifierStrategy.js'; import type { RoutingContext } from '../routingStrategy.js'; import type { Config } from '../../config/config.js'; import type { BaseLlmClient } from '../../core/baseLlmClient.js'; @@ -423,14 +426,15 @@ describe('NumericalClassifierStrategy', () => { expect(consoleWarnSpy).toHaveBeenCalled(); }); - it('should strip leading tool turns when history starts with tool calls', async () => { const history: Content[] = [ { role: 'model', parts: [{ functionCall: { name: 'leading_tool' } }] }, { role: 'user', parts: [ - { functionResponse: { name: 'leading_tool', response: { ok: true } } }, + { + functionResponse: { name: 'leading_tool', response: { ok: true } }, + }, ], }, { role: 'model', parts: [{ text: 'text response 1' }] }, @@ -654,7 +658,9 @@ describe('NumericalClassifierStrategy', () => { { role: 'model', parts: [{ functionCall: { name: 'tool_0' } }] }, { role: 'user', - parts: [{ functionResponse: { name: 'tool_0', response: { ok: true } } }], + parts: [ + { functionResponse: { name: 'tool_0', response: { ok: true } } }, + ], }, ]; for (let i = 0; i < HISTORY_TURNS_FOR_CONTEXT; i++) { diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.ts index 721d71864ee..0e2401c8f15 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.ts @@ -14,6 +14,7 @@ import type { } from '../routingStrategy.js'; import { resolveClassifierModel, isGemini3Model } from '../../config/models.js'; import { createUserContent, Type } from '@google/genai'; +import type { Config } from '../../config/config.js'; import { isFunctionCall, isFunctionResponse,