Skip to content

Commit 920fc87

Browse files
authored
feat: agent self-correction via validation feedback loop (#57)
* feat: add quick-checks validation for fast typecheck/build feedback Restructure validation into composable steps so typecheck (~5s) runs independently before full validation. Quick checks short-circuit on typecheck failure and format errors as actionable agent prompts, laying the foundation for the agent retry loop. * feat: add retry loop for agent self-correction on validation failures Extend the async generator in agent-interface to yield follow-up correction prompts when quick-checks (typecheck/build) fail. The agent retains full conversation context and gets up to 2 chances to fix its own mistakes before results surface to the user. Configurable via maxRetries option (default 2, 0 to disable). * feat: add within-session correction metrics to evals framework Add retry-aware execution to AgentExecutor using the same async generator + quick-checks pattern from production. Evals now track three tiers: first-attempt, with-correction, and with-retry pass rates. Adds --no-correction flag to disable for baseline comparison. * refactor: unify eval executor with production runAgent path AgentExecutor now delegates to the production runAgent instead of reimplementing the retry-aware async generator. Exports AgentRunConfig so evals can construct it directly, adds onMessage hook for latency tracking. Includes 13 tests verifying the wiring. * fix: recalibrate success criteria thresholds for correction-aware metrics First-attempt now means zero corrections, which is stricter than before. Lower threshold to 30% (aspirational), add withCorrectionPassRate at 90% as the primary quality gate, keep withRetryPassRate at 95%. * chore: disable dotnet eval scenario (broken SDK, no runtime) * fix: lower first-attempt threshold to 20% to match observed baseline Two eval runs show ~21-27% first-attempt rate. The correction loop consistently brings it to 93-100%. Set threshold at 20% to catch regressions without failing on normal variance. * fix: skip typecheck on non-TypeScript projects, raise first-attempt threshold detectTypecheckCommand was falling back to npx tsc --noEmit for every project including Python, Ruby, Go, etc. Now checks for tsconfig.json before falling back — no tsconfig means skip typecheck entirely. This eliminates false correction triggers on non-JS frameworks. Raises first-attempt threshold to 50% since the false positives were the main driver of the low rate. * feat: detect build systems beyond package.json for multi-language support Extend quick-checks to auto-detect Go (go.mod), Elixir (mix.exs), .NET (*.csproj), and Kotlin/Java (build.gradle) build commands from project files. Interpreted languages (Python, Ruby, PHP) pass through silently — no universal build command exists for them. * fix: bump first-attempt threshold to 80% and fix quality grader JSON parsing Raise firstAttemptPassRate from 50% to 80% now that false positives from non-TS projects are eliminated (85.7% observed in latest run). Fix quality grader parsing: the greedy regex matched braces inside <thinking> tags. Now extracts JSON only after </thinking> and uses a non-greedy pattern to avoid capturing nested objects. * chore: formatting * chore: remove comment slop and dead validation:quick events * refactor: simplify quick-checks, extract shared validateAndFormat, remove dead code Extract passResult helper (4 identical object literals → 1 function), unify parseTypecheckErrors into single regex with Set dedup, extract quickCheckValidateAndFormat shared between agent-runner and eval executor, remove getIntegration indirection and dead continueUrl param. * chore: formatting
1 parent edf6a6a commit 920fc87

23 files changed

Lines changed: 1563 additions & 188 deletions

src/lib/agent-interface.spec.ts

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { EventEmitter } from 'node:events';
3+
4+
const { mockQuery, mockConfig } = vi.hoisted(() => ({
5+
mockQuery: vi.fn(),
6+
mockConfig: {
7+
model: 'test-model',
8+
workos: { clientId: 'client_test', authkitDomain: 'test.workos.com', llmGatewayUrl: 'http://localhost:8000' },
9+
telemetry: { enabled: false, eventName: 'test_event' },
10+
proxy: { refreshThresholdMs: 300000 },
11+
nodeVersion: '20',
12+
logging: { debugMode: false },
13+
documentation: {
14+
workosDocsUrl: 'https://workos.com/docs',
15+
dashboardUrl: 'https://dashboard.workos.com',
16+
issuesUrl: 'https://github.com',
17+
},
18+
frameworks: {},
19+
legacy: { oauthPort: 3000 },
20+
branding: { showAsciiArt: false, asciiArt: '', compactAsciiArt: '', useCompact: false },
21+
},
22+
}));
23+
24+
vi.mock('@anthropic-ai/claude-agent-sdk', () => ({
25+
query: (...args: unknown[]) => mockQuery(...args),
26+
}));
27+
28+
vi.mock('../utils/debug.js', () => ({
29+
debug: vi.fn(),
30+
logInfo: vi.fn(),
31+
logWarn: vi.fn(),
32+
logError: vi.fn(),
33+
initLogFile: vi.fn(),
34+
getLogFilePath: vi.fn(() => null),
35+
}));
36+
37+
vi.mock('../utils/analytics.js', () => ({
38+
analytics: {
39+
capture: vi.fn(),
40+
setTag: vi.fn(),
41+
shutdown: vi.fn(),
42+
llmRequest: vi.fn(),
43+
incrementAgentIterations: vi.fn(),
44+
toolCalled: vi.fn(),
45+
},
46+
}));
47+
48+
vi.mock('./settings.js', () => ({
49+
getConfig: vi.fn(() => mockConfig),
50+
getAuthkitDomain: vi.fn(() => 'test.workos.com'),
51+
getCliAuthClientId: vi.fn(() => 'client_test'),
52+
}));
53+
54+
vi.mock('./credentials.js', () => ({
55+
hasCredentials: vi.fn(() => false),
56+
getCredentials: vi.fn(() => null),
57+
}));
58+
59+
vi.mock('./token-refresh.js', () => ({
60+
ensureValidToken: vi.fn(async () => ({ success: true })),
61+
}));
62+
63+
vi.mock('./credential-proxy.js', () => ({
64+
startCredentialProxy: vi.fn(),
65+
}));
66+
67+
vi.mock('../utils/urls.js', () => ({
68+
getLlmGatewayUrlFromHost: vi.fn(() => 'http://localhost:8000'),
69+
}));
70+
71+
import { runAgent, type RetryConfig } from './agent-interface.js';
72+
import { InstallerEventEmitter } from './events.js';
73+
import type { InstallerOptions } from '../utils/types.js';
74+
75+
/**
76+
* Create a mock SDK response that consumes the prompt stream and yields
77+
* responses for each prompt message. This models the real SDK behavior:
78+
* the response generator stays alive as long as prompts keep coming.
79+
*/
80+
function createMockSDKResponse(turns: Array<{ text?: string; error?: boolean }>) {
81+
return function mockQueryImpl({ prompt }: { prompt: AsyncIterable<unknown>; options: unknown }) {
82+
let turnIndex = 0;
83+
84+
async function* responseGenerator() {
85+
// Consume each prompt message and respond with the corresponding turn
86+
for await (const _promptMsg of prompt) {
87+
if (turnIndex >= turns.length) continue;
88+
89+
const turn = turns[turnIndex];
90+
turnIndex++;
91+
92+
if (turn.text) {
93+
yield {
94+
type: 'assistant',
95+
message: {
96+
content: [{ type: 'text', text: turn.text }],
97+
usage: { input_tokens: 100, output_tokens: 50 },
98+
model: 'test-model',
99+
},
100+
};
101+
}
102+
103+
yield {
104+
type: 'result',
105+
subtype: turn.error ? 'error' : 'success',
106+
result: turn.text ?? '',
107+
...(turn.error ? { errors: ['Test error'] } : {}),
108+
};
109+
}
110+
}
111+
112+
return responseGenerator();
113+
};
114+
}
115+
116+
function makeAgentConfig() {
117+
return {
118+
workingDirectory: '/tmp/test',
119+
mcpServers: {},
120+
model: 'test-model',
121+
allowedTools: [],
122+
sdkEnv: {},
123+
};
124+
}
125+
126+
function makeOptions(overrides: Partial<InstallerOptions> = {}): InstallerOptions {
127+
return {
128+
debug: false,
129+
forceInstall: false,
130+
installDir: '/tmp/test',
131+
local: true,
132+
ci: false,
133+
skipAuth: true,
134+
...overrides,
135+
};
136+
}
137+
138+
describe('runAgent retry loop', () => {
139+
let emitter: InstallerEventEmitter;
140+
let emittedEvents: Array<{ event: string; payload: unknown }>;
141+
142+
beforeEach(() => {
143+
mockQuery.mockReset();
144+
emitter = new InstallerEventEmitter();
145+
emittedEvents = [];
146+
147+
// Capture all events
148+
const originalEmit = emitter.emit.bind(emitter);
149+
emitter.emit = ((event: string, payload: unknown) => {
150+
emittedEvents.push({ event, payload });
151+
return originalEmit(event, payload);
152+
}) as typeof emitter.emit;
153+
});
154+
155+
it('returns retryCount=0 when no retryConfig provided', async () => {
156+
mockQuery.mockImplementation(createMockSDKResponse([{ text: 'Done!' }]));
157+
158+
const result = await runAgent(makeAgentConfig(), 'Test prompt', makeOptions(), undefined, emitter);
159+
160+
expect(result.error).toBeUndefined();
161+
expect(result.retryCount).toBe(0);
162+
});
163+
164+
it('returns retryCount=0 when validation passes first try', async () => {
165+
mockQuery.mockImplementation(createMockSDKResponse([{ text: 'Done!' }]));
166+
167+
const validateAndFormat = vi.fn().mockResolvedValue(null); // passes
168+
169+
const result = await runAgent(makeAgentConfig(), 'Test prompt', makeOptions(), undefined, emitter, {
170+
maxRetries: 2,
171+
validateAndFormat,
172+
});
173+
174+
expect(result.error).toBeUndefined();
175+
expect(result.retryCount).toBe(0);
176+
expect(validateAndFormat).toHaveBeenCalledTimes(1);
177+
178+
// Should emit validation:retry:start and validation:retry:complete
179+
const retryStartEvents = emittedEvents.filter((e) => e.event === 'validation:retry:start');
180+
const retryCompleteEvents = emittedEvents.filter((e) => e.event === 'validation:retry:complete');
181+
expect(retryStartEvents).toHaveLength(1);
182+
expect(retryCompleteEvents).toHaveLength(1);
183+
expect(retryCompleteEvents[0].payload).toEqual({ attempt: 1, passed: true });
184+
185+
// Should NOT emit agent:retry (no retry happened)
186+
const retryEvents = emittedEvents.filter((e) => e.event === 'agent:retry');
187+
expect(retryEvents).toHaveLength(0);
188+
});
189+
190+
it('retries once when validation fails then passes', async () => {
191+
// Two turns: initial + one retry
192+
mockQuery.mockImplementation(createMockSDKResponse([{ text: 'Initial attempt' }, { text: 'Fixed it!' }]));
193+
194+
const validateAndFormat = vi
195+
.fn()
196+
.mockResolvedValueOnce('Type error in src/foo.ts') // fail first
197+
.mockResolvedValueOnce(null); // pass second
198+
199+
const result = await runAgent(makeAgentConfig(), 'Test prompt', makeOptions(), undefined, emitter, {
200+
maxRetries: 2,
201+
validateAndFormat,
202+
});
203+
204+
expect(result.error).toBeUndefined();
205+
expect(result.retryCount).toBe(1);
206+
expect(validateAndFormat).toHaveBeenCalledTimes(2);
207+
208+
// Should emit agent:retry once
209+
const retryEvents = emittedEvents.filter((e) => e.event === 'agent:retry');
210+
expect(retryEvents).toHaveLength(1);
211+
expect(retryEvents[0].payload).toEqual({ attempt: 1, maxRetries: 2 });
212+
});
213+
214+
it('caps at maxRetries when validation always fails', async () => {
215+
// Three turns: initial + 2 retries
216+
mockQuery.mockImplementation(
217+
createMockSDKResponse([{ text: 'Attempt 1' }, { text: 'Attempt 2' }, { text: 'Attempt 3' }]),
218+
);
219+
220+
const validateAndFormat = vi.fn().mockResolvedValue('Still broken');
221+
222+
const result = await runAgent(makeAgentConfig(), 'Test prompt', makeOptions(), undefined, emitter, {
223+
maxRetries: 2,
224+
validateAndFormat,
225+
});
226+
227+
expect(result.error).toBeUndefined();
228+
expect(result.retryCount).toBe(2);
229+
// Called 2 times: after initial + after retry 1
230+
// NOT called after retry 2 because the loop exits
231+
expect(validateAndFormat).toHaveBeenCalledTimes(2);
232+
233+
const retryEvents = emittedEvents.filter((e) => e.event === 'agent:retry');
234+
expect(retryEvents).toHaveLength(2);
235+
});
236+
237+
it('preserves existing behavior with maxRetries=0', async () => {
238+
mockQuery.mockImplementation(createMockSDKResponse([{ text: 'Done!' }]));
239+
240+
const validateAndFormat = vi.fn().mockResolvedValue('Error');
241+
242+
const result = await runAgent(makeAgentConfig(), 'Test prompt', makeOptions(), undefined, emitter, {
243+
maxRetries: 0,
244+
validateAndFormat,
245+
});
246+
247+
expect(result.error).toBeUndefined();
248+
expect(result.retryCount).toBe(0);
249+
// validateAndFormat should never be called with maxRetries=0
250+
expect(validateAndFormat).not.toHaveBeenCalled();
251+
});
252+
253+
it('treats validateAndFormat errors as passed', async () => {
254+
mockQuery.mockImplementation(createMockSDKResponse([{ text: 'Done!' }]));
255+
256+
const validateAndFormat = vi.fn().mockRejectedValue(new Error('Validation crashed'));
257+
258+
const result = await runAgent(makeAgentConfig(), 'Test prompt', makeOptions(), undefined, emitter, {
259+
maxRetries: 2,
260+
validateAndFormat,
261+
});
262+
263+
expect(result.error).toBeUndefined();
264+
expect(result.retryCount).toBe(0);
265+
// Should have been called once, threw, treated as passed
266+
expect(validateAndFormat).toHaveBeenCalledTimes(1);
267+
});
268+
});

0 commit comments

Comments
 (0)