diff --git a/src/action/main.ts b/src/action/main.ts index 3da3a301..141fd43d 100644 --- a/src/action/main.ts +++ b/src/action/main.ts @@ -318,6 +318,7 @@ async function runScheduledAnalysis( const report = await runSkill(skill, context, { apiKey: inputs.anthropicApiKey, model: resolved.model, + maxTurns: trigger.maxTurns ?? config.defaults?.maxTurns, pathToClaudeCodeExecutable: claudePath, }); console.log(`Found ${report.findings.length} findings`); @@ -518,6 +519,7 @@ async function run(): Promise { const report = await runSkill(skill, context, { apiKey: inputs.anthropicApiKey, model: trigger.model, + maxTurns: trigger.maxTurns ?? config.defaults?.maxTurns, pathToClaudeCodeExecutable: claudePath, }); console.log(`Found ${report.findings.length} findings`); diff --git a/src/cli/main.ts b/src/cli/main.ts index 099e965d..7b32cfd6 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -104,6 +104,7 @@ async function runSkills( let repoPath: string | undefined; let skillsConfig: SkillDefinition[] | undefined; let defaultsModel: string | undefined; + let defaultsMaxTurns: number | undefined; try { repoPath = getRepoRoot(cwd); @@ -114,6 +115,7 @@ async function runSkills( const config = loadWardenConfig(dirname(configPath)); skillsConfig = config.skills; defaultsModel = config.defaults?.model; + defaultsMaxTurns = config.defaults?.maxTurns; } } catch { // Not in a git repo or no config - that's fine @@ -122,7 +124,7 @@ async function runSkills( // Build skill tasks // Model precedence: defaults.model > CLI flag > WARDEN_MODEL env var > SDK default const model = defaultsModel ?? options.model ?? process.env['WARDEN_MODEL']; - const runnerOptions: SkillRunnerOptions = { apiKey, model, abortController }; + const runnerOptions: SkillRunnerOptions = { apiKey, model, abortController, maxTurns: defaultsMaxTurns }; const tasks: SkillTaskOptions[] = skillNames.map((skillName) => ({ name: skillName, failOn: options.failOn, @@ -432,7 +434,12 @@ async function runConfigMode(options: CLIOptions, reporter: Reporter): Promise resolveSkillAsync(trigger.skill, repoPath, config.skills), context, - runnerOptions: { apiKey, model: trigger.model, abortController }, + runnerOptions: { + apiKey, + model: trigger.model, + abortController, + maxTurns: trigger.maxTurns ?? config.defaults?.maxTurns, + }, })); // Run triggers with listr2 diff --git a/src/config/loader.test.ts b/src/config/loader.test.ts index 80b1b30e..d8071e20 100644 --- a/src/config/loader.test.ts +++ b/src/config/loader.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { resolveTrigger } from './loader.js'; -import type { Trigger, WardenConfig } from './schema.js'; +import { WardenConfigSchema, type Trigger, type WardenConfig } from './schema.js'; describe('resolveTrigger', () => { const baseTrigger: Trigger = { @@ -194,3 +194,64 @@ describe('resolveTrigger', () => { }); }); }); + +describe('maxTurns config', () => { + it('accepts maxTurns in defaults', () => { + const config = { + version: 1, + defaults: { + maxTurns: 25, + }, + triggers: [], + }; + + const result = WardenConfigSchema.safeParse(config); + expect(result.success).toBe(true); + expect(result.data?.defaults?.maxTurns).toBe(25); + }); + + it('accepts maxTurns in trigger', () => { + const config = { + version: 1, + triggers: [ + { + name: 'test', + event: 'pull_request', + actions: ['opened'], + skill: 'security-review', + maxTurns: 30, + }, + ], + }; + + const result = WardenConfigSchema.safeParse(config); + expect(result.success).toBe(true); + expect(result.data?.triggers[0]?.maxTurns).toBe(30); + }); + + it('rejects non-positive maxTurns', () => { + const config = { + version: 1, + defaults: { + maxTurns: 0, + }, + triggers: [], + }; + + const result = WardenConfigSchema.safeParse(config); + expect(result.success).toBe(false); + }); + + it('rejects non-integer maxTurns', () => { + const config = { + version: 1, + defaults: { + maxTurns: 10.5, + }, + triggers: [], + }; + + const result = WardenConfigSchema.safeParse(config); + expect(result.success).toBe(false); + }); +}); diff --git a/src/config/schema.ts b/src/config/schema.ts index 92a6a1ca..c4f61607 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -72,6 +72,8 @@ export const TriggerSchema = z.object({ output: OutputConfigSchema.optional(), /** Model to use for this trigger (e.g., 'claude-sonnet-4-20250514'). Uses SDK default if not specified. */ model: z.string().optional(), + /** Maximum agentic turns (API round-trips) per hunk analysis. Overrides defaults.maxTurns. */ + maxTurns: z.number().int().positive().optional(), /** Schedule-specific configuration. Only used when event is 'schedule'. */ schedule: ScheduleConfigSchema.optional(), }).refine( @@ -114,6 +116,8 @@ export const DefaultsSchema = z.object({ output: OutputConfigSchema.optional(), /** Default model for all triggers (e.g., 'claude-sonnet-4-20250514') */ model: z.string().optional(), + /** Maximum agentic turns (API round-trips) per hunk analysis. Default: 50 */ + maxTurns: z.number().int().positive().optional(), }); export type Defaults = z.infer; diff --git a/src/sdk/runner.ts b/src/sdk/runner.ts index af6d9ec8..2f9ef81f 100644 --- a/src/sdk/runner.ts +++ b/src/sdk/runner.ts @@ -236,7 +236,7 @@ async function analyzeHunk( repoPath: string, options: SkillRunnerOptions ): Promise { - const { maxTurns = 5, model, abortController, pathToClaudeCodeExecutable } = options; + const { maxTurns = 50, model, abortController, pathToClaudeCodeExecutable } = options; const systemPrompt = buildHunkSystemPrompt(skill); const userPrompt = buildHunkUserPrompt(hunkCtx);