Skip to content
Merged
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
65 changes: 65 additions & 0 deletions src/adapters/agenticLoop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,71 @@ describe('runAgenticLoop nudge budgets (INT-1925)', () => {
});
});

describe('runAgenticLoop finishValidator (AGT-4300)', () => {
it('rejects a would-be final answer and continues the SAME conversation instead of returning', async () => {
const calls: number[] = [];
let callApiInvocations = 0;
const callApi = async (messages: ChatMessage[]) => {
callApiInvocations++;
// Every call after the first sees the growing history — proof this is
// one continuous session, not a caller restarting a fresh conversation
// per attempt (the whole point of the fix: keep the warm KV-cache prefix).
calls.push(messages.length);
return finalResp(callApiInvocations === 1 ? 'thin answer' : 'faithful answer');
};
const finishValidator = vi.fn(async (finalText: string) => {
if (finalText === 'thin answer') return { ok: false as const, nudge: 'try harder' };
return { ok: true as const };
});

const result = await runAgenticLoop({
prompt: 'draft this task', cwd: process.cwd(), model: 'test', callApi,
maxTurns: 10, webTools: false,
finishValidator, finishValidatorMaxRetries: 1,
});

expect(callApiInvocations).toBe(2); // one rejected, one accepted — no restart
expect(finishValidator).toHaveBeenCalledTimes(2);
expect(finishValidator).toHaveBeenNthCalledWith(1, 'thin answer', 1);
expect(finishValidator).toHaveBeenNthCalledWith(2, 'faithful answer', 2);
expect(result.text).toBe('faithful answer');
// The second call's message array is longer than the first's — the nudge
// was appended as a new turn on the SAME messages array, not a fresh one.
expect(calls[1]).toBeGreaterThan(calls[0]);
});

it('gives up and returns the last candidate once finishValidatorMaxRetries is exhausted', async () => {
let callApiInvocations = 0;
const callApi = async () => {
callApiInvocations++;
return finalResp('always thin');
};
const finishValidator = vi.fn(async () => ({ ok: false as const, nudge: 'try harder' }));

const result = await runAgenticLoop({
prompt: 'draft this task', cwd: process.cwd(), model: 'test', callApi,
maxTurns: 10, webTools: false,
finishValidator, finishValidatorMaxRetries: 1,
});

// maxRetries=1 allows exactly one rejection before the loop stops asking
// the validator and returns the last text as-is, rather than looping
// forever on a model that never satisfies the gate.
expect(callApiInvocations).toBe(2); // one rejected round, one accepted-by-exhaustion round
expect(finishValidator).toHaveBeenCalledTimes(2);
expect(result.text).toBe('always thin');
});

it('does not call finishValidator at all when unset (default off)', async () => {
const callApi = async () => finalResp('plain answer');
const result = await runAgenticLoop({
prompt: 'do a thing', cwd: process.cwd(), model: 'test', callApi,
maxTurns: 10, webTools: false,
});
expect(result.text).toBe('plain answer');
});
});

describe('runAgenticLoop warehouse discovery (AGT-4128)', () => {
it('points every tool-loop worker at the warehouse index before it asks for local-only data', async () => {
let firstMessages: ChatMessage[] = [];
Expand Down
45 changes: 43 additions & 2 deletions src/adapters/agenticLoop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { WEB_TOOL_DEFINITIONS } from './webTools.js';
import { detectRateLimit, RateLimitError } from './rateLimitError.js';
import { isInfraError } from './errorClassification.js';
import { parseSearchReplaceBlocks, applyEditBlock, type EditFormat } from '../support/editParser.js';
import type { CliRunResult } from './types.js';
import type { CliRunResult, FinishValidation } from './types.js';
import type { ChatUsage } from './chatStream.js';
import { recordUsage, type UsageAttribution } from '../support/usageLedger.js';
import { COORDINATION_TOOL_DEFINITIONS, type CoordinationToolContext } from '../coordination/coordinationTools.js';
Expand Down Expand Up @@ -132,6 +132,20 @@ export interface AgenticLoopOptions {
* 기본 0 (비활성) — 수정 없는 작업(진단·분석)도 정상이므로 옵트인.
*/
nudgeMaxOnNoEdit?: number;
/**
* Gate on the loop's finish itself, not just on whether it edited a file.
* Called with the model's would-be final answer before the loop returns;
* `{ ok: false, nudge }` appends the nudge as a new user turn and
* `continue`s the SAME conversation instead of returning. This is what
* lets a caller reject an insufficient answer (e.g. draftAnalyzer's hard
* gate) without restarting a fresh `spawnCli` call, which used to throw
* away the provider's warm KV-cache prefix on every retry — measured on
* vela: the retry's first call landed at 0.3% cache against 95%+ for the
* calls around it (AGT-4300). Bounded by `finishValidatorMaxRetries`.
*/
finishValidator?: (finalText: string, attempt: number) => FinishValidation | Promise<FinishValidation>;
/** Max times `finishValidator` may reject an answer before the loop gives up and returns it anyway. Default 0. */
finishValidatorMaxRetries?: number;
/** Verification-harness files for which edit/write are refused (see tools.ts ToolExecOptions) */
protectedFiles?: string[];
/** bash tool timeout — docker-based tests need minutes (default 30s) */
Expand Down Expand Up @@ -241,6 +255,8 @@ export async function runAgenticLoop(options: AgenticLoopOptions): Promise<Agent
compactAfterMessages = 60,
keepRecentMessages = 16,
nudgeMaxOnNoEdit = 0,
finishValidator,
finishValidatorMaxRetries = 0,
protectedFiles,
bashTimeoutMs,
webTools = true,
Expand Down Expand Up @@ -360,6 +376,7 @@ export async function runAgenticLoop(options: AgenticLoopOptions): Promise<Agent
// could exhaust it and then slip past the guard, ending analysis-only. (INT-1925)
let noEditNudgesUsed = 0;
let readLoopNudgesUsed = 0;
let finishValidatorRetriesUsed = 0;
// AGT-4054: an operator or another agent can reach out mid-task without this
// agent asking first — nothing else in the loop surfaces that unprompted, so
// track how long it has been since coordination_read last actually consumed
Expand Down Expand Up @@ -612,7 +629,31 @@ export async function runAgenticLoop(options: AgenticLoopOptions): Promise<Agent
// Normalize it to empty so the final-answer recovery below retries instead
// of returning an effectively blank success. (INT-2879)
const content = assistantMsg.content;
finalText = typeof content === 'string' && content.trim() ? content : '';
const candidateFinalText = typeof content === 'string' && content.trim() ? content : '';

// Caller-side finish gate (AGT-4300). A rejected answer used to mean the
// CALLER started a brand-new spawnCli conversation for the retry — cold
// start, none of this session's warm cache. Rejecting HERE instead keeps
// the same messages array and just appends another turn, the same way the
// no-edit guard above does. Called on EVERY candidate, including the final
// one after retries are exhausted, so a caller logging inside the
// validator sees every attempt, not just the ones that got a retry.
if (finishValidator) {
const verdict = await finishValidator(candidateFinalText, finishValidatorRetriesUsed + 1);
if (!verdict.ok && finishValidatorRetriesUsed < finishValidatorMaxRetries) {
finishValidatorRetriesUsed++;
onLog?.(
`↩ Finish gate: answer rejected (retry ${finishValidatorRetriesUsed}/${finishValidatorMaxRetries})`,
);
messages.push({ role: 'assistant', content: assistantMsg.content ?? '' });
messages.push({ role: 'user', content: verdict.nudge ?? 'That answer was not accepted. Try again.' });
continue;
}
// Either accepted, or retries are exhausted — fall through and return
// this candidate as-is rather than looping forever on a model that
// never satisfies the gate.
}
finalText = candidateFinalText;
break;
}

Expand Down
2 changes: 2 additions & 0 deletions src/adapters/atlascloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ export class AtlasCloudCliAdapter implements CliAdapter {
onLog: options.onLog,
enableTools: options.enableTools ?? true,
nudgeMaxOnNoEdit: options.nudgeMaxOnNoEdit,
finishValidator: options.finishValidator,
finishValidatorMaxRetries: options.finishValidatorMaxRetries,
protectedFiles: options.protectedFiles,
bashTimeoutMs: options.bashTimeoutMs,
webTools: options.webTools,
Expand Down
2 changes: 2 additions & 0 deletions src/adapters/gpt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ export class GptCliAdapter implements CliAdapter {
onLog: options.onLog,
enableTools: options.enableTools ?? true,
nudgeMaxOnNoEdit: options.nudgeMaxOnNoEdit,
finishValidator: options.finishValidator,
finishValidatorMaxRetries: options.finishValidatorMaxRetries,
protectedFiles: options.protectedFiles,
bashTimeoutMs: options.bashTimeoutMs,
webTools: options.webTools,
Expand Down
2 changes: 2 additions & 0 deletions src/adapters/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ export class LocalModelAdapter implements CliAdapter {
onLog: options.onLog,
enableTools: (options.enableTools ?? true) && supportsTools,
nudgeMaxOnNoEdit: options.nudgeMaxOnNoEdit,
finishValidator: options.finishValidator,
finishValidatorMaxRetries: options.finishValidatorMaxRetries,
protectedFiles: options.protectedFiles,
bashTimeoutMs: options.bashTimeoutMs,
webTools: options.webTools,
Expand Down
2 changes: 2 additions & 0 deletions src/adapters/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ export class OpenRouterCliAdapter implements CliAdapter {
onLog: options.onLog,
enableTools: options.enableTools ?? true,
nudgeMaxOnNoEdit: options.nudgeMaxOnNoEdit,
finishValidator: options.finishValidator,
finishValidatorMaxRetries: options.finishValidatorMaxRetries,
protectedFiles: options.protectedFiles,
bashTimeoutMs: options.bashTimeoutMs,
webTools: options.webTools,
Expand Down
30 changes: 30 additions & 0 deletions src/adapters/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,22 @@ export type { WorkerResult, ReviewResult };

export type AdapterName = 'codex' | 'codex-responses' | 'gpt' | 'local' | 'lmstudio' | 'openrouter' | 'atlascloud' | 'claude' | 'cc-router' | 'cursor';

/**
* Result of validating a would-be final answer from the agentic loop
* (see `CliRunOptions.finishValidator`).
*/
export interface FinishValidation {
/** Accept the answer and let the loop return it. */
ok: boolean;
/**
* When `ok` is false, the message appended as a new user turn so the model
* retries IN THE SAME conversation (as an assistant/user exchange), rather
* than the caller starting a brand-new `spawnCli` call. Required when `ok`
* is false.
*/
nudge?: string;
}

/**
* Raw result from a CLI process execution
*/
Expand Down Expand Up @@ -139,6 +155,20 @@ export interface CliRunOptions {
* - 'whole-file': write_file rewrites only.
*/
editFormat?: 'json' | 'search-replace' | 'whole-file';
/**
* Gate on the loop's finish, not just on whether it edited a file. When
* the loop is about to return a final answer with no more tool calls,
* this is called with that text; returning `{ ok: false, nudge }` appends
* the nudge as a new user turn and continues the SAME conversation
* instead of returning — unlike a caller-side retry that calls `spawnCli`
* again, this keeps the provider's warm KV-cache prefix instead of paying
* for a cold start on every retry. Bounded by `finishValidatorMaxRetries`
* (default 0 — no retries; the loop returns the first answer as-is).
* (AGT-4300)
*/
finishValidator?: (finalText: string, attempt: number) => FinishValidation | Promise<FinishValidation>;
/** Max times `finishValidator` may reject an answer before the loop gives up and returns it anyway. Default 0. */
finishValidatorMaxRetries?: number;
}

/**
Expand Down
125 changes: 88 additions & 37 deletions src/agents/draftAnalyzer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,65 +105,116 @@ describe('runDraftAnalysis fallback', () => {
).rejects.toBeInstanceOf(RateLimitError);
});

it('drafter hard gate: retries on the same adapter when the brief is insufficient', async () => {
it('drafter hard gate: retries in the SAME spawnCli call when the brief is insufficient (AGT-4300)', async () => {
// The gate retry used to be a caller-side loop that called spawnCli again —
// a fresh conversation that threw away the just-warmed provider cache
// (measured on vela: the retry's first call landed at 0.3% cache against
// 95%+ around it). It now retries INSIDE one spawnCli call via
// finishValidator, which the real agenticLoop invokes with each would-be
// final answer; this mock plays that same role so the test exercises the
// actual wiring (finishValidatorMaxRetries, the nudge, the attempt count)
// rather than asserting a call count that no longer means what it used to.
vi.spyOn(adapterModule, 'getDefaultAdapterName').mockReturnValue('codex');
vi.spyOn(adapterModule, 'getAdapter').mockReturnValue(makeAdapter('codex'));

vi.spyOn(adapterModule, 'spawnCli')
// attempt 1: thin brief (no completionCriteria) → insufficient → retry
.mockResolvedValueOnce({
exitCode: 0,
stdout: JSON.stringify({
taskType: 'feature',
intentSummary: 'do the thing',
relevantFiles: [],
suggestedApproach: 'figure it out',
}),
stderr: '',
durationMs: 1,
} as CliRunResult)
// attempt 2: faithful brief with execution-grounded criteria → sufficient
.mockResolvedValueOnce({
exitCode: 0,
stdout: JSON.stringify({
taskType: 'feature',
intentSummary: 'Wire the resolver into the streaming path',
relevantFiles: ['src/streaming.ts'],
suggestedApproach: 'call resolve_turn_model from build_request',
completionCriteria: ['resolve_turn_model invoked from streaming.ts (call site cited)'],
}),
stderr: '',
durationMs: 1,
} as CliRunResult);
const thinBrief = JSON.stringify({
taskType: 'feature',
intentSummary: 'do the thing',
relevantFiles: [],
suggestedApproach: 'figure it out',
});
const faithfulBrief = JSON.stringify({
taskType: 'feature',
intentSummary: 'Wire the resolver into the streaming path',
relevantFiles: ['src/streaming.ts'],
suggestedApproach: 'call resolve_turn_model from build_request',
completionCriteria: ['resolve_turn_model invoked from streaming.ts (call site cited)'],
});

const spawnCliSpy = vi.spyOn(adapterModule, 'spawnCli').mockImplementation(async (_adapter, options) => {
expect(options.finishValidatorMaxRetries).toBeGreaterThan(0);
const first = await options.finishValidator!(thinBrief, 1);
expect(first.ok).toBe(false);
expect(first.nudge).toBeTruthy();
const second = await options.finishValidator!(faithfulBrief, 2);
expect(second.ok).toBe(true);
return { exitCode: 0, stdout: faithfulBrief, stderr: '', durationMs: 1 } as CliRunResult;
});

const result = await runDraftAnalysis({
taskTitle: 'Wire resolver',
taskDescription: 'Test',
projectPath: '/tmp/project',
});

expect(adapterModule.spawnCli).toHaveBeenCalledTimes(2); // gate retry on same adapter
expect(spawnCliSpy).toHaveBeenCalledTimes(1); // one conversation, not a fresh one per attempt
expect(result.sufficient).toBe(true);
expect(result.completionCriteria).toHaveLength(1);
});

it('drafter hard gate: marks insufficient when retries still yield a thin brief', async () => {
it('drafter hard gate: marks insufficient when the finish gate exhausts its retries on a thin brief', async () => {
vi.spyOn(adapterModule, 'getDefaultAdapterName').mockReturnValue('codex');
vi.spyOn(adapterModule, 'getAdapter').mockReturnValue(makeAdapter('codex'));
// Always thin → never passes the gate.
vi.spyOn(adapterModule, 'spawnCli').mockResolvedValue({
exitCode: 0,
stdout: JSON.stringify({ taskType: 'feature', intentSummary: 'x', relevantFiles: [], suggestedApproach: 'y' }),
stderr: '',
durationMs: 1,
} as CliRunResult);

const thinBrief = JSON.stringify({ taskType: 'feature', intentSummary: 'x', relevantFiles: [], suggestedApproach: 'y' });

const spawnCliSpy = vi.spyOn(adapterModule, 'spawnCli').mockImplementation(async (_adapter, options) => {
const maxRetries = options.finishValidatorMaxRetries ?? 0;
let verdict = await options.finishValidator!(thinBrief, 1);
for (let attempt = 2; !verdict.ok && attempt <= maxRetries + 1; attempt += 1) {
verdict = await options.finishValidator!(thinBrief, attempt);
}
// Retries exhausted without an `ok` verdict — the real loop returns the
// last candidate text as-is rather than looping forever.
return { exitCode: 0, stdout: thinBrief, stderr: '', durationMs: 1 } as CliRunResult;
});

const result = await runDraftAnalysis({ taskTitle: 'T', taskDescription: 'D', projectPath: '/tmp/project' });

expect(adapterModule.spawnCli).toHaveBeenCalledTimes(2); // exhausts gate retries
expect(spawnCliSpy).toHaveBeenCalledTimes(1);
expect(result.sufficient).toBe(false);
});

it('falls back to one fresh retry when finishValidator never got its full quota (AGT-4300 layer-2 fix)', async () => {
// finishValidator only works on adapters routed through runAgenticLoop
// (openrouter/gpt/local/atlascloud), and even there a run that exhausts
// its turn budget while still exploring never reaches the no-tool-calls
// branch where finishValidator lives. Either way, the first spawnCli call
// here behaves like those cases: it never calls options.finishValidator
// at all (as codex/claude/cursor/codex-responses/cc-router would) and just
// returns a thin brief. Without this fallback, the hard gate would give
// this adapter fewer real chances than the two it is supposed to get.
vi.spyOn(adapterModule, 'getDefaultAdapterName').mockReturnValue('codex');
vi.spyOn(adapterModule, 'getAdapter').mockReturnValue(makeAdapter('codex'));

const thinBrief = JSON.stringify({ taskType: 'feature', intentSummary: 'x', relevantFiles: [], suggestedApproach: 'y' });
const faithfulBrief = JSON.stringify({
taskType: 'feature',
intentSummary: 'Wire the resolver into the streaming path',
relevantFiles: ['src/streaming.ts'],
suggestedApproach: 'call resolve_turn_model from build_request',
completionCriteria: ['resolve_turn_model invoked from streaming.ts (call site cited)'],
});

const spawnCliSpy = vi.spyOn(adapterModule, 'spawnCli')
// First call: never invokes options.finishValidator — simulates an
// adapter that doesn't route through runAgenticLoop at all.
.mockResolvedValueOnce({ exitCode: 0, stdout: thinBrief, stderr: '', durationMs: 1 } as CliRunResult)
// Fallback fresh retry: a faithful brief this time.
.mockResolvedValueOnce({ exitCode: 0, stdout: faithfulBrief, stderr: '', durationMs: 1 } as CliRunResult);

const result = await runDraftAnalysis({ taskTitle: 'T', taskDescription: 'D', projectPath: '/tmp/project' });

expect(spawnCliSpy).toHaveBeenCalledTimes(2);
// The fallback call's prompt carries the same retry nudge the old
// per-attempt loop used to append, so weaker/non-loop adapters still get
// a stricter second try, not just a repeat of the first prompt.
const fallbackCallArgs = spawnCliSpy.mock.calls[1][1];
expect(fallbackCallArgs.prompt).toContain('Your previous brief was insufficient');
expect(result.sufficient).toBe(true);
expect(result.completionCriteria).toHaveLength(1);
});

it('does not fallback on non-quota failures', async () => {
vi.spyOn(adapterModule, 'getDefaultAdapterName').mockReturnValue('codex');
vi.spyOn(adapterModule, 'getAdapter').mockReturnValue(makeAdapter('codex'));
Expand Down
Loading
Loading