From 9f0b967582dc283339c1ce1584145032f203ee71 Mon Sep 17 00:00:00 2001 From: Valdis Pornieks Date: Thu, 11 Jun 2026 20:03:52 +0300 Subject: [PATCH 01/10] feat: integrate configurable-agent for openai-compatible agentic reviews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ConfigurableAgentAdapter backed by @eggai/configurable-agent, routing the openai-compatible provider through the Vercel AI SDK v5 agent loop. - Add openai-compatible to provider enum and adapter type mapping - Add ConfigurableAgentAdapter: converts qualops ToolDefinitions to AI SDK ToolSet using inputSchema + jsonSchema() wrapper (Zod v4 → JSON Schema) - Wire baseUrl/apiKey from stage config through to the agent - Restore per-provider adapter routing (anthropic/openai unchanged) - Add adapter selection unit tests - Add .qualopsrc.configurable-agent-test.json for manual testing --- .../.qualopsrc.configurable-agent-test.json | 91 ++++++++++++++ CHANGELOG.md | 1 + package.json | 1 + qualops-config.schema.json | 3 +- src/ai/providers/capabilities.ts | 2 + src/ai/providers/factory.ts | 3 + src/config/config-schema.ts | 2 +- src/config/config.ts | 7 +- .../review/agentic/adapters/agent-adapter.ts | 2 + .../adapters/configurable-agent-adapter.ts | 119 ++++++++++++++++++ src/stages/review/agentic/adapters/index.ts | 7 +- src/stages/review/agentic/agentic-executor.ts | 2 + .../review/agentic/adapters/index.spec.ts | 70 +++++++++++ 13 files changed, 305 insertions(+), 5 deletions(-) create mode 100644 .qualops/.qualopsrc.configurable-agent-test.json create mode 100644 src/stages/review/agentic/adapters/configurable-agent-adapter.ts create mode 100644 tests/unit/stages/review/agentic/adapters/index.spec.ts diff --git a/.qualops/.qualopsrc.configurable-agent-test.json b/.qualops/.qualopsrc.configurable-agent-test.json new file mode 100644 index 00000000..550726b4 --- /dev/null +++ b/.qualops/.qualopsrc.configurable-agent-test.json @@ -0,0 +1,91 @@ +{ + "$schema": "../qualops-config.schema.json", + "ai": { + "reviewStage": { + "provider": "openai-compatible", + "model": "gpt-4.1", + "inputPerMillion": 2.0, + "outputPerMillion": 8.0, + "temperature": 0 + }, + "fixStage": { + "provider": "openai-compatible", + "model": "gpt-4.1", + "inputPerMillion": 2.0, + "outputPerMillion": 8.0, + "temperature": 0 + }, + "judgeStage": { + "provider": "openai-compatible", + "model": "gpt-4.1", + "inputPerMillion": 2.0, + "outputPerMillion": 8.0, + "temperature": 0 + } + }, + "performance": { + "maxFileSizeKB": 500, + "maxFilesPerBatch": 15, + "maxTokensPerFile": 8000, + "timeoutSeconds": 300, + "throttling": { + "enabled": true, + "maxRequestsPerMinute": 50 + } + }, + "review": { + "minConfidence": 7, + "maxConcurrentFiles": 3, + "pipeline": [ + { + "name": "agenticSecurityAudit", + "enabled": true, + "mode": "agentic", + "agentic": { + "maxTurns": 15, + "enabledSubagents": [ + "security-analyzer", + "dependency-tracer", + "breaking-change-detector" + ], + "systemPrompt": "You are a security-focused code reviewer analyzing the QualOps codebase. Focus on:\n1. Command injection vulnerabilities in shell executions\n2. Path traversal in file operations\n3. Credential exposure in logs or error messages\n4. Unsafe deserialization or eval usage\n5. Cross-file security implications" + } + } + ] + }, + "fix": { + "enabled": false + }, + "report": { + "outputFormat": "html", + "includedSeverities": [ + "critical", + "high", + "medium", + "low" + ], + "enableRootCauseExtraction": false + }, + "github": { + "enabled": false, + "postComments": false, + "skipOnDraft": false, + "blockPipeline": false, + "maxInlineComments": 50 + }, + "skipPatterns": [ + "node_modules/**", + ".git/**", + "dist/**", + "build/**", + "coverage/**", + "reports/**", + ".qualops-cache/**", + "*.min.js", + "*.bundle.js", + "examples/**", + "docs/**", + "evals/datasets/**", + "**/*.d.ts" + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 13e4ec7f..9c1c6360 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- `openai-compatible` provider support for agentic review mode via `@eggai/configurable-agent` (Vercel AI SDK v5 agent loop). Any provider with a custom `baseUrl` can now run the full agentic security audit without SDK-specific adapters. - Provider-dialect smoke spec: `npm run test:smoke` runs the 4 AI caller stages migrated in PR #145 (`file-reviewer`, `validation-resolver`, `dedup-resolver`, `root-cause-extract`) against each real provider (`anthropic`, `openai`, `bedrock`, `github`) using a slice fixture as input. Validates that the structured-output dialect path returns a zod-validated response without throwing. Implemented as a Jest spec under `tests/smoke/` with its own `jest.smoke.config.ts` — not picked up by default `npm test` (whose `roots` are limited to `tests/unit/`). Provider config comes from `ConfigService` + the existing `PROVIDER_DEFAULTS` table, not a duplicated table. Providers with missing credentials are `describe.skip()`-ed; providers with malformed credentials fail loudly via the provider class's own `validateApiKey()`. Input is a slice fixture under `evals/datasets/inbox/smoke-sql-injection/`, loosely following TDR 0002. Nightly + manual CI workflow at `.github/workflows/provider-dialect-smoke.yml`. Automates the unchecked manual smoke item from PR #145's test plan; distinct from the deferred per-stage golden-evals item which validates output quality. - `unstructured` dialect for LLM models without `response_format: {type: "json_schema"}` support (e.g. Llama, Qwen2.5, DeepSeek-V3, Phi, older o1-series). When `isUnstructured()` is true, the pipeline runs a full prose path: `ProseFileReviewer` → `ProseValidationResolver` → `ProseDeduplicationResolver` → `session/prose-report.md`. No JSON parsing, no structured schemas — the model writes free-form prose and subsequent stages refine it in-kind. - Bundled litellm model capability snapshot (`src/ai/providers/model-capabilities.json`, 2101 chat models) for automatic `supportsResponseSchema` lookup. Unknown models default to `unstructured` (safe). diff --git a/package.json b/package.json index a8bce736..34b8ba0f 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "generate:schema": "ts-node --transpile-only --project tsconfig.lib.json scripts/generate-config-schema.ts" }, "dependencies": { + "@eggai/configurable-agent": "^0.1.0", "@anthropic-ai/claude-agent-sdk": "0.3.169", "@anthropic-ai/sdk": "^0.102.0", "@aws-sdk/client-bedrock-runtime": "^3.1008.0", diff --git a/qualops-config.schema.json b/qualops-config.schema.json index 5139cc71..7a21ae78 100644 --- a/qualops-config.schema.json +++ b/qualops-config.schema.json @@ -201,7 +201,8 @@ "anthropic", "openai", "bedrock", - "github" + "github", + "openai-compatible" ] }, "nonEmptyString": { diff --git a/src/ai/providers/capabilities.ts b/src/ai/providers/capabilities.ts index ee8557d5..457eb576 100644 --- a/src/ai/providers/capabilities.ts +++ b/src/ai/providers/capabilities.ts @@ -56,6 +56,8 @@ export function detectCapabilities(provider: AIProviderName, model: string): Pro return detectAnthropicCapabilities(model); case 'bedrock': return detectBedrockCapabilities(model); + case 'openai-compatible': + return detectOpenAICapabilities(model); default: { const _exhaustive: never = provider; throw new Error(`Unknown provider: ${String(_exhaustive)}`); diff --git a/src/ai/providers/factory.ts b/src/ai/providers/factory.ts index 19f44124..9340a239 100644 --- a/src/ai/providers/factory.ts +++ b/src/ai/providers/factory.ts @@ -43,6 +43,9 @@ export class AIFactory { case AIProviderType.GITHUB: provider = new GitHubModelsProvider(stageConfig); break; + case 'openai-compatible': + provider = new OpenAIProvider(stageConfig); + break; default: { const _exhaustiveCheck: never = stageConfig.provider; throw new Error(`Unknown AI provider: ${_exhaustiveCheck}`); diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts index a8708b55..221d0ae2 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -14,7 +14,7 @@ const severityList = z.array(severity).min(1).meta({ uniqueItems: true, }); export const aiProvider = z - .enum(['anthropic', 'openai', 'bedrock', 'github']) + .enum(['anthropic', 'openai', 'bedrock', 'github', 'openai-compatible']) .meta({ defName: 'aiProvider', description: 'Supported AI provider names.' }); const confidenceScore = z.int().min(1).max(10).meta({ defName: 'confidenceScore', diff --git a/src/config/config.ts b/src/config/config.ts index fb928365..174ec044 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -340,11 +340,14 @@ export class ConfigService { * Maps a resolved provider name to the agent adapter type to use. * Returns `undefined` for providers that do not yet support agentic mode. */ - resolveAgentAdapterType(provider: AIProviderName): 'anthropic' | 'openai' | undefined { - const mapping: Partial> = { + resolveAgentAdapterType( + provider: AIProviderName, + ): 'anthropic' | 'openai' | 'openai-compatible' | undefined { + const mapping: Partial> = { anthropic: 'anthropic', openai: 'openai', github: 'openai', // GitHub Models uses an OpenAI-compatible API + 'openai-compatible': 'openai-compatible', }; return mapping[provider]; } diff --git a/src/stages/review/agentic/adapters/agent-adapter.ts b/src/stages/review/agentic/adapters/agent-adapter.ts index c938484b..18af37c6 100644 --- a/src/stages/review/agentic/adapters/agent-adapter.ts +++ b/src/stages/review/agentic/adapters/agent-adapter.ts @@ -17,6 +17,8 @@ export interface AgentAdapterParams { skipPatterns?: string[]; toolConfig: ToolConfig; onToolCall?: (turn: number, name: string, input: unknown) => void; + baseUrl?: string; + apiKey?: string; } export interface AgentAdapterResult { diff --git a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts new file mode 100644 index 00000000..93f42747 --- /dev/null +++ b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts @@ -0,0 +1,119 @@ +import { runAgent, jsonSchema } from '@eggai/configurable-agent/lib'; +import type { AgentConfig, AgentEvent } from '@eggai/configurable-agent/lib'; +import { z } from 'zod'; + +import type { AgentAdapter, AgentAdapterParams, AgentAdapterResult } from './agent-adapter'; +import { logger } from '../../../../shared/utils/logger'; +import { createToolSet } from '../tools'; + +function toJsonSchema(schema: z.ZodObject): Record { + // z.toJSONSchema emits Draft 2020-12 with a $schema key that confuses some providers. + // Strip it and emit a plain draft-07-compatible object instead. + const { $schema: _dropped, ...rest } = z.toJSONSchema(schema) as Record; + return rest; +} + +function resolveProvider(model: string): AgentConfig['model']['provider'] { + if (model.startsWith('claude')) return 'anthropic'; + if (model.startsWith('gpt') || model.startsWith('o1') || model.startsWith('o3')) return 'openai'; + return 'openai-compatible'; +} + +function buildAgentConfig(params: AgentAdapterParams) { + const provider = params.baseUrl ? 'openai-compatible' : resolveProvider(params.model); + return { + systemPrompt: params.systemPrompt, + model: { + provider, + name: params.model, + ...(params.baseUrl && { baseUrl: params.baseUrl }), + ...(params.apiKey !== undefined && { apiKey: params.apiKey }), + }, + agent: { maxSteps: params.maxTurns }, + mcpTools: [], + output: { structured: false }, + safety: { + compaction: { triggerTokens: 100_000, keepRecentMessages: 6 }, + toolOutput: { triggerTokens: 4_000, headChars: 500, tailChars: 500 }, + }, + } satisfies AgentConfig; +} + +export class ConfigurableAgentAdapter implements AgentAdapter { + async run(params: AgentAdapterParams): Promise { + const config = buildAgentConfig(params); + const qualopsTools = await createToolSet(params.cwd, params.toolConfig, params.skipPatterns); + + const tools: Record< + string, + { + description: string; + inputSchema: unknown; + execute: (args: Record) => Promise; + } + > = {}; + let turnIndex = 0; + for (const def of qualopsTools.tools) { + tools[def.name] = { + description: def.description, + inputSchema: jsonSchema(toJsonSchema(def.schema)), + execute: async (args: Record) => { + turnIndex++; + logger.info(`[Agentic/ConfigurableAgent] Tool call: ${def.name}`); + params.onToolCall?.(turnIndex, def.name, args); + return def.execute(args); + }, + }; + } + + try { + let output = ''; + let inputTokens: number | undefined; + let outputTokens: number | undefined; + let errorSubtype: string | undefined; + + await runAgent( + config, + [{ role: 'user', content: params.userPrompt }], + async (event: AgentEvent) => { + switch (event.type) { + case 'final': + output = event.content; + inputTokens = event.usage?.inputTokens; + outputTokens = event.usage?.outputTokens; + logger.info( + `[Agentic/ConfigurableAgent] Finished. steps=${event.steps}, stopReason=${event.stopReason}`, + ); + break; + case 'error': + logger.error( + `[Agentic/ConfigurableAgent] Error: code=${event.code} ${event.message}`, + ); + switch (event.code) { + case 'tool_call_on_final_step': + errorSubtype = 'error_max_turns'; + break; + case 'stream_error': + errorSubtype = 'error_provider_unavailable'; + break; + case 'structured_output_failed': + errorSubtype = 'error_content_filter'; + break; + default: + throw new Error(`Agent failed: ${event.message}`); + } + break; + default: + break; + } + }, + undefined, + { tools: tools as never }, + ); + + return { output, inputTokens, outputTokens, errorSubtype }; + } finally { + await qualopsTools.dispose(); + } + } +} diff --git a/src/stages/review/agentic/adapters/index.ts b/src/stages/review/agentic/adapters/index.ts index fe465f45..9a6100db 100644 --- a/src/stages/review/agentic/adapters/index.ts +++ b/src/stages/review/agentic/adapters/index.ts @@ -1,12 +1,17 @@ import type { AgentAdapter } from './agent-adapter'; import { AnthropicAdapter } from './anthropic-adapter'; +import { ConfigurableAgentAdapter } from './configurable-agent-adapter'; import { OpenAIAdapter } from './openai-adapter'; import { ConfigService } from '../../../../config/config'; import type { AIProviderName } from '../../../../shared/types'; -const ADAPTERS: Array<{ type: 'anthropic' | 'openai'; ctor: new () => AgentAdapter }> = [ +const ADAPTERS: Array<{ + type: 'anthropic' | 'openai' | 'openai-compatible'; + ctor: new () => AgentAdapter; +}> = [ { type: 'anthropic', ctor: AnthropicAdapter }, { type: 'openai', ctor: OpenAIAdapter }, + { type: 'openai-compatible', ctor: ConfigurableAgentAdapter }, ]; export function createAgentAdapter(provider: AIProviderName): AgentAdapter { diff --git a/src/stages/review/agentic/agentic-executor.ts b/src/stages/review/agentic/agentic-executor.ts index 812b10b3..3613f1b5 100644 --- a/src/stages/review/agentic/agentic-executor.ts +++ b/src/stages/review/agentic/agentic-executor.ts @@ -110,6 +110,8 @@ export class AgenticExecutor { workspaceRoot: this.config.bash?.workspaceRoot ?? this.cwd, }, }, + baseUrl: stageConfig.baseUrl as string | undefined, + apiKey: stageConfig.apiKey as string | undefined, onToolCall: (turn, name, input) => { turnIndex = turn; allToolCalls.push({ turn, name, input }); diff --git a/tests/unit/stages/review/agentic/adapters/index.spec.ts b/tests/unit/stages/review/agentic/adapters/index.spec.ts new file mode 100644 index 00000000..8dd48e20 --- /dev/null +++ b/tests/unit/stages/review/agentic/adapters/index.spec.ts @@ -0,0 +1,70 @@ +jest.mock('@/config/config', () => ({ + ConfigService: { + getInstance: jest.fn(), + }, +})); +jest.mock('@/stages/review/agentic/adapters/anthropic-adapter', () => ({ + AnthropicAdapter: jest.fn().mockImplementation(() => ({ type: 'anthropic' })), +})); +jest.mock('@/stages/review/agentic/adapters/openai-adapter', () => ({ + OpenAIAdapter: jest.fn().mockImplementation(() => ({ type: 'openai' })), +})); +jest.mock('@/stages/review/agentic/adapters/configurable-agent-adapter', () => ({ + ConfigurableAgentAdapter: jest.fn().mockImplementation(() => ({ type: 'openai-compatible' })), +})); + +import { ConfigService } from '@/config/config'; +import type { AIProviderName } from '@/shared/types'; +import { createAgentAdapter } from '@/stages/review/agentic/adapters'; +import { AnthropicAdapter } from '@/stages/review/agentic/adapters/anthropic-adapter'; +import { ConfigurableAgentAdapter } from '@/stages/review/agentic/adapters/configurable-agent-adapter'; +import { OpenAIAdapter } from '@/stages/review/agentic/adapters/openai-adapter'; + +const mockGetInstance = ConfigService.getInstance as jest.MockedFunction< + typeof ConfigService.getInstance +>; + +function setupProvider(adapterType: 'anthropic' | 'openai' | 'openai-compatible' | undefined) { + mockGetInstance.mockReturnValue({ + resolveAgentAdapterType: jest.fn().mockReturnValue(adapterType), + } as unknown as ReturnType); +} + +describe('createAgentAdapter — adapter selection', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns AnthropicAdapter for provider "anthropic"', () => { + setupProvider('anthropic'); + const adapter = createAgentAdapter('anthropic' as AIProviderName); + expect(AnthropicAdapter).toHaveBeenCalledTimes(1); + expect(adapter).toEqual({ type: 'anthropic' }); + }); + + it('returns OpenAIAdapter for provider "openai"', () => { + setupProvider('openai'); + const adapter = createAgentAdapter('openai' as AIProviderName); + expect(OpenAIAdapter).toHaveBeenCalledTimes(1); + expect(adapter).toEqual({ type: 'openai' }); + }); + + it('returns OpenAIAdapter for provider "github" (openai adapter type)', () => { + setupProvider('openai'); + const adapter = createAgentAdapter('github' as AIProviderName); + expect(OpenAIAdapter).toHaveBeenCalledTimes(1); + expect(adapter).toEqual({ type: 'openai' }); + }); + + it('returns ConfigurableAgentAdapter for provider "openai-compatible"', () => { + setupProvider('openai-compatible'); + const adapter = createAgentAdapter('openai-compatible' as AIProviderName); + expect(ConfigurableAgentAdapter).toHaveBeenCalledTimes(1); + expect(adapter).toEqual({ type: 'openai-compatible' }); + }); + + it('throws for a provider with no agentic support', () => { + setupProvider(undefined); + expect(() => createAgentAdapter('bedrock' as AIProviderName)).toThrow( + 'Agentic mode is not implemented for provider: bedrock', + ); + }); +}); From d96af73b0c6b0a973168111bf32f5b610bde8b01 Mon Sep 17 00:00:00 2001 From: Valdis Pornieks Date: Thu, 11 Jun 2026 21:02:57 +0300 Subject: [PATCH 02/10] fix: declare baseUrl/apiKey in schema and remove dead resolveProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues from code review: 1. Delete resolveProvider() from ConfigurableAgentAdapter — the function was dead code (claude/openai branches unreachable since the adapter is only ever instantiated for openai-compatible) and a drift risk. Hardcode 'openai-compatible' as the library provider. 2. Declare baseUrl and apiKey in aiStageConfigSchema with env-var fallbacks (OPENAI_BASE_URL / OPENAI_API_KEY) resolved in getResolvedStageConfig, matching the pattern used by other providers. Remove the as string | undefined casts in the executor that suppressed missing type declarations. --- CHANGELOG.md | 1 + qualops-config.schema.json | 9 +++++++++ src/config/config-schema.ts | 6 ++++++ src/config/config.ts | 10 +++++++++- src/shared/types/index.ts | 4 ++++ .../agentic/adapters/configurable-agent-adapter.ts | 9 +-------- 6 files changed, 30 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c1c6360..b47670e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- `baseUrl` and `apiKey` fields added to `aiStageConfig` schema for `openai-compatible` providers, with `OPENAI_BASE_URL` / `OPENAI_API_KEY` env-var fallbacks resolved in `getResolvedStageConfig`. - `openai-compatible` provider support for agentic review mode via `@eggai/configurable-agent` (Vercel AI SDK v5 agent loop). Any provider with a custom `baseUrl` can now run the full agentic security audit without SDK-specific adapters. - Provider-dialect smoke spec: `npm run test:smoke` runs the 4 AI caller stages migrated in PR #145 (`file-reviewer`, `validation-resolver`, `dedup-resolver`, `root-cause-extract`) against each real provider (`anthropic`, `openai`, `bedrock`, `github`) using a slice fixture as input. Validates that the structured-output dialect path returns a zod-validated response without throwing. Implemented as a Jest spec under `tests/smoke/` with its own `jest.smoke.config.ts` — not picked up by default `npm test` (whose `roots` are limited to `tests/unit/`). Provider config comes from `ConfigService` + the existing `PROVIDER_DEFAULTS` table, not a duplicated table. Providers with missing credentials are `describe.skip()`-ed; providers with malformed credentials fail loudly via the provider class's own `validateApiKey()`. Input is a slice fixture under `evals/datasets/inbox/smoke-sql-injection/`, loosely following TDR 0002. Nightly + manual CI workflow at `.github/workflows/provider-dialect-smoke.yml`. Automates the unchecked manual smoke item from PR #145's test plan; distinct from the deferred per-stage golden-evals item which validates output quality. - `unstructured` dialect for LLM models without `response_format: {type: "json_schema"}` support (e.g. Llama, Qwen2.5, DeepSeek-V3, Phi, older o1-series). When `isUnstructured()` is true, the pipeline runs a full prose path: `ProseFileReviewer` → `ProseValidationResolver` → `ProseDeduplicationResolver` → `session/prose-report.md`. No JSON parsing, no structured schemas — the model writes free-form prose and subsequent stages refine it in-kind. diff --git a/qualops-config.schema.json b/qualops-config.schema.json index 7a21ae78..232f83bc 100644 --- a/qualops-config.schema.json +++ b/qualops-config.schema.json @@ -190,6 +190,15 @@ "description": "Optional model output token cap.", "type": "integer", "minimum": 1 + }, + "baseUrl": { + "description": "Base URL for openai-compatible providers. Defaults to OPENAI_BASE_URL env var.", + "type": "string", + "format": "uri" + }, + "apiKey": { + "description": "API key for openai-compatible providers. Defaults to OPENAI_API_KEY env var.", + "type": "string" } }, "passthrough": true diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts index 221d0ae2..65f966fc 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -73,6 +73,12 @@ const aiStageConfigSchema = z .optional() .meta({ description: 'Sampling temperature used by compatible providers.' }), maxTokens: z.int().min(1).optional().meta({ description: 'Optional model output token cap.' }), + baseUrl: z.string().url().optional().meta({ + description: 'Base URL for openai-compatible providers. Defaults to OPENAI_BASE_URL env var.', + }), + apiKey: z.string().optional().meta({ + description: 'API key for openai-compatible providers. Defaults to OPENAI_API_KEY env var.', + }), }) .passthrough() .meta({ diff --git a/src/config/config.ts b/src/config/config.ts index 174ec044..966d3012 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -333,7 +333,15 @@ export class ConfigService { getResolvedStageConfig(stage: string): ResolvedStageConfig { const raw = this.getAIStageConfig(stage); const { provider, model } = this.resolveModel({ stage: raw }); - return { ...raw, provider, model }; + const baseUrl = raw.baseUrl ?? process.env.OPENAI_BASE_URL; + const apiKey = raw.apiKey ?? process.env.OPENAI_API_KEY; + return { + ...raw, + provider, + model, + ...(baseUrl !== undefined && { baseUrl }), + ...(apiKey !== undefined && { apiKey }), + }; } /** diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 96240e46..4b27d093 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -52,6 +52,8 @@ export type AIStageConfig = { outputPerMillion: number; temperature?: number; maxTokens?: number; + baseUrl?: string; + apiKey?: string; [key: string]: unknown; }; @@ -62,6 +64,8 @@ export type ResolvedStageConfig = { outputPerMillion: number; temperature?: number; maxTokens?: number; + baseUrl?: string; + apiKey?: string; [key: string]: unknown; }; diff --git a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts index 93f42747..61b17605 100644 --- a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts +++ b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts @@ -13,18 +13,11 @@ function toJsonSchema(schema: z.ZodObject): Record Date: Thu, 11 Jun 2026 21:46:55 +0300 Subject: [PATCH 03/10] fix: scope OPENAI_BASE_URL/OPENAI_API_KEY fallback to openai-compatible provider --- src/config/config.ts | 23 +++++--- src/stages/review/agentic/agentic-executor.ts | 4 +- tests/unit/config/config.spec.ts | 58 +++++++++++++++++++ 3 files changed, 76 insertions(+), 9 deletions(-) diff --git a/src/config/config.ts b/src/config/config.ts index 966d3012..755d4216 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -333,14 +333,23 @@ export class ConfigService { getResolvedStageConfig(stage: string): ResolvedStageConfig { const raw = this.getAIStageConfig(stage); const { provider, model } = this.resolveModel({ stage: raw }); - const baseUrl = raw.baseUrl ?? process.env.OPENAI_BASE_URL; - const apiKey = raw.apiKey ?? process.env.OPENAI_API_KEY; + const credentials = this.resolveCredentials(provider, raw); + return { ...raw, provider, model, ...credentials }; + } + + private resolveCredentials( + provider: AIProviderName, + raw: AIStageConfig, + ): { baseUrl?: string; apiKey?: string } { + if (provider === 'openai-compatible') { + return { + baseUrl: raw.baseUrl ?? process.env.OPENAI_BASE_URL, + apiKey: raw.apiKey ?? process.env.OPENAI_API_KEY, + }; + } return { - ...raw, - provider, - model, - ...(baseUrl !== undefined && { baseUrl }), - ...(apiKey !== undefined && { apiKey }), + ...(raw.baseUrl !== undefined && { baseUrl: raw.baseUrl }), + ...(raw.apiKey !== undefined && { apiKey: raw.apiKey }), }; } diff --git a/src/stages/review/agentic/agentic-executor.ts b/src/stages/review/agentic/agentic-executor.ts index 3613f1b5..7b629732 100644 --- a/src/stages/review/agentic/agentic-executor.ts +++ b/src/stages/review/agentic/agentic-executor.ts @@ -110,8 +110,8 @@ export class AgenticExecutor { workspaceRoot: this.config.bash?.workspaceRoot ?? this.cwd, }, }, - baseUrl: stageConfig.baseUrl as string | undefined, - apiKey: stageConfig.apiKey as string | undefined, + baseUrl: stageConfig.baseUrl, + apiKey: stageConfig.apiKey, onToolCall: (turn, name, input) => { turnIndex = turn; allToolCalls.push({ turn, name, input }); diff --git a/tests/unit/config/config.spec.ts b/tests/unit/config/config.spec.ts index d1728790..aa104cd5 100644 --- a/tests/unit/config/config.spec.ts +++ b/tests/unit/config/config.spec.ts @@ -562,6 +562,64 @@ describe('ConfigService', () => { 'AI configuration for stage "review" not found in .qualopsrc.json', ); }); + + it('uses config baseUrl/apiKey for openai-compatible provider', () => { + const instance = ConfigService.getInstance(); + instance.set('ai', { + reviewStage: { + model: { provider: 'openai-compatible', name: 'my-model' } as any, + inputPerMillion: 1, + outputPerMillion: 2, + baseUrl: 'https://my.api/v1', + apiKey: 'sk-config', + }, + }); + const result = instance.getResolvedStageConfig('review'); + expect(result.baseUrl).toBe('https://my.api/v1'); + expect(result.apiKey).toBe('sk-config'); + }); + + it('falls back to OPENAI_BASE_URL/OPENAI_API_KEY env vars for openai-compatible', () => { + const instance = ConfigService.getInstance(); + instance.set('ai', { + reviewStage: { + model: { provider: 'openai-compatible', name: 'my-model' } as any, + inputPerMillion: 1, + outputPerMillion: 2, + }, + }); + const origBase = process.env.OPENAI_BASE_URL; + const origKey = process.env.OPENAI_API_KEY; + process.env.OPENAI_BASE_URL = 'https://env.api/v1'; + process.env.OPENAI_API_KEY = 'sk-env'; + try { + const result = instance.getResolvedStageConfig('review'); + expect(result.baseUrl).toBe('https://env.api/v1'); + expect(result.apiKey).toBe('sk-env'); + } finally { + process.env.OPENAI_BASE_URL = origBase; + process.env.OPENAI_API_KEY = origKey; + } + }); + + it('does not apply OPENAI env vars for non-openai-compatible providers', () => { + const instance = ConfigService.getInstance(); + instance.set('ai', { + reviewStage: { + model: { provider: 'anthropic', name: 'claude-sonnet-4-6' } as any, + inputPerMillion: 3, + outputPerMillion: 15, + }, + }); + const origBase = process.env.OPENAI_BASE_URL; + process.env.OPENAI_BASE_URL = 'https://should-not-appear/v1'; + try { + const result = instance.getResolvedStageConfig('review'); + expect(result.baseUrl).toBeUndefined(); + } finally { + process.env.OPENAI_BASE_URL = origBase; + } + }); }); describe('resolveAgentModel', () => { From fd186f5831bd52957657aa68547a2ecf21b366ba Mon Sep 17 00:00:00 2001 From: Valdis Pornieks Date: Fri, 12 Jun 2026 09:29:19 +0300 Subject: [PATCH 04/10] fix: wire onToolCall from tool_call event, not execute callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool call visibility was driven from inside each tool's execute function, which fires after the model request is already in flight. Move it to the tool_call event handler to match the pattern used by AnthropicAdapter and OpenAIAdapter — fires when the model requests the tool, before execution. --- .../agentic/adapters/configurable-agent-adapter.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts index 61b17605..77589782 100644 --- a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts +++ b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts @@ -45,17 +45,11 @@ export class ConfigurableAgentAdapter implements AgentAdapter { execute: (args: Record) => Promise; } > = {}; - let turnIndex = 0; for (const def of qualopsTools.tools) { tools[def.name] = { description: def.description, inputSchema: jsonSchema(toJsonSchema(def.schema)), - execute: async (args: Record) => { - turnIndex++; - logger.info(`[Agentic/ConfigurableAgent] Tool call: ${def.name}`); - params.onToolCall?.(turnIndex, def.name, args); - return def.execute(args); - }, + execute: (args: Record) => def.execute(args), }; } @@ -64,12 +58,18 @@ export class ConfigurableAgentAdapter implements AgentAdapter { let inputTokens: number | undefined; let outputTokens: number | undefined; let errorSubtype: string | undefined; + let turnIndex = 0; await runAgent( config, [{ role: 'user', content: params.userPrompt }], async (event: AgentEvent) => { switch (event.type) { + case 'tool_call': + turnIndex++; + logger.info(`[Agentic/ConfigurableAgent] Tool call: ${event.name}`); + params.onToolCall?.(turnIndex, event.name, event.args); + break; case 'final': output = event.content; inputTokens = event.usage?.inputTokens; From 5df367972f9e5003111451b3519c1e361d9b2455 Mon Sep 17 00:00:00 2001 From: Valdis Pornieks Date: Fri, 12 Jun 2026 09:35:19 +0300 Subject: [PATCH 05/10] fix: inject subagent prompts and tool list into ConfigurableAgentAdapter system prompt The configurable-agent loop is a single flat agent with no subagent concept. Previously it ignored params.agents entirely, so the model received only the bare orchestrator system prompt with no tool-use instructions, causing it to finish in one step without calling any tools. Flatten all enabled subagent prompts into the system prompt under "## Review perspectives", and append an "## Available tools" section listing the tool names so the model knows what investigative tools it has access to. --- .../adapters/configurable-agent-adapter.ts | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts index 77589782..93487f81 100644 --- a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts +++ b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts @@ -13,9 +13,29 @@ function toJsonSchema(schema: z.ZodObject): Record 0) { + parts.push('## Review perspectives\n'); + for (const [name, def] of agentEntries) { + parts.push(`### ${name}\n${def.prompt}`); + } + } + + if (toolNames.length > 0) { + parts.push( + `## Available tools\nYou have access to the following tools to investigate the codebase: ${toolNames.join(', ')}. Use them to trace cross-file issues, verify patterns, and confirm findings before reporting.`, + ); + } + + return parts.filter(Boolean).join('\n\n'); +} + +function buildAgentConfig(params: AgentAdapterParams, toolNames: string[]) { return { - systemPrompt: params.systemPrompt, + systemPrompt: buildSystemPrompt(params, toolNames), model: { provider: 'openai-compatible' as const, name: params.model, @@ -34,8 +54,11 @@ function buildAgentConfig(params: AgentAdapterParams) { export class ConfigurableAgentAdapter implements AgentAdapter { async run(params: AgentAdapterParams): Promise { - const config = buildAgentConfig(params); const qualopsTools = await createToolSet(params.cwd, params.toolConfig, params.skipPatterns); + const config = buildAgentConfig( + params, + qualopsTools.tools.map((t) => t.name), + ); const tools: Record< string, From dc369b4b0dd9b7fe017c534641d0d80843d086d9 Mon Sep 17 00:00:00 2001 From: Valdis Pornieks Date: Fri, 12 Jun 2026 09:41:40 +0300 Subject: [PATCH 06/10] fix: remove contradictory hardcoded prompt from buildSystemPrompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hardcoded "Use Grep/Glob ONLY if checking external dependencies" and "Max 10 tool calls" boilerplate was contradicting the subagent prompts that the ConfigurableAgentAdapter injects — causing the model to finish in one step with no tool calls. buildSystemPrompt now returns only user-configured content (systemPrompt + prompt file). The adapter is responsible for composing the full system prompt with subagent instructions and available tools. --- src/stages/review/agentic/agentic-executor.ts | 36 +------------------ .../review/agentic/agentic-executor.spec.ts | 4 +-- 2 files changed, 3 insertions(+), 37 deletions(-) diff --git a/src/stages/review/agentic/agentic-executor.ts b/src/stages/review/agentic/agentic-executor.ts index 7b629732..320459ba 100644 --- a/src/stages/review/agentic/agentic-executor.ts +++ b/src/stages/review/agentic/agentic-executor.ts @@ -181,40 +181,6 @@ export class AgenticExecutor { parts.push(content); } - const customPrompt = parts.join('\n\n'); - - return `You are a code reviewer. File contents and diffs are provided below. - -${customPrompt} - -## Process - -1. Analyze the provided code/diffs -2. Use Grep/Glob ONLY if checking external dependencies -3. Output JSON findings - -## Output Format - -\`\`\`json -[ - { - "type": "security|bug|performance|maintainability", - "severity": "critical|high|medium|low", - "description": "What the issue is", - "location": "src/file.ts:42", - "reasoning": "Why this is a problem", - "suggestion": "How to fix it", - "confidence": 8 - } -] -\`\`\` - -If no issues found, output: \`\`\`json\n[]\n\`\`\` - -## Rules - -- confidence >= 7 -- Focus on changed code -- Max 10 tool calls`; + return parts.join('\n\n'); } } diff --git a/tests/unit/stages/review/agentic/agentic-executor.spec.ts b/tests/unit/stages/review/agentic/agentic-executor.spec.ts index 796e2ec1..3a316651 100644 --- a/tests/unit/stages/review/agentic/agentic-executor.spec.ts +++ b/tests/unit/stages/review/agentic/agentic-executor.spec.ts @@ -127,9 +127,9 @@ describe('AgenticExecutor — systemPrompt / prompt composition', () => { mockCreateAgentAdapter.mockReset(); }); - it('uses default prompt when neither systemPrompt nor prompt is set', async () => { + it('passes empty system prompt when neither systemPrompt nor prompt is set', async () => { await runExecutor(makeJob()); - expect(capturedParams?.systemPrompt).toContain('You are a code reviewer'); + expect(capturedParams?.systemPrompt).toBe(''); }); it('injects inline systemPrompt into the system message', async () => { From 5397cc94eab5f62d3c1cf16faf87b8315918427c Mon Sep 17 00:00:00 2001 From: Valdis Pornieks Date: Fri, 12 Jun 2026 09:43:40 +0300 Subject: [PATCH 07/10] fix: clarify exact tool names in system prompt to override agent markdown aliases Subagent prompts reference SDK aliases (Read, Grep, Glob) that don't match the actual registered tool names (read_file, grep_files, glob_files). List tools as a bulleted list with an explicit instruction to use exact names. --- .../review/agentic/adapters/configurable-agent-adapter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts index 93487f81..509174c9 100644 --- a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts +++ b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts @@ -26,7 +26,7 @@ function buildSystemPrompt(params: AgentAdapterParams, toolNames: string[]): str if (toolNames.length > 0) { parts.push( - `## Available tools\nYou have access to the following tools to investigate the codebase: ${toolNames.join(', ')}. Use them to trace cross-file issues, verify patterns, and confirm findings before reporting.`, + `## Available tools\nThe following tools are available. Use their EXACT names when calling them — do not use aliases like Read, Grep, or Glob:\n${toolNames.map((n) => `- ${n}`).join('\n')}\n\nUse these tools to trace cross-file issues, verify patterns, and confirm findings before reporting.`, ); } From a7dd7b654c7c8a7866bf612498faafd18d0973c0 Mon Sep 17 00:00:00 2001 From: Valdis Pornieks Date: Fri, 12 Jun 2026 10:59:53 +0300 Subject: [PATCH 08/10] refactor: remove hardcoded framework label from file headers in user prompt The framework-detector defaulted every file to 'typescript' regardless of actual language. The file extension in the path already signals the language to the model, so the label adds noise without value. --- .../adapters/configurable-agent-adapter.ts | 30 +++++++++++++++++-- src/stages/review/agentic/agentic-executor.ts | 25 ++++++++++++++-- src/stages/review/agentic/prompt-builder.ts | 2 +- .../review/agentic/prompt-builder.spec.ts | 10 ------- 4 files changed, 51 insertions(+), 16 deletions(-) diff --git a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts index 509174c9..a4a1c491 100644 --- a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts +++ b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts @@ -76,6 +76,12 @@ export class ConfigurableAgentAdapter implements AgentAdapter { }; } + // --- DEBUG --- + process.stderr.write('\n=== [DEBUG] SYSTEM PROMPT ===\n' + config.systemPrompt + '\n'); + process.stderr.write('\n=== [DEBUG] USER PROMPT ===\n' + params.userPrompt + '\n'); + process.stderr.write('=== [DEBUG] END PROMPTS ===\n\n'); + // --- END DEBUG --- + try { let output = ''; let inputTokens: number | undefined; @@ -102,15 +108,35 @@ export class ConfigurableAgentAdapter implements AgentAdapter { ); break; case 'error': - logger.error( - `[Agentic/ConfigurableAgent] Error: code=${event.code} ${event.message}`, + logger.warn( + `[Agentic/ConfigurableAgent] Error: code=${event.code} — ${event.message}`, ); + if (event.details) { + process.stderr.write( + `\n=== [DEBUG] ERROR DETAILS ===\n${JSON.stringify(event, null, 2)}\n=== [DEBUG] END ERROR ===\n\n`, + ); + } switch (event.code) { case 'tool_call_on_final_step': errorSubtype = 'error_max_turns'; break; case 'stream_error': errorSubtype = 'error_provider_unavailable'; + if (event.partialContent) { + logger.warn( + `[Agentic/ConfigurableAgent] Recovering partial response (${event.partialContent.length} chars)`, + ); + output = event.partialContent; + } + break; + case 'rate_limit_tokens': + errorSubtype = 'error_rate_limit_tokens'; + if (event.partialContent) { + logger.warn( + `[Agentic/ConfigurableAgent] Recovering partial response before rate limit (${event.partialContent.length} chars)`, + ); + output = event.partialContent; + } break; case 'structured_output_failed': errorSubtype = 'error_content_filter'; diff --git a/src/stages/review/agentic/agentic-executor.ts b/src/stages/review/agentic/agentic-executor.ts index 320459ba..453ddeff 100644 --- a/src/stages/review/agentic/agentic-executor.ts +++ b/src/stages/review/agentic/agentic-executor.ts @@ -128,13 +128,32 @@ export class AgenticExecutor { output: result.errorSubtype ? { error: result.errorSubtype } : result.output, }); - if (result.output) { - logger.info( - `[Agentic] Success result (first 500 chars): ${result.output.substring(0, 500)}`, + if (result.errorSubtype === 'error_rate_limit_tokens') { + throw new Error( + `[Agentic] Job "${this.job.name}" failed: input tokens consumed the entire TPM budget leaving no room for output. ` + + `Reduce the number of files reviewed per job or upgrade your rate limit tier.`, + ); + } + + if (result.errorSubtype) { + logger.warn( + `[Agentic] Job "${this.job.name}" completed with error: ${result.errorSubtype}`, ); + } + + if (result.output) { + logger.info(`[Agentic] Result (first 500 chars): ${result.output.substring(0, 500)}`); const parsed = parseIssuesFromResult(result.output, files, this.job.name, this.cwd); + if (parsed.length === 0 && result.output.trim().length > 0) { + logger.warn( + `[Agentic] Job "${this.job.name}" returned output but no parseable issues — response may be truncated (${result.output.length} chars). ` + + `This is often caused by token rate limits leaving insufficient tokens for the response.`, + ); + } issues.push(...parsed); logger.info(`[Agentic] Parsed ${parsed.length} issues from result`); + } else if (result.errorSubtype) { + logger.warn(`[Agentic] Job "${this.job.name}" returned no output — review incomplete`); } } catch (error) { logger.error( diff --git a/src/stages/review/agentic/prompt-builder.ts b/src/stages/review/agentic/prompt-builder.ts index ddcad0c6..cbe6f580 100644 --- a/src/stages/review/agentic/prompt-builder.ts +++ b/src/stages/review/agentic/prompt-builder.ts @@ -49,7 +49,7 @@ export function buildFileContext( content = formatFileContent(file.content, budget); } - const header = `## ${file.path}${file.framework ? ` (${file.framework})` : ''}`; + const header = `## ${file.path}`; return `${header}\n\n${content}`; } diff --git a/tests/unit/stages/review/agentic/prompt-builder.spec.ts b/tests/unit/stages/review/agentic/prompt-builder.spec.ts index 37244af7..fec4353f 100644 --- a/tests/unit/stages/review/agentic/prompt-builder.spec.ts +++ b/tests/unit/stages/review/agentic/prompt-builder.spec.ts @@ -53,16 +53,6 @@ describe('buildFileContext', () => { expect(result).toContain('## src/foo.ts'); }); - it('includes framework in header when present', () => { - const result = buildFileContext( - file('src/foo.ts', 'x', { framework: 'react' }), - 'full', - 8000, - 50000, - ); - expect(result).toContain('## src/foo.ts (react)'); - }); - it('uses diff content when mode is "diff" and rawDiff is present', () => { const f = file('src/foo.ts', 'x', { rawDiff: '@@ -1 +1 @@ -old\n+new' }); const result = buildFileContext(f, 'diff', 8000, 50000); From 74f3907199cb159bcb3d7bcfff0e9de2c1ed33b9 Mon Sep 17 00:00:00 2001 From: Valdis Pornieks Date: Fri, 12 Jun 2026 11:10:15 +0300 Subject: [PATCH 09/10] feat: surface rate limit and stream errors from agentic review as hard failures - Map rate_limit_tokens to a thrown error with an actionable message - Map stream_error to warning with partial response recovery - Warn when output exists but yields no parseable issues (truncation hint) --- .../agentic/adapters/configurable-agent-adapter.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts index a4a1c491..6662326a 100644 --- a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts +++ b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts @@ -76,12 +76,6 @@ export class ConfigurableAgentAdapter implements AgentAdapter { }; } - // --- DEBUG --- - process.stderr.write('\n=== [DEBUG] SYSTEM PROMPT ===\n' + config.systemPrompt + '\n'); - process.stderr.write('\n=== [DEBUG] USER PROMPT ===\n' + params.userPrompt + '\n'); - process.stderr.write('=== [DEBUG] END PROMPTS ===\n\n'); - // --- END DEBUG --- - try { let output = ''; let inputTokens: number | undefined; @@ -111,11 +105,6 @@ export class ConfigurableAgentAdapter implements AgentAdapter { logger.warn( `[Agentic/ConfigurableAgent] Error: code=${event.code} — ${event.message}`, ); - if (event.details) { - process.stderr.write( - `\n=== [DEBUG] ERROR DETAILS ===\n${JSON.stringify(event, null, 2)}\n=== [DEBUG] END ERROR ===\n\n`, - ); - } switch (event.code) { case 'tool_call_on_final_step': errorSubtype = 'error_max_turns'; From d2877bbea8098c60d778818372af05673f4ba97a Mon Sep 17 00:00:00 2001 From: Valdis Pornieks Date: Fri, 12 Jun 2026 12:33:21 +0300 Subject: [PATCH 10/10] refactor: use ERROR_SUBTYPE_MAP and HARD_FAILURES set for extensible error handling Replace the switch statement in ConfigurableAgentAdapter with a lookup map (ERROR_SUBTYPE_MAP) so new error codes can be added in one line. Unknown codes throw immediately rather than silently swallowing. Replace the single error_rate_limit_tokens check in AgenticExecutor with a HARD_FAILURES set that also covers error_max_tokens (max output tokens reached). Add max_tokens_reached to the error subtype map so finishReason =length is surfaced as a hard failure with partial content recovery. --- .../adapters/configurable-agent-adapter.ts | 48 ++++++++----------- src/stages/review/agentic/agentic-executor.ts | 13 +++-- 2 files changed, 28 insertions(+), 33 deletions(-) diff --git a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts index 6662326a..40c6db25 100644 --- a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts +++ b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts @@ -76,6 +76,16 @@ export class ConfigurableAgentAdapter implements AgentAdapter { }; } + // Maps configurable-agent error codes to qualops error subtypes. + // Codes not in this map are treated as unrecoverable and will throw. + const ERROR_SUBTYPE_MAP: Record = { + tool_call_on_final_step: 'error_max_turns', + stream_error: 'error_provider_unavailable', + rate_limit_tokens: 'error_rate_limit_tokens', + max_tokens_reached: 'error_max_tokens', + structured_output_failed: 'error_content_filter', + }; + try { let output = ''; let inputTokens: number | undefined; @@ -101,39 +111,21 @@ export class ConfigurableAgentAdapter implements AgentAdapter { `[Agentic/ConfigurableAgent] Finished. steps=${event.steps}, stopReason=${event.stopReason}`, ); break; - case 'error': + case 'error': { logger.warn( `[Agentic/ConfigurableAgent] Error: code=${event.code} — ${event.message}`, ); - switch (event.code) { - case 'tool_call_on_final_step': - errorSubtype = 'error_max_turns'; - break; - case 'stream_error': - errorSubtype = 'error_provider_unavailable'; - if (event.partialContent) { - logger.warn( - `[Agentic/ConfigurableAgent] Recovering partial response (${event.partialContent.length} chars)`, - ); - output = event.partialContent; - } - break; - case 'rate_limit_tokens': - errorSubtype = 'error_rate_limit_tokens'; - if (event.partialContent) { - logger.warn( - `[Agentic/ConfigurableAgent] Recovering partial response before rate limit (${event.partialContent.length} chars)`, - ); - output = event.partialContent; - } - break; - case 'structured_output_failed': - errorSubtype = 'error_content_filter'; - break; - default: - throw new Error(`Agent failed: ${event.message}`); + const subtype = ERROR_SUBTYPE_MAP[event.code]; + if (!subtype) throw new Error(`Agent failed: ${event.message}`); + errorSubtype = subtype; + if (event.partialContent) { + logger.warn( + `[Agentic/ConfigurableAgent] Recovering partial response (${event.partialContent.length} chars)`, + ); + output = event.partialContent; } break; + } default: break; } diff --git a/src/stages/review/agentic/agentic-executor.ts b/src/stages/review/agentic/agentic-executor.ts index 453ddeff..4eee7f0c 100644 --- a/src/stages/review/agentic/agentic-executor.ts +++ b/src/stages/review/agentic/agentic-executor.ts @@ -128,10 +128,14 @@ export class AgenticExecutor { output: result.errorSubtype ? { error: result.errorSubtype } : result.output, }); - if (result.errorSubtype === 'error_rate_limit_tokens') { + // Hard failures: errors where the response cannot be trusted even partially. + // Add new unrecoverable error subtypes here. + const HARD_FAILURES = new Set(['error_rate_limit_tokens', 'error_max_tokens']); + + if (result.errorSubtype && HARD_FAILURES.has(result.errorSubtype)) { throw new Error( - `[Agentic] Job "${this.job.name}" failed: input tokens consumed the entire TPM budget leaving no room for output. ` + - `Reduce the number of files reviewed per job or upgrade your rate limit tier.`, + `[Agentic] Job "${this.job.name}" failed: ${result.errorSubtype}. ` + + `Reduce the number of files reviewed per job or check your model configuration.`, ); } @@ -146,8 +150,7 @@ export class AgenticExecutor { const parsed = parseIssuesFromResult(result.output, files, this.job.name, this.cwd); if (parsed.length === 0 && result.output.trim().length > 0) { logger.warn( - `[Agentic] Job "${this.job.name}" returned output but no parseable issues — response may be truncated (${result.output.length} chars). ` + - `This is often caused by token rate limits leaving insufficient tokens for the response.`, + `[Agentic] Job "${this.job.name}" returned output but no parseable issues — response may be truncated (${result.output.length} chars).`, ); } issues.push(...parsed);