Skip to content
This repository was archived by the owner on Oct 16, 2026. It is now read-only.
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
34 changes: 4 additions & 30 deletions apps/cli/src/commands/create/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,53 +6,27 @@ const ASSERTION_TEMPLATES: Record<string, string> = {
default: `#!/usr/bin/env bun
import { defineAssertion } from '@agentv/eval';

/** Extract text from the last message with the given role. */
function getMessageText(messages: Array<{ role: string; content?: unknown }>, role = 'assistant'): string {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role !== role) continue;
if (typeof msg.content === 'string') return msg.content;
if (Array.isArray(msg.content)) {
return msg.content.filter((b: any) => b.type === 'text').map((b: any) => b.text).join('\\n');
}
}
return '';
}

export default defineAssertion(({ output }) => {
// TODO: Implement your assertion logic
const text = getMessageText(output ?? []);
const text = output ?? '';
const pass = text.length > 0;
return {
pass,
reasoning: pass ? 'Output has content' : 'Output is empty',
assertions: [{ text: pass ? 'Output has content' : 'Output is empty', passed: pass }],
};
});
`,
score: `#!/usr/bin/env bun
import { defineAssertion } from '@agentv/eval';

/** Extract text from the last message with the given role. */
function getMessageText(messages: Array<{ role: string; content?: unknown }>, role = 'assistant'): string {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role !== role) continue;
if (typeof msg.content === 'string') return msg.content;
if (Array.isArray(msg.content)) {
return msg.content.filter((b: any) => b.type === 'text').map((b: any) => b.text).join('\\n');
}
}
return '';
}

export default defineAssertion(({ output }) => {
// TODO: Implement your scoring logic (0.0 to 1.0)
const text = getMessageText(output ?? []);
const text = output ?? '';
const score = text.length > 0 ? 1.0 : 0.0;
return {
pass: score >= 0.5,
score,
reasoning: \`Score: \${score}\`,
assertions: [{ text: 'Output has content', passed: score === 1.0 }],
};
});
`,
Expand Down
6 changes: 0 additions & 6 deletions apps/cli/src/commands/eval/commands/assert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,6 @@ export const evalAssertCommand = command({
question: resolvedInput,
criteria: '',
expected_output: [],
reference_answer: '',

input_files: [],
trace,
token_usage: null,
Expand All @@ -93,10 +91,6 @@ export const evalAssertCommand = command({
workspace_path: null,
config: null,
metadata: {},
// Text convenience accessors (new names)
input_text: resolvedInput,
output_text: resolvedOutput,
expected_output_text: '',
},
null,
2,
Expand Down
4 changes: 0 additions & 4 deletions apps/cli/src/commands/pipeline/grade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,6 @@ export async function runCodeGraders(
const executeCodeGrader = async (graderConfig: Record<string, unknown>, task: GraderTask) => {
const { testId, resultsDir, responseText, inputData } = task;
const graderName = graderConfig.name as string;
const inputText = extractInputText(inputData.input);
const messages = [{ role: 'assistant' as const, content: responseText }];
const trace = buildTraceFromMessages({
input: inputData.input,
Expand All @@ -133,9 +132,6 @@ export async function runCodeGraders(
workspace_path: null,
config: graderConfig.config ?? null,
metadata: inputData.metadata ?? {},
input_text: inputText,
output_text: responseText,
expected_output_text: '',
});

try {
Expand Down
40 changes: 40 additions & 0 deletions apps/cli/test/commands/create/assertion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'bun:test';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execa } from 'execa';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, '../../../../..');
const CLI_ENTRY = path.join(projectRoot, 'apps/cli/src/cli.ts');

describe('agentv create assertion', () => {
it('scaffolds canonical assertion output without deprecated reasoning examples', async () => {
const cwd = await mkdtemp(path.join(tmpdir(), 'agentv-create-assertion-'));

try {
const result = await execa(
'bun',
['--no-env-file', CLI_ENTRY, 'create', 'assertion', 'word-count'],
{ cwd, reject: false },
);

expect(result.exitCode).toBe(0);

const content = await readFile(
path.join(cwd, '.agentv', 'assertions', 'word-count.ts'),
'utf8',
);
expect(content).toContain("const text = output ?? '';");
expect(content).toContain(
"assertions: [{ text: pass ? 'Output has content' : 'Output is empty', passed: pass }]",
);
expect(content).not.toContain('reasoning:');
expect(content).not.toContain('getMessageText');
} finally {
await rm(cwd, { recursive: true, force: true });
}
}, 30_000);
});
45 changes: 44 additions & 1 deletion apps/cli/test/commands/eval/assert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,29 @@ console.log(JSON.stringify({ score: 1.0, assertions: [{ text: "always passes", p
path.join(gradersDir, 'check-contains.ts'),
`const input = await Bun.stdin.text();
const payload = JSON.parse(input);
const output = payload.output_text ?? payload.output ?? '';
const output = payload.output ?? '';
const score = typeof output === 'string' && output.includes('hello') ? 1.0 : 0.0;
console.log(JSON.stringify({ score, assertions: [{ text: score ? "contains hello" : "missing hello", passed: !!score }] }));`,
'utf8',
);

await writeFile(
path.join(gradersDir, 'reject-legacy-fields.ts'),
`const input = await Bun.stdin.text();
const payload = JSON.parse(input);
const hasLegacyFields = [
"input_text",
"output_text",
"expected_output_text",
"reference_answer",
].some((key) => Object.prototype.hasOwnProperty.call(payload, key));
console.log(JSON.stringify({
score: hasLegacyFields ? 0 : 1,
assertions: [{ text: hasLegacyFields ? "legacy fields present" : "canonical fields only", passed: !hasLegacyFields }],
}));`,
'utf8',
);

return { baseDir };
}

Expand Down Expand Up @@ -138,4 +155,30 @@ describe('agentv eval assert', () => {
await rm(baseDir, { recursive: true, force: true });
}
}, 30_000);

it('sends only canonical wire fields to code graders', async () => {
const { baseDir } = await createGraderFixture();
try {
const result = await execa(
'bun',
[
'--no-env-file',
CLI_ENTRY,
'eval',
'assert',
'reject-legacy-fields',
'--agent-output',
'hello world',
'--agent-input',
'test',
],
{ cwd: baseDir, reject: false },
);
expect(result.exitCode).toBe(0);
const parsed = JSON.parse(result.stdout);
expect(parsed.score).toBe(1.0);
} finally {
await rm(baseDir, { recursive: true, force: true });
}
}, 30_000);
});
6 changes: 3 additions & 3 deletions apps/web/src/content/docs/docs/evaluation/batch-cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ Each test has its own grader that validates the batch runner output. The grader
**Input (stdin):**
```json
{
"output_text": "{\"id\":\"case-001\",\"decision\":\"CLEAR\",...}",
"output": "{\"id\":\"case-001\",\"decision\":\"CLEAR\",...}",
"expected_output": [{"role": "assistant", "content": {"decision": "CLEAR"}}],
"input": [...]
}
Expand All @@ -170,7 +170,7 @@ Each test has its own grader that validates the batch runner output. The grader
import fs from 'node:fs';

type EvalInput = {
output_text?: string;
output?: string;
expected_output?: Array<{ role: string; content: unknown }>;
};

Expand All @@ -182,7 +182,7 @@ function main() {

let candidateDecision: string | undefined;
try {
const parsed = JSON.parse(input.output_text ?? '');
const parsed = JSON.parse(input.output ?? '');
candidateDecision = parsed.decision;
} catch {
candidateDecision = undefined;
Expand Down
16 changes: 10 additions & 6 deletions apps/web/src/content/docs/docs/evaluation/sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,10 @@ import { defineAssertion } from '@agentv/eval';

export default defineAssertion(({ output }) => {
const wordCount = (output ?? '').trim().split(/\s+/).filter(Boolean).length;
const pass = wordCount >= 3;
return {
pass: wordCount >= 3,
reasoning: `Output has ${wordCount} words`,
pass,
assertions: [{ text: `Output has ${wordCount} words`, passed: pass }],
};
});
```
Expand All @@ -47,10 +48,13 @@ Return a `score` (0–1) instead of `pass` for graded evaluation:
// .agentv/assertions/efficiency.ts
import { defineAssertion } from '@agentv/eval';

export default defineAssertion(({ output, traceSummary }) => ({
pass: (output ?? '').length > 0 && (traceSummary?.eventCount ?? 0) <= 10,
reasoning: 'Checks content exists and is efficient',
}));
export default defineAssertion(({ output, traceSummary }) => {
const pass = (output ?? '').length > 0 && (traceSummary?.eventCount ?? 0) <= 10;
return {
pass,
assertions: [{ text: 'Checks content exists and is efficient', passed: pass }],
};
});
```

If only `pass` is given, score is `1` (pass) or `0` (fail).
Expand Down
11 changes: 5 additions & 6 deletions apps/web/src/content/docs/docs/graders/code-graders.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Raw grader stdin is a process-boundary wire format, so keys are `snake_case`. Ty
| Raw stdin key | SDK field | Meaning |
|---------------|-----------|---------|
| `output` | `output` | Final answer / scored result as a string |
| `answer` | `answer` | Deprecated alias for the same final answer string |
| `answer` | `answer` | Same final answer string, exposed for ergonomic handler code |
| `messages` | `messages` | Transcript messages for transcript-aware graders |
| `expected_output` | `expectedOutput` | Reference answer messages |
| `output_path` | `outputPath` | Temp file containing large final answer JSON, when used |
Expand Down Expand Up @@ -108,11 +108,11 @@ Scripts that write to stderr and exit non-zero surface as execution errors rathe
# validators/check_answer.py
import json, sys
data = json.load(sys.stdin)
output_text = data.get("output") or data.get("answer") or ""
output = data.get("output") or data.get("answer") or ""

assertions = []

if "42" in output_text:
if "42" in output:
assertions.append({"text": "Output contains correct value (42)", "passed": True})
else:
assertions.append({"text": "Output does not contain expected value (42)", "passed": False})
Expand All @@ -133,11 +133,11 @@ print(json.dumps({
import { readFileSync } from "fs";

const data = JSON.parse(readFileSync("/dev/stdin", "utf-8"));
const outputText: string = data.output ?? data.answer ?? "";
const output: string = data.output ?? data.answer ?? "";

const assertions: Array<{ text: string; passed: boolean }> = [];

if (outputText.includes("42")) {
if (output.includes("42")) {
assertions.push({ text: "Output contains correct value (42)", passed: true });
} else {
assertions.push({ text: "Output does not contain expected value (42)", passed: false });
Expand All @@ -148,7 +148,6 @@ const passed = assertions.filter(a => a.passed).length;
console.log(JSON.stringify({
score: passed > 0 ? 1.0 : 0.0,
assertions,
reasoning: `Passed ${passed} check(s)`,
}));
```

Expand Down
24 changes: 15 additions & 9 deletions apps/web/src/content/docs/docs/graders/custom-assertions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,17 +57,18 @@ assertions:

## Pass/Fail Pattern

The simplest pattern returns `pass` (boolean) and `reasoning` (string):
The simplest pattern returns `pass` (boolean) and an optional `assertions` array:

```typescript
// .agentv/assertions/word-count.ts
import { defineAssertion } from '@agentv/eval';

export default defineAssertion(({ output }) => {
const wordCount = (output ?? '').trim().split(/\s+/).filter(Boolean).length;
const pass = wordCount >= 3;
return {
pass: wordCount >= 3,
reasoning: `Output has ${wordCount} words`,
pass,
assertions: [{ text: `Output has ${wordCount} words`, passed: pass }],
};
});
```
Expand Down Expand Up @@ -106,7 +107,6 @@ The handler must return an `AssertionScore` object:
| `pass` | `boolean` | Explicit pass/fail. If omitted, derived from `score` (>= 0.5 = pass). |
| `score` | `number` | Numeric score between 0 and 1. Defaults to 1 if `pass=true`, 0 if `pass=false`. |
| `assertions` | `Array<{ text: string, passed: boolean, evidence?: string }>` | Per-aspect results. Each entry describes one check with its verdict and optional evidence. |
| `reasoning` | `string` | Human-readable explanation. |
| `details` | `Record<string, unknown>` | Optional structured data for domain-specific metrics. |

## Context Available to Assertions
Expand Down Expand Up @@ -140,8 +140,9 @@ Expected output:
```json
{
"score": 1,
"assertions": [],
"reasoning": "Output has 6 words (>= 3 required)"
"assertions": [
{ "text": "Output has 6 words", "passed": true }
]
}
```

Expand Down Expand Up @@ -201,9 +202,14 @@ export default defineAssertion(({ output }) => {
return {
pass,
score: pass ? 1.0 : Math.min(wordCount / minWords, 0.9),
reasoning: pass
? `Output has ${wordCount} words (>= ${minWords} required)`
: `Output has only ${wordCount} words (need >= ${minWords})`,
assertions: [
{
text: pass
? `Output has ${wordCount} words (>= ${minWords} required)`
: `Output has only ${wordCount} words (need >= ${minWords})`,
passed: pass,
},
],
};
});
```
Expand Down
Loading
Loading