Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
},
"homepage": "https://warden.sentry.dev",
"dependencies": {
"@agentclientprotocol/sdk": "^0.21.0",
"@anthropic-ai/claude-agent-sdk": "^0.2.22",
"@anthropic-ai/sdk": "^0.72.1",
"@inquirer/select": "^5.0.4",
Expand Down
21 changes: 21 additions & 0 deletions packages/docs/src/pages/config.astro
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,27 @@ model = "claude-opus-4-5"`}
<p><strong>Model precedence:</strong> trigger &gt; skill &gt; defaults &gt; CLI flag (<code>-m</code>) &gt; env var (<code>WARDEN_MODEL</code>). Most specific wins.</p>
<p><strong>Synthesis fallback:</strong> <code>defaults.synthesis.model</code> falls back to <code>defaults.auxiliary.model</code> when not set.</p>

<h3>Agent Client Protocol runtime</h3>

<p>Set <code>defaults.runtime</code> to <code>acp</code> to run hunk analysis through an ACP-compatible agent instead of Claude Code SDK. Use a custom command or a registry agent id.</p>

<Terminal showCopy={true}>
<Code
code={`[defaults]
runtime = "acp"

[defaults.agent.acp]
command = "atlas alta agent run"

# Or resolve an agent from the ACP registry:
# registryId = "amp-acp"`}
lang="toml"
theme="vitesse-black"
/>
</Terminal>

<p>Registry agents are resolved from <code>https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json</code> by default. Analysis, extraction repair, consolidation, and fix-quality helper calls use the selected ACP agent.</p>

<h2 id="chunking">Chunking</h2>

<p>Control how files are split for analysis. By default, Warden analyzes each hunk separately.</p>
Expand Down
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/action/triggers/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export async function executeTrigger(
apiKey: anthropicApiKey,
model: trigger.model,
runtime: trigger.runtime,
acp: trigger.acp,
auxiliaryModel: trigger.auxiliaryModel,
synthesisModel: trigger.synthesisModel,
maxTurns: trigger.maxTurns,
Expand Down
1 change: 1 addition & 0 deletions src/action/workflow/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ export async function runScheduleWorkflow(
apiKey: inputs.anthropicApiKey,
model: resolved.model,
runtime: resolved.runtime,
acp: resolved.acp,
auxiliaryModel: resolved.auxiliaryModel,
synthesisModel: resolved.synthesisModel,
maxTurns: resolved.maxTurns,
Expand Down
42 changes: 26 additions & 16 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,7 @@ interface SkillToRun {
model?: string;
maxTurns?: number;
runtime?: SkillRunnerOptions['runtime'];
acp?: SkillRunnerOptions['acp'];
auxiliaryModel?: string;
synthesisModel?: string;
auxiliaryMaxRetries?: number;
Expand All @@ -507,7 +508,7 @@ interface ProcessedResults {

type SkillRunnerOptionOverrides = Pick<
SkillRunnerOptions,
'model' | 'maxTurns' | 'runtime' | 'auxiliaryModel' | 'synthesisModel' | 'auxiliaryMaxRetries'
'model' | 'maxTurns' | 'runtime' | 'acp' | 'auxiliaryModel' | 'synthesisModel' | 'auxiliaryMaxRetries'
>;

/** Apply per-skill runner overrides on top of the shared execution defaults. */
Expand All @@ -520,6 +521,7 @@ export function mergeSkillRunnerOptions(
if (overrides.model !== undefined) merged.model = overrides.model;
if (overrides.maxTurns !== undefined) merged.maxTurns = overrides.maxTurns;
if (overrides.runtime !== undefined) merged.runtime = overrides.runtime;
if (overrides.acp !== undefined) merged.acp = overrides.acp;
if (overrides.auxiliaryModel !== undefined) merged.auxiliaryModel = overrides.auxiliaryModel;
if (overrides.synthesisModel !== undefined) merged.synthesisModel = overrides.synthesisModel;
if (overrides.auxiliaryMaxRetries !== undefined) {
Expand Down Expand Up @@ -819,20 +821,6 @@ export async function runSkills(
// Not in a git repo - that's fine for file mode
}

// Pre-flight: verify auth will work before starting analysis
try {
verifyAuth({ apiKey });
} catch (error: unknown) {
const message = (error as WardenAuthenticationError).message;
reporter.error(message);
emitEmptyRunLog(repoPath ?? cwd, options, {
code: 'auth_failed',
message,
timestamp: new Date().toISOString(),
});
return 1;
}

// Resolve config path
let configPath: string | null = null;
if (options.config) {
Expand All @@ -849,6 +837,22 @@ export async function runSkills(
const defaultAuxiliaryModel = resolveCliDefaultAuxiliaryModel(config);
const defaultSynthesisModel = resolveCliDefaultSynthesisModel(config);

// Pre-flight: verify Claude auth only when a Claude-backed run may execute.
try {
if ((config?.defaults?.runtime ?? 'claude') === 'claude') {
verifyAuth({ apiKey });
}
} catch (error: unknown) {
const message = (error as WardenAuthenticationError).message;
reporter.error(message);
emitEmptyRunLog(repoPath ?? cwd, options, {
code: 'auth_failed',
message,
timestamp: new Date().toISOString(),
});
return 1;
}

// Determine which triggers/skills to run
let skillsToRun: SkillToRun[];
if (options.skill) {
Expand All @@ -868,6 +872,7 @@ export async function runSkills(
model: match?.model ?? defaultModel,
maxTurns: match?.maxTurns ?? config?.defaults?.agent?.maxTurns ?? config?.defaults?.maxTurns,
runtime: match?.runtime ?? config?.defaults?.runtime ?? 'claude',
acp: match?.acp ?? config?.defaults?.agent?.acp,
auxiliaryModel: match?.auxiliaryModel ?? defaultAuxiliaryModel,
synthesisModel: match?.synthesisModel ?? defaultSynthesisModel,
auxiliaryMaxRetries:
Expand All @@ -894,6 +899,7 @@ export async function runSkills(
model: t.model,
maxTurns: t.maxTurns,
runtime: t.runtime,
acp: t.acp,
auxiliaryModel: t.auxiliaryModel,
synthesisModel: t.synthesisModel,
auxiliaryMaxRetries: t.auxiliaryMaxRetries,
Expand Down Expand Up @@ -926,6 +932,7 @@ export async function runSkills(
apiKey,
model: sdkModel,
runtime: config?.defaults?.runtime ?? 'claude',
acp: config?.defaults?.agent?.acp,
auxiliaryModel: defaultAuxiliaryModel,
synthesisModel: defaultSynthesisModel,
abortController,
Expand Down Expand Up @@ -1231,7 +1238,9 @@ async function runConfigMode(options: CLIOptions, reporter: Reporter): Promise<n
}

try {
verifyAuth({ apiKey });
if ((config.defaults?.runtime ?? 'claude') === 'claude') {
verifyAuth({ apiKey });
}
} catch (error: unknown) {
const message = (error as WardenAuthenticationError).message;
reporter.error(message);
Expand All @@ -1257,6 +1266,7 @@ async function runConfigMode(options: CLIOptions, reporter: Reporter): Promise<n
apiKey,
model: trigger.model,
runtime: trigger.runtime,
acp: trigger.acp,
auxiliaryModel: trigger.auxiliaryModel,
synthesisModel: trigger.synthesisModel,
abortController,
Expand Down
4 changes: 4 additions & 0 deletions src/cli/output/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,8 @@ export async function runSkillTask(
runtime: runnerOptions.runtime,
model: runnerOptions.synthesisModel,
maxRetries: runnerOptions.auxiliaryMaxRetries,
providerOptions: runnerOptions.runtime === 'acp' ? runnerOptions.acp : undefined,
abortController: runnerOptions.abortController,
});
let mergedFindings = mergeResult.findings;
if (mergeResult.usage) {
Expand All @@ -526,6 +528,8 @@ export async function runSkillTask(
runtime: runnerOptions.runtime,
model: runnerOptions.auxiliaryModel,
maxRetries: runnerOptions.auxiliaryMaxRetries,
providerOptions: runnerOptions.runtime === 'acp' ? runnerOptions.acp : undefined,
abortController: runnerOptions.abortController,
});
mergedFindings = sanitized.findings;
if (sanitized.usage) {
Expand Down
40 changes: 40 additions & 0 deletions src/config/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,23 @@ describe('resolveSkillConfigs', () => {
expect(resolved?.auxiliaryMaxRetries).toBe(2);
});

it('resolves ACP agent runtime options', () => {
const config: WardenConfig = {
...baseConfig,
defaults: {
runtime: 'acp',
agent: {
acp: { command: 'atlas alta agent run' },
},
},
};

const [resolved] = resolveSkillConfigs(config);

expect(resolved?.runtime).toBe('acp');
expect(resolved?.acp).toEqual({ command: 'atlas alta agent run' });
});

it('falls back to auxiliary model when synthesis model is unset', () => {
const config: WardenConfig = {
...baseConfig,
Expand Down Expand Up @@ -853,6 +870,29 @@ describe('maxTurns config', () => {
expect(result.data?.defaults?.synthesis?.model).toBe('claude-opus-4-5');
});

it('accepts ACP custom command and registry defaults', () => {
const customCommand = WardenConfigSchema.safeParse({
version: 1,
defaults: {
runtime: 'acp',
agent: { acp: { command: 'atlas alta agent run' } },
},
skills: [],
});
const registry = WardenConfigSchema.safeParse({
version: 1,
defaults: {
runtime: 'acp',
agent: { acp: { registryId: 'amp-acp', registryUrl: 'https://example.com/registry.json' } },
},
skills: [],
});

expect(customCommand.success).toBe(true);
expect(registry.success).toBe(true);
expect(registry.data?.defaults?.agent?.acp?.registryUrl).toBe('https://example.com/registry.json');
});

it('rejects unknown runtimes', () => {
const config = {
version: 1,
Expand Down
6 changes: 6 additions & 0 deletions src/config/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type RunnerConfig,
type LogsConfig,
type RuntimeName,
type AcpAgentRuntimeConfig,
} from './schema.js';
import type { SeverityThreshold, ConfidenceThreshold } from '../types/index.js';

Expand Down Expand Up @@ -292,6 +293,8 @@ export interface ResolvedTrigger {
maxTurns?: number;
/** Runtime backend for all model-backed execution. */
runtime?: RuntimeName;
/** Agent Client Protocol runtime options. */
acp?: AcpAgentRuntimeConfig;
/** Model for auxiliary structured model calls. */
auxiliaryModel?: string;
/** Model for post-analysis synthesis/consolidation. */
Expand Down Expand Up @@ -340,6 +343,7 @@ export function resolveSkillConfigs(
const envModel = emptyToUndefined(process.env['WARDEN_MODEL']);
const result: ResolvedTrigger[] = [];
const runtime = defaults?.runtime ?? 'claude';
const acp = defaults?.agent?.acp;
const auxiliaryModel = emptyToUndefined(defaults?.auxiliary?.model);
const synthesisModel =
emptyToUndefined(defaults?.synthesis?.model) ??
Expand Down Expand Up @@ -386,6 +390,7 @@ export function resolveSkillConfigs(
model: baseModel,
maxTurns: baseMaxTurns,
runtime,
acp,
auxiliaryModel,
synthesisModel,
auxiliaryMaxRetries,
Expand Down Expand Up @@ -413,6 +418,7 @@ export function resolveSkillConfigs(
model: emptyToUndefined(trigger.model) ?? baseModel,
maxTurns: trigger.maxTurns ?? baseMaxTurns,
runtime,
acp,
auxiliaryModel,
synthesisModel,
auxiliaryMaxRetries,
Expand Down
16 changes: 16 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,27 @@ export type TriggerType = z.infer<typeof TriggerTypeSchema>;
export { RuntimeNameSchema };
export type { RuntimeName };

export const AcpAgentRuntimeConfigSchema = z.object({
/** Custom ACP agent command, e.g. "atlas alta agent run". */
command: z.string().min(1).optional(),
/** Additional arguments appended to the custom ACP command. */
args: z.array(z.string()).optional(),
/** ACP registry agent id resolved from the public registry. */
registryId: z.string().min(1).optional(),
/** ACP registry URL. Defaults to the public latest registry. */
registryUrl: z.string().url().optional(),
/** Extra environment variables for the ACP agent process. */
env: z.record(z.string(), z.string()).optional(),
}).strict();
export type AcpAgentRuntimeConfig = z.infer<typeof AcpAgentRuntimeConfigSchema>;

export const AgentRuntimeConfigSchema = z.object({
/** Model for repo-aware skill execution. Overrides legacy defaults.model. */
model: z.string().optional(),
/** Maximum agentic turns for repo-aware skill execution. Overrides legacy defaults.maxTurns. */
maxTurns: z.number().int().positive().optional(),
/** Agent Client Protocol runtime options. Used when defaults.runtime = "acp". */
acp: AcpAgentRuntimeConfigSchema.optional(),
}).strict();
export type AgentRuntimeConfig = z.infer<typeof AgentRuntimeConfigSchema>;

Expand Down
19 changes: 14 additions & 5 deletions src/sdk/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ function isAbortRequested(error: unknown, abortController?: AbortController): bo
async function parseHunkOutput(
result: SkillRunResult,
filename: string,
options: SkillRunnerOptions
options: SkillRunnerOptions,
repoPath: string,
): Promise<ParseHunkOutputResult> {
if (result.status !== 'success') {
// SDK error - not an extraction failure, just no findings
Expand All @@ -86,6 +87,9 @@ async function parseHunkOutput(
runtime: options.runtime,
model: options.auxiliaryModel,
maxRetries: options.auxiliaryMaxRetries,
repoPath,
providerOptions: options.runtime === 'acp' ? options.acp : undefined,
abortController: options.abortController,
});

if (fallback.success) {
Expand Down Expand Up @@ -200,9 +204,10 @@ async function analyzeHunk(
try {
const runtimeName = options.runtime ?? 'claude';
const runtime = getRuntime(runtimeName);
const providerOptions =
runtimeName === 'claude'
? { pathToClaudeCodeExecutable: options.pathToClaudeCodeExecutable }
const providerOptions = runtimeName === 'claude'
? { pathToClaudeCodeExecutable: options.pathToClaudeCodeExecutable }
: runtimeName === 'acp'
? options.acp
: undefined;
const { result: resultMessage, authError } = await runtime.runSkill({
systemPrompt,
Expand Down Expand Up @@ -271,7 +276,7 @@ async function analyzeHunk(
};
}

const parseResult = await parseHunkOutput(resultMessage, hunkCtx.filename, options);
const parseResult = await parseHunkOutput(resultMessage, hunkCtx.filename, options, repoPath);

// Filter findings outside hunk line range (defense-in-depth)
const hunkRange = getHunkLineRange(hunkCtx.hunk);
Expand Down Expand Up @@ -803,6 +808,8 @@ export async function runSkill(
runtime: options.runtime,
model: options.synthesisModel,
maxRetries: options.auxiliaryMaxRetries,
providerOptions: options.runtime === 'acp' ? options.acp : undefined,
abortController: options.abortController,
});
let mergedFindings = mergeResult.findings;
if (mergeResult.usage) {
Expand All @@ -814,6 +821,8 @@ export async function runSkill(
runtime: options.runtime,
model: options.auxiliaryModel,
maxRetries: options.auxiliaryMaxRetries,
providerOptions: options.runtime === 'acp' ? options.acp : undefined,
abortController: options.abortController,
});
mergedFindings = sanitized.findings;
if (sanitized.usage) {
Expand Down
Loading