Skip to content
Closed
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
22 changes: 22 additions & 0 deletions packages/coding-agent/src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ export interface Args {
noThemes?: boolean;
noContextFiles?: boolean;
autonomous?: boolean;
autonomousGates?: string[];
autonomousGateRetries?: number;
autonomousGateTimeoutMs?: number;
listModels?: string | true;
offline?: boolean;
verbose?: boolean;
Expand Down Expand Up @@ -169,6 +172,13 @@ export function parseArgs(args: string[]): Args {
result.noContextFiles = true;
} else if (arg === "--autonomous") {
result.autonomous = true;
} else if (arg === "--autonomous-gate" && i + 1 < args.length) {
result.autonomousGates = result.autonomousGates ?? [];
result.autonomousGates.push(args[++i]);
} else if (arg === "--autonomous-gate-retries" && i + 1 < args.length) {
result.autonomousGateRetries = parsePositiveInt(args[++i], "--autonomous-gate-retries", result);
} else if (arg === "--autonomous-gate-timeout-ms" && i + 1 < args.length) {
result.autonomousGateTimeoutMs = parsePositiveInt(args[++i], "--autonomous-gate-timeout-ms", result);
} else if (arg === "--list-models") {
// Check if next arg is a search pattern (not a flag or file arg)
if (i + 1 < args.length && !args[i + 1].startsWith("-") && !args[i + 1].startsWith("@")) {
Expand Down Expand Up @@ -266,6 +276,9 @@ ${chalk.bold("Options:")}
--no-themes Disable theme discovery and loading
--no-context-files, -nc Disable AGENTS.md and CLAUDE.md discovery and loading
--autonomous Continue autonomously on help requests or soft blockers
--autonomous-gate <command> Run a command before autonomous mode may finish (repeatable)
--autonomous-gate-retries <n> Max autonomous retries per failed gate (default: 3)
--autonomous-gate-timeout-ms <n> Timeout per autonomous gate command in milliseconds
--export <file> Export session file to HTML and exit
--list-models [search] List available models (with optional fuzzy search)
--verbose Force verbose startup (overrides quietStartup setting)
Expand Down Expand Up @@ -376,3 +389,12 @@ ${chalk.bold("Built-in Tool Names:")}
edit - Edit files with find/replace (off by default)
`);
}

function parsePositiveInt(flagValue: string, flagName: string, result: Args): number | undefined {
const parsed = Number(flagValue);
if (!Number.isInteger(parsed) || parsed <= 0) {
result.diagnostics.push({ type: "error", message: `${flagName} requires a positive integer` });
return undefined;
}
return parsed;
}
4 changes: 3 additions & 1 deletion packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1569,7 +1569,9 @@ export class AgentSession {
if (goalMessages.length > 0 || signal?.aborted) {
return goalMessages;
}
const autonomousMessage = nextAutonomousContinuation(this._autonomousState, context.message, { cwd: this._cwd });
const autonomousMessage = await nextAutonomousContinuation(this._autonomousState, context.message, {
cwd: this._cwd,
});
return autonomousMessage ? [autonomousMessage] : [];
}

Expand Down
87 changes: 82 additions & 5 deletions packages/coding-agent/src/core/autonomous.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ export interface AgentAutonomousFinishContractConfig {
continuationPrompt?: string;
}

export interface AgentAutonomousGateConfig {
commands?: string[];
onFail?: "feed_output_back";
maxRetries?: number;
timeoutMs?: number;
}

export interface AgentAutonomousConfig {
enabled?: boolean;
maxContinuations?: number;
Expand All @@ -14,6 +21,7 @@ export interface AgentAutonomousConfig {
timeoutMs?: number;
continuationPrompt?: string;
finishContract?: AgentAutonomousFinishContractConfig;
gates?: AgentAutonomousGateConfig;
}

export interface AgentAutonomousStatus {
Expand All @@ -22,7 +30,7 @@ export interface AgentAutonomousStatus {
turnsUsed: number;
tokensUsed: number;
startedAt?: number;
limits: Required<Omit<AgentAutonomousConfig, "enabled" | "continuationPrompt" | "finishContract">>;
limits: Required<Omit<AgentAutonomousConfig, "enabled" | "continuationPrompt" | "finishContract" | "gates">>;
}

export const DEFAULT_AUTONOMOUS_CONTINUATION_PROMPT =
Expand All @@ -32,7 +40,7 @@ export const DEFAULT_AUTONOMOUS_FINISH_PROMPT =
"Autonomous finish contract is not satisfied. Do not stop with 'I think it works'. Continue until one of these is true: a clean git patch exists, configured tests passed, an explicit blocker artifact is written, or a no-op is justified with evidence. Inspect the repo and run the relevant checks now.";

export const DEFAULT_AUTONOMOUS_LIMITS: Required<
Omit<AgentAutonomousConfig, "enabled" | "continuationPrompt" | "finishContract">
Omit<AgentAutonomousConfig, "enabled" | "continuationPrompt" | "finishContract" | "gates">
> = {
maxContinuations: 3,
maxTurns: 12,
Expand All @@ -46,9 +54,11 @@ export interface AutonomousRuntimeState {
turnsUsed: number;
tokensUsed: number;
startedAt?: number;
limits: Required<Omit<AgentAutonomousConfig, "enabled" | "continuationPrompt" | "finishContract">>;
limits: Required<Omit<AgentAutonomousConfig, "enabled" | "continuationPrompt" | "finishContract" | "gates">>;
continuationPrompt: string;
finishContract: Required<AgentAutonomousFinishContractConfig>;
gates: Required<AgentAutonomousGateConfig>;
gateAttempts: Record<string, number>;
}

export interface AutonomousDecision {
Expand All @@ -75,6 +85,13 @@ export function createAutonomousRuntimeState(config?: AgentAutonomousConfig): Au
enabled: config?.finishContract?.enabled ?? true,
continuationPrompt: config?.finishContract?.continuationPrompt?.trim() || DEFAULT_AUTONOMOUS_FINISH_PROMPT,
},
gates: {
commands: [...(config?.gates?.commands ?? [])],
onFail: config?.gates?.onFail ?? "feed_output_back",
maxRetries: normalizeLimit(config?.gates?.maxRetries, 3),
timeoutMs: normalizeLimit(config?.gates?.timeoutMs, 5 * 60 * 1000),
},
gateAttempts: {},
};
}

Expand Down Expand Up @@ -109,19 +126,28 @@ export function addAutonomousUsage(state: AutonomousRuntimeState, usage: Usage |
state.tokensUsed += usage?.totalTokens ?? 0;
}

export function nextAutonomousContinuation(
export async function nextAutonomousContinuation(
state: AutonomousRuntimeState,
message: AssistantMessage,
options: { cwd?: string } = {},
now = Date.now(),
): UserMessage | undefined {
): Promise<UserMessage | undefined> {
if (!state.enabled) {
return undefined;
}
const decision = shouldAutonomouslyContinue(state, message, options, now);
if (!decision.shouldContinue) {
return undefined;
}
if (decision.reason === "finish_contract" && options.cwd) {
const gateContinuation = runAutonomousQualityGates(state, options.cwd, now);
if (gateContinuation || state.gates.commands.length > 0) {
if (gateContinuation) {
state.continuationsUsed++;
}
return gateContinuation;
}
}
state.continuationsUsed++;
return {
role: "user",
Expand Down Expand Up @@ -249,6 +275,57 @@ export function hasAutonomousFinishEvidence(text: string, cwd?: string): boolean
);
}

function runAutonomousQualityGates(
state: AutonomousRuntimeState,
cwd: string,
timestamp: number,
): UserMessage | undefined {
for (const command of state.gates.commands) {
const result = spawnSync(command, {
cwd,
encoding: "utf8",
shell: true,
timeout: state.gates.timeoutMs,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status === 0 && !result.error) {
state.gateAttempts[command] = 0;
continue;
}

const attempt = (state.gateAttempts[command] ?? 0) + 1;
state.gateAttempts[command] = attempt;
if (attempt > state.gates.maxRetries) {
return undefined;
}
const exitText =
result.error?.message ??
(result.signal ? `terminated by ${result.signal}` : `exited ${result.status ?? "unknown"}`);
const output = truncateGateOutput([result.stdout, result.stderr].filter(Boolean).join("\n").trim());
return {
role: "user",
content: [
{
type: "text",
text:
`Autonomous quality gate failed (attempt ${attempt}/${state.gates.maxRetries}): \`${command}\` ${exitText}.\n` +
(output ? `\nOutput:\n${output}\n` : "\n") +
"\nFix the failure and continue. Do not finish until quality gates pass or you have a concrete external blocker with evidence.",
},
],
timestamp,
};
}
return undefined;
}

function truncateGateOutput(output: string, maxChars = 6000): string {
if (output.length <= maxChars) {
return output;
}
return `${output.slice(0, maxChars)}\n... [truncated ${output.length - maxChars} chars]`;
}

function hasGitWorktreeChanges(cwd: string): boolean {
const result = spawnSync("git", ["--no-optional-locks", "status", "--porcelain"], {
cwd,
Expand Down
15 changes: 14 additions & 1 deletion packages/coding-agent/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,7 @@ function runtimeConfigFromArgs(
agentDir: string,
sessionDir: string | undefined,
): AgentSessionRuntimeConfig {
const hasAutonomousGates = (parsed.autonomousGates?.length ?? 0) > 0;
return {
cwd,
agentDir,
Expand All @@ -663,7 +664,19 @@ function runtimeConfigFromArgs(
themes: resolveCliPaths(cwd, parsed.themes),
noThemes: parsed.noThemes,
noContextFiles: parsed.noContextFiles,
autonomous: parsed.autonomous ? { enabled: true } : undefined,
autonomous:
parsed.autonomous || hasAutonomousGates
? {
enabled: true,
gates: hasAutonomousGates
? {
commands: parsed.autonomousGates,
maxRetries: parsed.autonomousGateRetries,
timeoutMs: parsed.autonomousGateTimeoutMs,
}
: undefined,
}
: undefined,
extensionFlagValues: parsed.unknownFlags.size > 0 ? Object.fromEntries(parsed.unknownFlags.entries()) : undefined,
};
}
Expand Down
21 changes: 21 additions & 0 deletions packages/coding-agent/test/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,27 @@ describe("parseArgs", () => {
});
});

describe("autonomous gate flags", () => {
test("parses repeatable --autonomous-gate flags", () => {
const result = parseArgs(["--autonomous-gate", "npm test", "--autonomous-gate", "npm run lint"]);
expect(result.autonomousGates).toEqual(["npm test", "npm run lint"]);
});

test("parses autonomous gate retry and timeout limits", () => {
const result = parseArgs(["--autonomous-gate-retries", "4", "--autonomous-gate-timeout-ms", "1234"]);
expect(result.autonomousGateRetries).toBe(4);
expect(result.autonomousGateTimeoutMs).toBe(1234);
});

test("reports invalid autonomous gate limits", () => {
const result = parseArgs(["--autonomous-gate-retries", "nope"]);
expect(result.diagnostics).toContainEqual({
type: "error",
message: "--autonomous-gate-retries requires a positive integer",
});
});
});

describe("tool flags", () => {
test("parses --no-tools flag", () => {
const result = parseArgs(["--no-tools"]);
Expand Down
40 changes: 40 additions & 0 deletions packages/coding-agent/test/suite/agent-session-autonomous.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,46 @@ describe("AgentSession autonomous mode", () => {
expect(harness.session.getAutonomousStatus().continuationsUsed).toBe(0);
});

it("accepts passing quality gates as finish evidence", async () => {
const harness = await createHarness({
autonomous: {
enabled: true,
maxContinuations: 2,
gates: { commands: [`${process.execPath} -e "process.exit(0)"`] },
},
});
harnesses.push(harness);
harness.setResponses([fauxAssistantMessage("Done.")]);

await harness.session.prompt("make the change");

expect(getUserTexts(harness)).toEqual(["make the change"]);
expect(harness.session.getAutonomousStatus().continuationsUsed).toBe(0);
});

it("feeds failing quality gate output back into autonomous mode", async () => {
const harness = await createHarness({
autonomous: {
enabled: true,
maxContinuations: 2,
gates: {
commands: [`${process.execPath} -e "console.error('gate failed'); process.exit(1)"`],
maxRetries: 1,
},
},
});
harnesses.push(harness);
harness.setResponses([fauxAssistantMessage("Done."), fauxAssistantMessage("I wrote BLOCKER.md with evidence.")]);

await harness.session.prompt("make the change");

const users = getUserTexts(harness);
expect(users[0]).toBe("make the change");
expect(users[1]).toContain("Autonomous quality gate failed");
expect(users[1]).toContain("gate failed");
expect(harness.session.getAutonomousStatus().continuationsUsed).toBe(1);
});

it("classifies soft blockers separately from real external blockers", () => {
const state = createAutonomousRuntimeState({ enabled: true });

Expand Down