Skip to content

Commit 45a889a

Browse files
authored
fix(cli): make required_version advisory, warn on eval failure instead of prompting/exiting (#1396)
1 parent 24cad9d commit 45a889a

7 files changed

Lines changed: 185 additions & 84 deletions

File tree

apps/cli/src/commands/eval/run-eval.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,11 @@ import {
3131
subscribeToPiLogEntries,
3232
} from '@agentv/core';
3333

34-
import { enforceRequiredVersion } from '../../version-check.js';
34+
import {
35+
type VersionCheckResult,
36+
enforceRequiredVersion,
37+
formatRequiredVersionFailureNote,
38+
} from '../../version-check.js';
3539
import {
3640
type RemoteExportStatus,
3741
getRelativeRunPath,
@@ -1107,9 +1111,11 @@ export async function runEvalCommand(
11071111
// Pass a dummy file in cwd so the search starts from the working directory.
11081112
const yamlConfig = await loadConfig(path.join(cwd, '_'), repoRoot);
11091113

1110-
// Check required_version before proceeding with eval
1114+
// Check required_version before proceeding with eval. A mismatch is advisory
1115+
// unless the user explicitly opts into --strict.
1116+
let requiredVersionCheck: VersionCheckResult | undefined;
11111117
if (yamlConfig?.required_version) {
1112-
await enforceRequiredVersion(yamlConfig.required_version, {
1118+
requiredVersionCheck = await enforceRequiredVersion(yamlConfig.required_version, {
11131119
strict: normalizeBoolean(input.rawOptions.strict),
11141120
});
11151121
}
@@ -1774,6 +1780,13 @@ export async function runEvalCommand(
17741780
resolvedThreshold !== undefined ? { threshold: resolvedThreshold } : undefined;
17751781
const summary = calculateEvaluationSummary(summaryResults, thresholdOpts);
17761782
console.log(formatEvaluationSummary(summary, thresholdOpts));
1783+
if (
1784+
requiredVersionCheck &&
1785+
!requiredVersionCheck.satisfied &&
1786+
(summary.qualityFailureCount > 0 || summary.executionErrorCount > 0)
1787+
) {
1788+
console.log(`\n${formatRequiredVersionFailureNote(requiredVersionCheck)}`);
1789+
}
17771790

17781791
// Exit code: 2 when all tests are execution errors (no evaluation performed),
17791792
// 1 when any test scored below threshold.

apps/cli/src/commands/results/serve.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@
2424
* variants. They share handler functions via DataContext, differing only in
2525
* how searchDir is resolved.
2626
*
27-
* Before starting the server, the command enforces `required_version` from
28-
* the cwd's `.agentv/config.yaml` (single-project scope) via
29-
* `enforceRequiredVersion()`, matching the behavior of `agentv eval`.
27+
* Before starting the server, the command checks `required_version` from
28+
* the cwd's `.agentv/config.yaml` (single-project scope) and warns on
29+
* mismatches without prompting, self-updating, or blocking startup.
3030
*
3131
* Exported functions (for testing):
3232
* - resolveSourceFile(source, cwd) — resolves a run manifest path
@@ -2097,11 +2097,11 @@ export const resultsServeCommand = command({
20972097
}
20982098

20992099
// ── Version check ────────────────────────────────────────────────
2100-
// Enforce `required_version` from .agentv/config.yaml so Dashboard/serve
2101-
// match `agentv eval` behavior. Same prompt in TTY, warn+continue
2102-
// otherwise. Single-project scope only — when one agentv instance
2103-
// serves multiple repos with differing version requirements, a
2104-
// per-project local install is required instead.
2100+
// Check `required_version` from .agentv/config.yaml so Dashboard/serve
2101+
// match `agentv eval` behavior. Version mismatches are advisory by
2102+
// default. Single-project scope only — when one agentv instance serves
2103+
// multiple repos with differing version requirements, a per-project local
2104+
// install is required instead.
21052105
const repoRoot = await findRepoRoot(cwd);
21062106
const yamlConfig = await loadConfig(path.join(cwd, '_'), repoRoot);
21072107
if (yamlConfig?.required_version) {

apps/cli/src/self-update.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
11
/**
22
* Shared self-update logic for agentv.
33
*
4-
* Used by both `agentv self update` and the version-check prompt
5-
* when the installed version doesn't satisfy `required_version`.
4+
* Used only by the explicit `agentv self update` command. Project
5+
* `required_version` checks are advisory and never invoke this module.
66
*
7-
* When called from the version-check prompt, a `versionRange` (from the
8-
* project's `required_version` config) is passed through as the npm/bun
9-
* version specifier (e.g., `agentv@">=4.1.0"`). This ensures the update
10-
* respects the project's constraints and avoids unintended major-version jumps.
11-
*
12-
* When called from `agentv self update` (no range), it installs `@latest` for stable
13-
* versions or `@next` when the current version has a prerelease identifier.
7+
* By default it installs `@latest` for stable versions or `@next` when the
8+
* current version has a prerelease identifier. Callers may pass a
9+
* `versionRange`/dist tag when they need a specific install specifier.
1410
*
1511
* Install scope detection: if `process.argv[1]` contains `node_modules`,
1612
* agentv was invoked from a local project dependency (e.g. `npx agentv` or

apps/cli/src/version-check.ts

Lines changed: 30 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
import { coerce, major, satisfies, validRange } from 'semver';
1+
import { coerce, satisfies, validRange } from 'semver';
22

33
import packageJson from '../package.json' with { type: 'json' };
4-
import { performSelfUpdate } from './self-update.js';
54

65
const ANSI_YELLOW = '\u001b[33m';
76
const ANSI_RED = '\u001b[31m';
8-
const ANSI_GREEN = '\u001b[32m';
97
const ANSI_RESET = '\u001b[0m';
108

119
export interface VersionCheckResult {
@@ -15,8 +13,12 @@ export interface VersionCheckResult {
1513
}
1614

1715
/**
18-
* Validate and check the installed version against a required semver range.
19-
* Throws on malformed range strings.
16+
* Advisory version checks for project `required_version`.
17+
*
18+
* A mismatched installed version never prompts, self-updates, or exits by
19+
* default. Commands may print the returned warning to help users diagnose
20+
* failures, while explicit `--strict` callers can still opt into a hard gate.
21+
* Malformed ranges remain configuration errors.
2022
*/
2123
export function checkVersion(requiredVersion: string): VersionCheckResult {
2224
const currentVersion = packageJson.version;
@@ -34,23 +36,30 @@ export function checkVersion(requiredVersion: string): VersionCheckResult {
3436
};
3537
}
3638

39+
function formatVersionMismatch(result: VersionCheckResult): string {
40+
return `agentv ${result.currentVersion} does not satisfy this project's required_version ${result.requiredRange}`;
41+
}
42+
43+
export function formatRequiredVersionWarning(result: VersionCheckResult): string {
44+
return `${ANSI_YELLOW}Warning: ${formatVersionMismatch(result)}. Run \`agentv self update\`.${ANSI_RESET}`;
45+
}
46+
47+
export function formatRequiredVersionFailureNote(result: VersionCheckResult): string {
48+
return `note: ${formatVersionMismatch(result)} - this may be the cause. Run \`agentv self update\`.`;
49+
}
50+
3751
/**
38-
* Run the version compatibility check and handle user interaction.
52+
* Run the version compatibility check.
3953
*
40-
* - If the version satisfies the range, returns silently.
54+
* - If the version satisfies the range, returns the satisfied result silently.
4155
* - If the range is malformed, prints an error and exits with code 1.
42-
* - If the version is below the range:
43-
* - Interactive (TTY): warns and prompts "Update now? (Y/n)".
44-
* Y → runs self-update inline (constrained to the config range),
45-
* then exits with a message to re-run the command.
46-
* N → continues the command as-is.
47-
* - Non-interactive: warns to stderr, continues (unless strict).
48-
* - Strict mode: warns and exits with code 1.
56+
* - If the version is below the range, warns to stderr and continues.
57+
* - Strict mode remains an explicit opt-in hard failure.
4958
*/
50-
export async function enforceRequiredVersion(
59+
export function enforceRequiredVersion(
5160
requiredVersion: string,
5261
options?: { strict?: boolean },
53-
): Promise<void> {
62+
): VersionCheckResult {
5463
let result: VersionCheckResult;
5564
try {
5665
result = checkVersion(requiredVersion);
@@ -60,59 +69,19 @@ export async function enforceRequiredVersion(
6069
}
6170

6271
if (result.satisfied) {
63-
return;
72+
return result;
6473
}
6574

66-
const warning = `${ANSI_YELLOW}Warning: This project requires agentv ${result.requiredRange} but you have ${result.currentVersion}.${ANSI_RESET}`;
75+
const warning = formatRequiredVersionWarning(result);
6776

6877
if (options?.strict) {
69-
console.error(`${warning}\n Run \`agentv self update\` to upgrade.`);
78+
console.error(warning);
7079
console.error(
7180
`${ANSI_RED}Aborting: --strict mode requires the installed version to satisfy the required range.${ANSI_RESET}`,
7281
);
7382
process.exit(1);
7483
}
7584

76-
if (process.stdin.isTTY && process.stdout.isTTY) {
77-
console.warn(warning);
78-
const shouldUpdate = await promptUpdate();
79-
if (shouldUpdate) {
80-
await runInlineUpdate(result.currentVersion, result.requiredRange);
81-
}
82-
// N → continue the command without interruption
83-
} else {
84-
// Non-interactive: warn to stderr and continue
85-
process.stderr.write(`${warning}\n Run \`agentv self update\` to upgrade.\n`);
86-
}
87-
}
88-
89-
async function promptUpdate(): Promise<boolean> {
90-
const { confirm } = await import('@inquirer/prompts');
91-
return confirm({ message: 'Update now?', default: true });
92-
}
93-
94-
async function runInlineUpdate(currentVersion: string, versionRange: string): Promise<void> {
95-
// Cap at the current major version to avoid unintended breaking changes.
96-
// e.g., if current is 4.14.2 and range is ">=4.1.0", install ">=4.1.0 <5.0.0"
97-
// so that a hypothetical 5.0.0 is never pulled in by auto-update.
98-
const currentMajor = major(coerce(currentVersion) ?? currentVersion);
99-
const safeRange = `${versionRange} <${currentMajor + 1}.0.0`;
100-
101-
console.log('');
102-
const result = await performSelfUpdate({ currentVersion, versionRange: safeRange });
103-
104-
if (!result.success) {
105-
console.error(`${ANSI_RED}Update failed. Run \`agentv self update\` manually.${ANSI_RESET}`);
106-
process.exit(1);
107-
}
108-
109-
if (result.newVersion) {
110-
console.log(
111-
`\n${ANSI_GREEN}Update complete: ${currentVersion}${result.newVersion}${ANSI_RESET}`,
112-
);
113-
} else {
114-
console.log(`\n${ANSI_GREEN}Update complete.${ANSI_RESET}`);
115-
}
116-
console.log('Please re-run your command.');
117-
process.exit(0);
85+
process.stderr.write(`${warning}\n`);
86+
return result;
11887
}

apps/cli/test/eval.integration.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
44
import path from 'node:path';
55
import { fileURLToPath } from 'node:url';
66
import { execa } from 'execa';
7+
import packageJson from '../package.json' with { type: 'json' };
78
import { assertCoreBuild } from './setup-core-build.js';
89

910
assertCoreBuild();
@@ -215,6 +216,17 @@ async function writeTsOutputConfig(fixture: EvalFixture, outputDir: string): Pro
215216
);
216217
}
217218

219+
async function writeRequiredVersionConfig(
220+
fixture: EvalFixture,
221+
requiredVersion: string,
222+
): Promise<void> {
223+
await writeFile(
224+
path.join(fixture.suiteDir, '.agentv', 'config.yaml'),
225+
`required_version: "${requiredVersion}"\n`,
226+
'utf8',
227+
);
228+
}
229+
218230
async function expectFileExists(filePath: string): Promise<void> {
219231
await access(filePath);
220232
}
@@ -262,6 +274,58 @@ describe('agentv eval CLI', () => {
262274
}
263275
}, 30_000);
264276

277+
it('surfaces required_version mismatch note when the eval fails', async () => {
278+
const fixture = await createFixture();
279+
try {
280+
await writeRequiredVersionConfig(fixture, '>=999.0.0');
281+
282+
const { stdout, stderr, exitCode } = await runCli(fixture, [
283+
'eval',
284+
fixture.testFilePath,
285+
'--threshold',
286+
'0.8',
287+
]);
288+
289+
expect(exitCode).toBe(1);
290+
expect(stdout).toContain('RESULT: FAIL');
291+
expect(stdout).toContain(
292+
`note: agentv ${packageJson.version} does not satisfy this project's required_version >=999.0.0 - this may be the cause. Run \`agentv self update\`.`,
293+
);
294+
expect(stderr).toContain(
295+
`Warning: agentv ${packageJson.version} does not satisfy this project's required_version >=999.0.0. Run \`agentv self update\`.`,
296+
);
297+
expect(`${stdout}\n${stderr}`).not.toContain('Update now?');
298+
expect(`${stdout}\n${stderr}`).not.toContain('Update complete');
299+
} finally {
300+
await rm(fixture.baseDir, { recursive: true, force: true });
301+
}
302+
}, 30_000);
303+
304+
it('keeps required_version mismatch low-noise when the eval passes', async () => {
305+
const fixture = await createFixture();
306+
try {
307+
await writeRequiredVersionConfig(fixture, '>=999.0.0');
308+
309+
const { stdout, stderr, exitCode } = await runCli(fixture, [
310+
'eval',
311+
fixture.testFilePath,
312+
'--threshold',
313+
'0.5',
314+
]);
315+
316+
expect(exitCode).toBe(0);
317+
expect(stdout).toContain('RESULT: PASS');
318+
expect(stdout).not.toContain('note: agentv');
319+
expect(stderr).toContain(
320+
`Warning: agentv ${packageJson.version} does not satisfy this project's required_version >=999.0.0. Run \`agentv self update\`.`,
321+
);
322+
expect(`${stdout}\n${stderr}`).not.toContain('Update now?');
323+
expect(`${stdout}\n${stderr}`).not.toContain('Update complete');
324+
} finally {
325+
await rm(fixture.baseDir, { recursive: true, force: true });
326+
}
327+
}, 30_000);
328+
265329
it('writes canonical artifacts under an explicit --output directory', async () => {
266330
const fixture = await createFixture();
267331
try {

apps/cli/test/version-check.test.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
import { describe, expect, test } from 'bun:test';
1+
import { describe, expect, spyOn, test } from 'bun:test';
22
import packageJson from '../package.json' with { type: 'json' };
3-
import { checkVersion } from '../src/version-check.js';
3+
import {
4+
checkVersion,
5+
enforceRequiredVersion,
6+
formatRequiredVersionFailureNote,
7+
} from '../src/version-check.js';
48

59
const [major, minor] = packageJson.version.split('.').map(Number);
610

@@ -50,3 +54,53 @@ describe('checkVersion', () => {
5054
expect(result.currentVersion).toMatch(/^\d+\.\d+\.\d+/);
5155
});
5256
});
57+
58+
describe('enforceRequiredVersion', () => {
59+
test('warns and continues on mismatch by default', () => {
60+
const stderrSpy = spyOn(process.stderr, 'write').mockImplementation(() => true);
61+
const exitSpy = spyOn(process, 'exit').mockImplementation((() => {
62+
throw new Error('process.exit should not be called');
63+
}) as never);
64+
65+
try {
66+
const result = enforceRequiredVersion('>=99.0.0');
67+
68+
expect(result.satisfied).toBe(false);
69+
expect(result.requiredRange).toBe('>=99.0.0');
70+
expect(exitSpy).not.toHaveBeenCalled();
71+
expect(String(stderrSpy.mock.calls[0]?.[0] ?? '')).toContain(
72+
`agentv ${packageJson.version} does not satisfy this project's required_version >=99.0.0`,
73+
);
74+
expect(String(stderrSpy.mock.calls[0]?.[0] ?? '')).toContain('agentv self update');
75+
expect(String(stderrSpy.mock.calls[0]?.[0] ?? '')).not.toContain('Update now?');
76+
} finally {
77+
stderrSpy.mockRestore();
78+
exitSpy.mockRestore();
79+
}
80+
});
81+
82+
test('keeps strict mode as an explicit hard failure', () => {
83+
const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
84+
const exitSpy = spyOn(process, 'exit').mockImplementation(((code?: number) => {
85+
throw new Error(`process.exit(${code})`);
86+
}) as never);
87+
88+
try {
89+
expect(() => enforceRequiredVersion('>=99.0.0', { strict: true })).toThrow('process.exit(1)');
90+
expect(exitSpy).toHaveBeenCalledWith(1);
91+
} finally {
92+
errorSpy.mockRestore();
93+
exitSpy.mockRestore();
94+
}
95+
});
96+
});
97+
98+
describe('formatRequiredVersionFailureNote', () => {
99+
test('formats the eval failure diagnostic note', () => {
100+
const result = checkVersion('>=99.0.0');
101+
102+
expect(formatRequiredVersionFailureNote(result)).toBe(
103+
`note: agentv ${packageJson.version} does not satisfy this project's required_version >=99.0.0 - this may be the cause. Run \`agentv self update\`.`,
104+
);
105+
});
106+
});

apps/web/src/content/docs/docs/evaluation/running-evals.mdx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,11 +411,16 @@ The value is a **semver range** using standard npm syntax (e.g., `>=2.12.0`, `^2
411411
| Condition | Interactive (TTY) | Non-interactive (CI) |
412412
|-----------|-------------------|---------------------|
413413
| Version satisfies range | Runs silently | Runs silently |
414-
| Version below range | Warns + prompts to continue | Warns to stderr, continues |
414+
| Version below range | Warns to stderr, continues | Warns to stderr, continues |
415415
| `--strict` flag + mismatch | Warns + exits 1 | Warns + exits 1 |
416416
| No `required_version` set | Runs silently | Runs silently |
417417
| Malformed semver range | Error + exits 1 | Error + exits 1 |
418418

419+
By default, `required_version` is advisory: AgentV never prompts, self-updates,
420+
or blocks a run just because the installed version is outside the range. If an
421+
eval fails or has execution errors while the range is unsatisfied, the summary
422+
includes a note that the version mismatch may be the cause.
423+
419424
Use `--strict` in CI pipelines to enforce version requirements:
420425

421426
```bash

0 commit comments

Comments
 (0)