/** * Tests for adaptive thinking budget in StandardGraph. * * Verifies that `getAdaptiveClientOptions()` correctly reduces thinking budgets * for subsequent ReAct iterations across all supported providers: * - Anthropic (thinking.budget_tokens) * - Bedrock (additionalModelRequestFields.thinking.budget_tokens) * - VertexAI / Google (thinkingConfig.thinkingBudget) * * Also verifies pass-through behavior for non-thinking providers and * budgets already at or below the minimum. */ import { StandardGraph } from '@/graphs/Graph'; import { Providers } from '@/common'; import { TOOL_TURN_THINKING_BUDGET } from '@/common/constants'; import type * as t from '@/types'; /** * Create a minimal StandardGraph for testing getAdaptiveClientOptions(). * Only needs agent config — we call the method directly, not via createCallModel(). */ function createTestGraph(): StandardGraph { return new StandardGraph({ agents: [ { agentId: 'test-agent', provider: Providers.OPENAI as any, clientOptions: { model: 'gpt-4' }, }, ], }); } describe('getAdaptiveClientOptions', () => { let graph: StandardGraph; beforeEach(() => { graph = createTestGraph(); }); describe('Anthropic provider', () => { it('should reduce thinking budget when above TOOL_TURN_THINKING_BUDGET', () => { const clientOptions = { model: 'claude-sonnet-4-20250514', thinking: { type: 'enabled' as const, budget_tokens: 4000 }, } as t.AnthropicClientOptions; const result = graph.getAdaptiveClientOptions( clientOptions, Providers.ANTHROPIC ); const anthropicResult = result as t.AnthropicClientOptions; expect(anthropicResult.thinking).toBeDefined(); expect((anthropicResult.thinking as any).budget_tokens).toBe( TOOL_TURN_THINKING_BUDGET ); expect((anthropicResult.thinking as any).type).toBe('enabled'); // Original should not be mutated expect((clientOptions.thinking as any).budget_tokens).toBe(4000); }); it('should reduce thinking budget for adaptive thinking type', () => { const clientOptions = { model: 'claude-sonnet-4-20250514', thinking: { type: 'adaptive' as const, budget_tokens: 8000 }, } as t.AnthropicClientOptions; const result = graph.getAdaptiveClientOptions( clientOptions, Providers.ANTHROPIC ); const anthropicResult = result as t.AnthropicClientOptions; expect((anthropicResult.thinking as any).budget_tokens).toBe( TOOL_TURN_THINKING_BUDGET ); expect((anthropicResult.thinking as any).type).toBe('adaptive'); }); it('should NOT reduce thinking budget when already at minimum', () => { const clientOptions = { model: 'claude-sonnet-4-20250514', thinking: { type: 'enabled' as const, budget_tokens: 1024 }, } as t.AnthropicClientOptions; const result = graph.getAdaptiveClientOptions( clientOptions, Providers.ANTHROPIC ); // Should return the same object (no modification needed) expect(result).toBe(clientOptions); }); it('should NOT reduce thinking budget when below minimum', () => { const clientOptions = { model: 'claude-sonnet-4-20250514', thinking: { type: 'enabled' as const, budget_tokens: 512 }, } as t.AnthropicClientOptions; const result = graph.getAdaptiveClientOptions( clientOptions, Providers.ANTHROPIC ); expect(result).toBe(clientOptions); }); it('should pass through when thinking is not enabled', () => { const clientOptions = { model: 'claude-sonnet-4-20250514', } as t.AnthropicClientOptions; const result = graph.getAdaptiveClientOptions( clientOptions, Providers.ANTHROPIC ); expect(result).toBe(clientOptions); }); it('should pass through when thinking is boolean false', () => { const clientOptions = { model: 'claude-sonnet-4-20250514', thinking: false, } as unknown as t.AnthropicClientOptions; const result = graph.getAdaptiveClientOptions( clientOptions, Providers.ANTHROPIC ); expect(result).toBe(clientOptions); }); it('should preserve other clientOptions fields when reducing budget', () => { const clientOptions = { model: 'claude-sonnet-4-20250514', thinking: { type: 'enabled' as const, budget_tokens: 5000 }, promptCache: true, maxTokens: 4096, } as t.AnthropicClientOptions; const result = graph.getAdaptiveClientOptions( clientOptions, Providers.ANTHROPIC ); const anthropicResult = result as t.AnthropicClientOptions; expect(anthropicResult.promptCache).toBe(true); expect(anthropicResult.maxTokens).toBe(4096); expect(anthropicResult.model).toBe('claude-sonnet-4-20250514'); expect((anthropicResult.thinking as any).budget_tokens).toBe( TOOL_TURN_THINKING_BUDGET ); }); }); describe('Bedrock provider', () => { it('should reduce thinking budget when above TOOL_TURN_THINKING_BUDGET', () => { const clientOptions = { model: 'anthropic.claude-sonnet-4-20250514-v1:0', additionalModelRequestFields: { thinking: { type: 'enabled', budget_tokens: 4000 }, }, } as t.BedrockAnthropicClientOptions; const result = graph.getAdaptiveClientOptions( clientOptions, Providers.BEDROCK ); const bedrockResult = result as t.BedrockAnthropicClientOptions; expect( bedrockResult.additionalModelRequestFields?.thinking ).toBeDefined(); expect( (bedrockResult.additionalModelRequestFields!.thinking as any) .budget_tokens ).toBe(TOOL_TURN_THINKING_BUDGET); // Original should not be mutated expect( (clientOptions.additionalModelRequestFields!.thinking as any) .budget_tokens ).toBe(4000); }); it('should NOT reduce thinking budget when already at minimum', () => { const clientOptions = { model: 'anthropic.claude-sonnet-4-20250514-v1:0', additionalModelRequestFields: { thinking: { type: 'enabled', budget_tokens: 1024 }, }, } as t.BedrockAnthropicClientOptions; const result = graph.getAdaptiveClientOptions( clientOptions, Providers.BEDROCK ); expect(result).toBe(clientOptions); }); it('should pass through when no additionalModelRequestFields', () => { const clientOptions = { model: 'anthropic.claude-sonnet-4-20250514-v1:0', } as t.BedrockAnthropicClientOptions; const result = graph.getAdaptiveClientOptions( clientOptions, Providers.BEDROCK ); expect(result).toBe(clientOptions); }); it('should pass through when thinking is undefined in additionalModelRequestFields', () => { const clientOptions = { model: 'anthropic.claude-sonnet-4-20250514-v1:0', additionalModelRequestFields: {}, } as t.BedrockAnthropicClientOptions; const result = graph.getAdaptiveClientOptions( clientOptions, Providers.BEDROCK ); expect(result).toBe(clientOptions); }); }); describe('VertexAI provider', () => { it('should reduce thinking budget when above TOOL_TURN_THINKING_BUDGET', () => { const clientOptions = { model: 'gemini-2.5-pro', thinkingConfig: { thinkingBudget: 8000 }, } as t.GoogleClientOptions; const result = graph.getAdaptiveClientOptions( clientOptions, Providers.VERTEXAI ); const vertexResult = result as t.GoogleClientOptions; expect(vertexResult.thinkingConfig?.thinkingBudget).toBe( TOOL_TURN_THINKING_BUDGET ); // Original should not be mutated expect(clientOptions.thinkingConfig!.thinkingBudget).toBe(8000); }); it('should NOT reduce thinking budget when already at minimum', () => { const clientOptions = { model: 'gemini-2.5-pro', thinkingConfig: { thinkingBudget: 1024 }, } as t.GoogleClientOptions; const result = graph.getAdaptiveClientOptions( clientOptions, Providers.VERTEXAI ); expect(result).toBe(clientOptions); }); it('should pass through when no thinkingConfig', () => { const clientOptions = { model: 'gemini-2.5-pro', } as t.GoogleClientOptions; const result = graph.getAdaptiveClientOptions( clientOptions, Providers.VERTEXAI ); expect(result).toBe(clientOptions); }); }); describe('Google provider', () => { it('should reduce thinking budget (same logic as VertexAI)', () => { const clientOptions = { model: 'gemini-2.5-pro', thinkingConfig: { thinkingBudget: 4000 }, } as t.GoogleClientOptions; const result = graph.getAdaptiveClientOptions( clientOptions, Providers.GOOGLE ); const googleResult = result as t.GoogleClientOptions; expect(googleResult.thinkingConfig?.thinkingBudget).toBe( TOOL_TURN_THINKING_BUDGET ); }); }); describe('Non-thinking providers (pass-through)', () => { it('should return original options for OpenAI', () => { const clientOptions = { model: 'gpt-4', temperature: 0.7, } as t.OpenAIClientOptions; const result = graph.getAdaptiveClientOptions( clientOptions, Providers.OPENAI ); expect(result).toBe(clientOptions); }); it('should return original options for Azure', () => { const clientOptions = { deploymentName: 'gpt-4', } as t.AzureClientOptions; const result = graph.getAdaptiveClientOptions( clientOptions, Providers.AZURE ); expect(result).toBe(clientOptions); }); it('should return original options for MistralAI', () => { const clientOptions = { model: 'mistral-large', } as t.MistralAIClientOptions; const result = graph.getAdaptiveClientOptions( clientOptions, Providers.MISTRALAI ); expect(result).toBe(clientOptions); }); }); describe('immutability', () => { it('should not mutate the original Anthropic clientOptions', () => { const thinking = { type: 'enabled' as const, budget_tokens: 5000 }; const clientOptions = { model: 'claude-sonnet-4-20250514', thinking, } as t.AnthropicClientOptions; const result = graph.getAdaptiveClientOptions( clientOptions, Providers.ANTHROPIC ); // Result should be a new object expect(result).not.toBe(clientOptions); // Original thinking object should be unchanged expect(thinking.budget_tokens).toBe(5000); // Result should have reduced budget expect((result as t.AnthropicClientOptions).thinking).not.toBe(thinking); }); it('should not mutate the original Bedrock clientOptions', () => { const thinking = { type: 'enabled', budget_tokens: 5000 }; const additionalModelRequestFields = { thinking }; const clientOptions = { model: 'anthropic.claude-sonnet-4-20250514-v1:0', additionalModelRequestFields, } as t.BedrockAnthropicClientOptions; const result = graph.getAdaptiveClientOptions( clientOptions, Providers.BEDROCK ); expect(result).not.toBe(clientOptions); expect(thinking.budget_tokens).toBe(5000); }); }); });