|
| 1 | +// @ts-nocheck |
| 2 | +/** |
| 3 | + * AgentExecution default-LLM env resolution + AgentLLMsRegistry call tracking |
| 4 | + * (V48 Gate 3 — tools/executions domain). |
| 5 | + * |
| 6 | + * Inference is NON-configurable (F26-A): determinism comes from the boundary |
| 7 | + * LLM seam, so @bitcode/generic-llms is mocked here with recording providers |
| 8 | + * while the REAL env-resolution defaults (resolveDefaultLLMConfig) stay live. |
| 9 | + * |
| 10 | + * Pins: |
| 11 | + * - AgentExecution honors BITCODE_LLM_PROVIDER + BITCODE_LLM_MODEL: the '*' |
| 12 | + * global config carries the env model and the env provider is the default. |
| 13 | + * - Provider fallback: no BITCODE_LLM_PROVIDER + OPENAI_API_KEY → openai with |
| 14 | + * its default model. |
| 15 | + * - getDefaultLLM's tracking wrapper stores the llm-namespace telemetry keys |
| 16 | + * (content-bearing keys are withheld downstream by sourceSafeStreamEvent — |
| 17 | + * pinned in pipelines-generics' streaming tests). |
| 18 | + * - BITCODE_LLM_CALL_TIMEOUT_MS bounds every call (reject + failed status); |
| 19 | + * 0 disables the bound. |
| 20 | + */ |
| 21 | +const mockLLMCalls: Array<{ provider: string; config: any }> = []; |
| 22 | + |
| 23 | +jest.mock('@bitcode/generic-llms', () => { |
| 24 | + const { LLMRegistry } = jest.requireActual('@bitcode/llm-generics'); |
| 25 | + const defaults = jest.requireActual('@bitcode/generic-llms/defaults'); |
| 26 | + return { |
| 27 | + resolveDefaultLLMConfig: defaults.resolveDefaultLLMConfig, |
| 28 | + resolveDefaultLLMModel: defaults.resolveDefaultLLMModel, |
| 29 | + resolveDefaultLLMProvider: defaults.resolveDefaultLLMProvider, |
| 30 | + factoryLLMRegistryWithProviders: () => { |
| 31 | + const registry = new LLMRegistry(); |
| 32 | + for (const name of ['openai', 'anthropic', 'google']) { |
| 33 | + registry.registerProvider({ |
| 34 | + name, |
| 35 | + createLLM: (config: any) => async () => { |
| 36 | + mockLLMCalls.push({ provider: name, config }); |
| 37 | + return { |
| 38 | + content: `stubbed:${name}:${config.model ?? 'unconfigured'}`, |
| 39 | + usage: { inputTokens: 3, outputTokens: 4, totalTokens: 7 }, |
| 40 | + }; |
| 41 | + }, |
| 42 | + }); |
| 43 | + } |
| 44 | + return registry; |
| 45 | + }, |
| 46 | + }; |
| 47 | +}); |
| 48 | + |
| 49 | +import { Execution } from '@bitcode/execution-generics'; |
| 50 | +import { AgentExecution } from '../execution/AgentExecution'; |
| 51 | +import { AgentLLMsRegistry } from '../execution/AgentLLMsRegistry'; |
| 52 | + |
| 53 | +const ENV_KEYS = [ |
| 54 | + 'BITCODE_LLM_PROVIDER', |
| 55 | + 'BITCODE_LLM_MODEL', |
| 56 | + 'BITCODE_LLM_CALL_TIMEOUT_MS', |
| 57 | + 'OPENAI_API_KEY', |
| 58 | +]; |
| 59 | +let envSnapshot: Record<string, string | undefined>; |
| 60 | + |
| 61 | +beforeEach(() => { |
| 62 | + envSnapshot = {}; |
| 63 | + for (const key of ENV_KEYS) envSnapshot[key] = process.env[key]; |
| 64 | + mockLLMCalls.length = 0; |
| 65 | +}); |
| 66 | + |
| 67 | +afterEach(() => { |
| 68 | + for (const key of ENV_KEYS) { |
| 69 | + if (envSnapshot[key] === undefined) delete process.env[key]; |
| 70 | + else process.env[key] = envSnapshot[key]; |
| 71 | + } |
| 72 | +}); |
| 73 | + |
| 74 | +function findLlmChild(execution: Execution, suffix: string): Execution | undefined { |
| 75 | + return Array.from(execution.children.values()).find((child) => child.id.endsWith(suffix)); |
| 76 | +} |
| 77 | + |
| 78 | +describe('AgentExecution — default-LLM env resolution (BITCODE_LLM_*)', () => { |
| 79 | + it('honors BITCODE_LLM_PROVIDER + BITCODE_LLM_MODEL for getDefaultLLM', async () => { |
| 80 | + process.env.BITCODE_LLM_PROVIDER = 'anthropic'; |
| 81 | + process.env.BITCODE_LLM_MODEL = 'claude-env-pin'; |
| 82 | + delete process.env.BITCODE_LLM_CALL_TIMEOUT_MS; |
| 83 | + |
| 84 | + const agentExec = new AgentExecution('agent:env-honored'); |
| 85 | + const llm = agentExec.llms.getDefaultLLM(); |
| 86 | + const output = await llm({ messages: [{ role: 'user', content: 'hi' }] }); |
| 87 | + |
| 88 | + expect(mockLLMCalls).toHaveLength(1); |
| 89 | + expect(mockLLMCalls[0].provider).toBe('anthropic'); |
| 90 | + expect(mockLLMCalls[0].config.model).toBe('claude-env-pin'); |
| 91 | + expect(output.content).toBe('stubbed:anthropic:claude-env-pin'); |
| 92 | + |
| 93 | + // The agent-level presence config for Bitcode boundary enforcement. |
| 94 | + expect(agentExec.llms.get('default')).toEqual({ model: 'claude-env-pin' }); |
| 95 | + expect(agentExec.llms.ensureDefaultConfigured()).toBe(true); |
| 96 | + }); |
| 97 | + |
| 98 | + it('falls back to the API-key-derived provider and its default model when BITCODE_LLM_* are unset', async () => { |
| 99 | + delete process.env.BITCODE_LLM_PROVIDER; |
| 100 | + delete process.env.BITCODE_LLM_MODEL; |
| 101 | + process.env.OPENAI_API_KEY = 'test-key-openai'; |
| 102 | + |
| 103 | + const agentExec = new AgentExecution('agent:env-fallback'); |
| 104 | + await agentExec.llms.getDefaultLLM()({ messages: [{ role: 'user', content: 'hi' }] }); |
| 105 | + |
| 106 | + expect(mockLLMCalls).toHaveLength(1); |
| 107 | + expect(mockLLMCalls[0].provider).toBe('openai'); |
| 108 | + expect(mockLLMCalls[0].config.model).toBe('gpt-4.1-mini'); |
| 109 | + }); |
| 110 | + |
| 111 | + it('child AgentExecutions resolve the same env default through their own registries', async () => { |
| 112 | + process.env.BITCODE_LLM_PROVIDER = 'google'; |
| 113 | + process.env.BITCODE_LLM_MODEL = 'gemini-env-pin'; |
| 114 | + |
| 115 | + const parent = new AgentExecution('agent:parent'); |
| 116 | + const child = parent.child('step:try'); |
| 117 | + await child.llms.getDefaultLLM()({ messages: [{ role: 'user', content: 'hi' }] }); |
| 118 | + |
| 119 | + expect(mockLLMCalls[mockLLMCalls.length - 1]).toMatchObject({ |
| 120 | + provider: 'google', |
| 121 | + config: { model: 'gemini-env-pin' }, |
| 122 | + }); |
| 123 | + }); |
| 124 | +}); |
| 125 | + |
| 126 | +describe('AgentLLMsRegistry — execution tracking wrapper', () => { |
| 127 | + function stubRegistry(llm: any) { |
| 128 | + return { getLLM: () => llm }; |
| 129 | + } |
| 130 | + |
| 131 | + it('stores the llm-namespace telemetry keys on a llm:<key>:<model> child on success', async () => { |
| 132 | + delete process.env.BITCODE_LLM_CALL_TIMEOUT_MS; |
| 133 | + const execution = new Execution('agent:tracking'); |
| 134 | + const registry = new AgentLLMsRegistry( |
| 135 | + execution, |
| 136 | + stubRegistry(async () => ({ |
| 137 | + content: 'raw model prose', |
| 138 | + usage: { inputTokens: 1, outputTokens: 2, totalTokens: 3 }, |
| 139 | + metadata: { finishReason: 'stop' }, |
| 140 | + })), |
| 141 | + ); |
| 142 | + |
| 143 | + const input = { |
| 144 | + messages: [{ role: 'user', content: 'prompt body' }], |
| 145 | + config: { model: 'stub-model' }, |
| 146 | + }; |
| 147 | + const output = await registry.getDefaultLLM()(input); |
| 148 | + |
| 149 | + // Provider-agnostic stopReason normalization from finishReason. |
| 150 | + expect(output.metadata.stopReason).toBe('stop'); |
| 151 | + |
| 152 | + const llmExec = findLlmChild(execution, 'llm:default:stub-model'); |
| 153 | + expect(llmExec).toBeDefined(); |
| 154 | + // The exact key set matters: content-bearing keys (messages/config/ |
| 155 | + // response) are NOT in the streaming source-safe allowlist and get |
| 156 | + // withheld by sourceSafeStreamEvent; the rest are safe metadata. |
| 157 | + expect(Array.from(llmExec.getAll('llm').keys()).sort()).toEqual( |
| 158 | + ['config', 'configKey', 'duration', 'messages', 'response', 'startTime', 'status', 'usage'].sort(), |
| 159 | + ); |
| 160 | + expect(llmExec.get('llm', 'configKey')).toBe('default'); |
| 161 | + expect(llmExec.get('llm', 'response')).toBe('raw model prose'); |
| 162 | + expect(llmExec.get('llm', 'usage')).toEqual({ inputTokens: 1, outputTokens: 2, totalTokens: 3 }); |
| 163 | + expect(llmExec.get('llm', 'status')).toBe('success'); |
| 164 | + expect(typeof llmExec.get('llm', 'duration')).toBe('number'); |
| 165 | + }); |
| 166 | + |
| 167 | + it('defaults metadata.stopReason to "unknown" when the provider supplies none', async () => { |
| 168 | + delete process.env.BITCODE_LLM_CALL_TIMEOUT_MS; |
| 169 | + const execution = new Execution('agent:stop-reason'); |
| 170 | + const registry = new AgentLLMsRegistry( |
| 171 | + execution, |
| 172 | + stubRegistry(async () => ({ content: 'ok' })), |
| 173 | + ); |
| 174 | + |
| 175 | + const output = await registry.getDefaultLLM()({ messages: [], config: { model: 'stub-model' } }); |
| 176 | + |
| 177 | + expect(output.metadata.stopReason).toBe('unknown'); |
| 178 | + }); |
| 179 | + |
| 180 | + it('BITCODE_LLM_CALL_TIMEOUT_MS bounds a hung call: rejects and stores failed status', async () => { |
| 181 | + process.env.BITCODE_LLM_CALL_TIMEOUT_MS = '25'; |
| 182 | + const execution = new Execution('agent:timeout'); |
| 183 | + const registry = new AgentLLMsRegistry( |
| 184 | + execution, |
| 185 | + stubRegistry(() => new Promise(() => {})), // never resolves |
| 186 | + ); |
| 187 | + |
| 188 | + await expect( |
| 189 | + registry.getDefaultLLM()({ messages: [], config: { model: 'stub-model' } }), |
| 190 | + ).rejects.toThrow('LLM call (stub-model) timed out after 25ms'); |
| 191 | + |
| 192 | + const llmExec = findLlmChild(execution, 'llm:default:stub-model'); |
| 193 | + expect(llmExec.get('llm', 'status')).toBe('failed'); |
| 194 | + expect(llmExec.get('llm', 'error')).toContain('timed out after 25ms'); |
| 195 | + expect(typeof llmExec.get('llm', 'duration')).toBe('number'); |
| 196 | + expect(llmExec.get('llm', 'response')).toBeUndefined(); |
| 197 | + }); |
| 198 | + |
| 199 | + it('BITCODE_LLM_CALL_TIMEOUT_MS=0 disables the bound entirely', async () => { |
| 200 | + process.env.BITCODE_LLM_CALL_TIMEOUT_MS = '0'; |
| 201 | + const execution = new Execution('agent:timeout-disabled'); |
| 202 | + const registry = new AgentLLMsRegistry( |
| 203 | + execution, |
| 204 | + stubRegistry( |
| 205 | + () => |
| 206 | + new Promise((resolve) => |
| 207 | + setTimeout(() => resolve({ content: 'slow but fine' }), 60), |
| 208 | + ), |
| 209 | + ), |
| 210 | + ); |
| 211 | + |
| 212 | + const output = await registry.getDefaultLLM()({ messages: [], config: { model: 'stub-model' } }); |
| 213 | + |
| 214 | + expect(output.content).toBe('slow but fine'); |
| 215 | + const llmExec = findLlmChild(execution, 'llm:default:stub-model'); |
| 216 | + expect(llmExec.get('llm', 'status')).toBe('success'); |
| 217 | + }); |
| 218 | + |
| 219 | + it('a provider failure is recorded and rethrown (clean failure for PTRR retry)', async () => { |
| 220 | + delete process.env.BITCODE_LLM_CALL_TIMEOUT_MS; |
| 221 | + const execution = new Execution('agent:provider-failure'); |
| 222 | + const registry = new AgentLLMsRegistry( |
| 223 | + execution, |
| 224 | + stubRegistry(async () => { |
| 225 | + throw new Error('provider unavailable'); |
| 226 | + }), |
| 227 | + ); |
| 228 | + |
| 229 | + await expect( |
| 230 | + registry.getDefaultLLM()({ messages: [], config: { model: 'stub-model' } }), |
| 231 | + ).rejects.toThrow('provider unavailable'); |
| 232 | + |
| 233 | + const llmExec = findLlmChild(execution, 'llm:default:stub-model'); |
| 234 | + expect(llmExec.get('llm', 'status')).toBe('failed'); |
| 235 | + expect(llmExec.get('llm', 'error')).toBe('provider unavailable'); |
| 236 | + }); |
| 237 | +}); |
0 commit comments