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
8 changes: 4 additions & 4 deletions .claude/skills/creating-mcp-agent-evals/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ pnpm run evals:mcp-agent --dataset mcp-server-evals-merge --id '^merge/<family>/
- `--claude-judge` runs the judge on the Agent SDK too (`--judge-model` then takes an Anthropic ID, default `claude-sonnet-5`); with `--subscription --claude-judge` a run needs only `APIFY_TOKEN` + Langfuse keys. Caveat: a Claude judge scoring a Claude agent can be self-lenient — prefer the OpenRouter judge for comparable numbers.
- `<m>` above is any agent model; pick it off this ladder rather than a default in the examples: `claude-opus-5` (calibration) → `claude-sonnet-5` → `claude-haiku-4-5` (the CLI default, and the sensitive probe). A chained shell command's exit code is the LAST run's — read each log's `📊` line, not the chain status.
- Known flakes, retry the single item once before diagnosing: `🔥 Never completed (task threw)` (harness/SDK spawn); in remote/sandboxed environments, the agent reading "MCP servers still connecting" and falling back to built-ins (doesn't reproduce locally).
- Between runs of suites that create named resources: `pnpm run evals:mcp-agent:tasks-fixtures` (adapt per family) deletes leftover `eval-*` resources and reseeds the permanent fixture. Web-target families that create no named state need no fixtures script — say so in the README instead.
- After a run of the schedules family, delete what it created: `pnpm run evals:mcp-agent:schedules-fixtures -- --run-id <id>`, with the id the run's summary printed. CI passes `<github.run_id>-<github.run_attempt>` to the run and to its teardown step.
- Between runs of suites that create named resources: `pnpm run evals:mcp-agent:fixtures` deletes `eval-*` leftovers older than 6 h (with `--run-id`, only that run's) and reseeds the permanent fixtures. Web-target families that create no named state need no fixtures — say so in the README instead.
- After a run of the schedules or tasks family, delete what it created: `pnpm run evals:mcp-agent:fixtures -- --run-id <id>`, with the id the run's summary printed. CI passes `<github.run_id>-<github.run_attempt>` to the run and to its teardown step.

## Dataset item shape (Langfuse)

Expand Down Expand Up @@ -115,9 +115,9 @@ const client = new ApifyClient({ token: process.env.APIFY_TOKEN });

## Fixtures script pattern

One script per stateful family, wired as `evals:mcp-agent:<family>-fixtures` in package.json: create the permanent fixture if missing, **reset its mutable state** every run (an eval agent may have mutated it), and sweep leftovers.
One script for every stateful family, `evals/scripts/fixtures.ts` (`pnpm run evals:mcp-agent:fixtures`): sweep leftovers, then create each permanent fixture if missing and **reset its mutable state** every run (an eval agent may have mutated it). A new stateful family adds its sweep and fixture there.

`evals/scripts/schedules_fixtures.ts` is the current template for the sweep: `--run-id <id>` deletes only names with that run's `-<id>-n<id length>-t` token. Without it, the seed sweep deletes `eval-*` schedules older than `LEFTOVER_MAX_AGE_MS` (6 h). The rule is a pure function in a side-effect-free sibling module (`evals/scripts/schedules_sweep.ts`), so a unit test imports it without the CLI's `dotenv` load running. `evals/scripts/tasks_fixtures.ts` still sweeps by prefix alone.
The sweep: `--run-id <id>` deletes only names with that run's `-<id>-n<id length>-t` token. Without it, the seed sweep deletes `eval-*` schedules and tasks older than `LEFTOVER_MAX_AGE_MS` (6 h). The rule is a pure function in a side-effect-free sibling module (`evals/scripts/sweep.ts`), so a unit test imports it without the CLI's `dotenv` load running.

## Coverage matrix (definition of done for the dataset)

Expand Down
16 changes: 5 additions & 11 deletions .github/workflows/_evaluations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,9 @@ jobs:
- name: Install pnpm and dependencies
uses: apify/actions/pnpm-install@v1.1.2

- name: Seed task fixtures (merge tier only)
- name: Seed fixtures (merge tier only)
if: inputs.tier == 'merge'
run: pnpm run evals:mcp-agent:tasks-fixtures
env:
APIFY_TOKEN: ${{ secrets.APIFY_TOKEN }}

- name: Seed schedule fixtures (merge tier only)
if: inputs.tier == 'merge'
run: pnpm run evals:mcp-agent:schedules-fixtures
run: pnpm run evals:mcp-agent:fixtures
env:
APIFY_TOKEN: ${{ secrets.APIFY_TOKEN }}

Expand Down Expand Up @@ -97,12 +91,12 @@ jobs:

# The schedule cases leave enabled schedules behind, and an enabled schedule keeps firing
# on the eval account until something deletes it. Seeding at the start of the next run is
# too late, so tear them down here — `always()` so a failed or cancelled run still cleans up.
# too late, so tear down here — `always()` so a failed or cancelled run still cleans up.
# Same run id as the run step, so this deletes only what this run created.
- name: Tear down schedule fixtures (merge tier only)
- name: Tear down fixtures (merge tier only)
if: always() && inputs.tier == 'merge'
run: >
pnpm run evals:mcp-agent:schedules-fixtures --
pnpm run evals:mcp-agent:fixtures --
--run-id ${{ github.run_id }}-${{ github.run_attempt }}
env:
APIFY_TOKEN: ${{ secrets.APIFY_TOKEN }}
5 changes: 1 addition & 4 deletions .github/workflows/on_master_evals.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,7 @@ jobs:

evaluations_merge:
name: MCP agent evaluations (merge)
# Serialized: the merge tier's `eval-*` task fixtures are shared per account.
concurrency:
group: eval-merge-master
cancel-in-progress: false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm glad to see this gone :)

# Not serialized: the fixtures script deletes only this run's eval-* names or leftovers older than 6 h.
uses: ./.github/workflows/_evaluations.yaml
with:
tier: merge
Expand Down
38 changes: 18 additions & 20 deletions evals/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,10 @@ whole prompt. An item that names its resources with `{{uniq}}` gets a fresh name
fixed-name create case still can, and fails the gate on it. The console prints a `retrying once` line for
those items.

The tasks family (`merge/tasks/*`, 10 items: 7 proper + 3 with `expectedErrors`) uses fixed `eval-*`
task names, which are unique per account, and the create cases never clean up — so every run
leaves debris that collides on the next one. Run `pnpm run evals:mcp-agent:tasks-fixtures`
before every run: it deletes leftover `eval-*` tasks and seeds the permanent fixture task. It
deletes on whatever account `APIFY_TOKEN` points at and prints that account first; pass
`--dry-run` to see what it would delete before it does. Three cases (`publish-discovery`,
The tasks family (`merge/tasks/*`, 10 items: 7 proper + 3 with `expectedErrors`) ends every task name
it creates with `-{{uniq}}` in the query and in the reference (see "Unique resource names" below), so its
trials and any concurrent run work on separate tasks. Run `pnpm run evals:mcp-agent:fixtures` before
every run (see the schedules family below for what it does). Three cases (`publish-discovery`,
`publish-medium-1`, `chain-hard-1`) publish task examples on `apify/normal-mode-test-actor`, and
publishing needs write access to the Actor, so only those three depend on an account that has it.

Expand Down Expand Up @@ -106,19 +104,20 @@ separate schedules and `--iterations N` is safe. One permanent, disabled fixture
point at it and no case may modify it. It stays disabled on purpose; an enabled one would start a run on
the eval account every night.

Run `pnpm run evals:mcp-agent:tasks-fixtures && pnpm run evals:mcp-agent:schedules-fixtures` before a
run: the second script resets the fixture (disabled, `0 3 * * *` UTC, one task action) and deletes
`eval-*` schedules older than 6 hours, on whatever account `APIFY_TOKEN` points at. Pass `--dry-run` to
see what it would delete.
Run `pnpm run evals:mcp-agent:fixtures` before a run: it deletes `eval-*` schedules and tasks older
than 6 hours, then resets the task fixture and the schedule fixture (disabled, `0 3 * * *` UTC, one
task action), creating them if missing. It deletes on whatever account `APIFY_TOKEN` points at and
prints that account first; pass `--dry-run` to see what it would delete before it does.

**After** a run, delete the schedules that run created, with the command it printed:
**After** a run, delete the schedules and tasks that run created, with the command it printed:

```bash
pnpm run evals:mcp-agent:schedules-fixtures -- --run-id <the run id from the summary>
pnpm run evals:mcp-agent:fixtures -- --run-id <the run id from the summary>
```

`--run-id` deletes only that run's names at any age. Without it, the next seed sweep deletes
`eval-*` schedules older than 6 hours. CI tears down its own run in an `always()` step.
`eval-*` schedules and tasks older than 6 hours. CI tears down its own run's schedules and tasks in
an `always()` step.

The deterministic platform facts the judge used to score here live in
`tests/test_kit/cases/schedules.cases.ts` instead, asserted against the live API. Those cases name their
Expand Down Expand Up @@ -164,7 +163,7 @@ The harness uses `canUseTool` instead of root-incompatible permission bypass fla

### `--iterations` on stateful agent items

`--iterations N` repeats each item in one experiment and reports `pass@k` and `pass^k`. It is safe for tool-call and stateless agent items, and for agent items that name their resources with `{{uniq}}` (below). Stateful cases without the marker, such as `merge/tasks/*`, can collide with their own leftovers.
`--iterations N` repeats each item in one experiment and reports `pass@k` and `pass^k`. It is safe for tool-call and stateless agent items, and for agent items that name their resources with `{{uniq}}` (below). Stateful cases without the marker can collide with their own leftovers.

### Unique resource names: `{{uniq}}` and `--run-id`

Expand All @@ -190,8 +189,8 @@ used. An unmarked item keeps colliding exactly as before, so the fix is per item
teardown command when it finishes:

```
🧹 Run id r3k9f2qa7c — delete this run's schedules:
pnpm run evals:mcp-agent:schedules-fixtures -- --run-id r3k9f2qa7c
🧹 Run id r3k9f2qa7c — delete this run's schedules and tasks:
pnpm run evals:mcp-agent:fixtures -- --run-id r3k9f2qa7c
```

**Exit codes:**
Expand Down Expand Up @@ -317,7 +316,7 @@ experiment-item-run Langfuse SDK, holds the scores

- `config.ts` - Models and the MCP tool-name prefix, shared across responsibilities
- `environment.ts` - Env var sanitization and missing-var reporting
- `run_id.ts` - The `<static>-<runId>-n<runId length>-t<trial>r<attempt>` name grammar and `--run-id` parsing, shared by the runner and the fixtures scripts
- `run_id.ts` - The `<static>-<runId>-n<runId length>-t<trial>r<attempt>` name grammar and `--run-id` parsing, shared by the runner and the fixtures script
- `runner/run.ts` - Main CLI entry, runner defaults
- `runner/experiment.ts` - Experiment task (agent + tool-call dispatch), `EVALUATORS`, run summary, exit gate
- `runner/filters.ts` - Test case filtering by category and id
Expand All @@ -332,9 +331,8 @@ experiment-item-run Langfuse SDK, holds the scores
- `langfuse/observations.ts` - Builds and emits the item's span tree (agent, usage, tool calls)
- `langfuse/tracing.ts` - OpenTelemetry span processor init/shutdown
- `scripts/export_dataset.ts` - Snapshot CLI entry (`pnpm run evals:mcp-agent:export-dataset`)
- `scripts/tasks_fixtures.ts` - Task-suite fixture CLI entry (`pnpm run evals:mcp-agent:tasks-fixtures`)
- `scripts/schedules_fixtures.ts` - Schedule-suite fixture CLI entry (`pnpm run evals:mcp-agent:schedules-fixtures`)
- `scripts/schedules_sweep.ts` - The teardown's delete rule; side-effect free, so a unit test can import it
- `scripts/fixtures.ts` - Task and schedule fixture CLI entry (`pnpm run evals:mcp-agent:fixtures`)
- `scripts/sweep.ts` - The fixtures' delete rule; side-effect free, so a unit test can import it
- `dataset_snapshot_<dataset>.json` - Local export of a dataset, not read at runtime and gitignored

## Configuration
Expand Down
2 changes: 1 addition & 1 deletion evals/run_id.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Name grammar for a run's eval resources: `<static>-<runId>-n<runId length>-t<trial>r<attempt>`.
* The runner builds the suffix and the fixtures scripts match it, so the two cannot drift.
* The runner builds the suffix and the fixtures script matches it, so the two cannot drift.
*/

const RUN_ID_PATTERN = /^[a-z0-9-]+$/;
Expand Down
6 changes: 3 additions & 3 deletions evals/runner/experiment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,13 +334,13 @@ export function formatRunSummary(summary: RunSummary, passThreshold: number, ite
}

/**
* The run's id and the command that deletes the schedules it created. The runner never deletes them
* The run's id and the command that deletes the schedules and tasks it created. The runner never deletes them
* itself: family-specific cleanup does not belong in the runner every family shares.
*/
export function formatTeardownHint(runId: string): string[] {
return [
`🧹 Run id ${runId} — delete this run's schedules:`,
` pnpm run evals:mcp-agent:schedules-fixtures -- --run-id ${runId}`,
`🧹 Run id ${runId} — delete this run's schedules and tasks:`,
` pnpm run evals:mcp-agent:fixtures -- --run-id ${runId}`,
];
}

Expand Down
2 changes: 1 addition & 1 deletion evals/runner/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ async function main() {
console.error(`❌ Run failed: ${error instanceof Error ? error.message : String(error)}`);
exitCode = 1;
} finally {
// In the finally: a run that crashed may already have created schedules under this id.
// In the finally: a run that crashed may already have created schedules or tasks under this id.
// Not for the pr dataset: its tool-call items create nothing.
if (runId && datasetName !== MCP_AGENT_PR_DATASET_NAME) {
for (const line of formatTeardownHint(runId)) console.log(line);
Expand Down
134 changes: 134 additions & 0 deletions evals/scripts/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#!/usr/bin/env node
/* eslint-disable no-console */
/**
* Fixtures for the `merge/tasks/*` and `merge/schedules/*` agent evals.
*
* Deletes the schedules and tasks a run left behind (delete rule in `sweep.ts`), then resets the
* two permanent read-only fixtures, creating them if missing. Schedules go before tasks and the
* task fixture before the schedule fixture, because the fixture schedule runs the fixture task.
*
* Usage: pnpm run evals:mcp-agent:fixtures [--run-id <id>] [--dry-run]
*/

import 'dotenv/config';

import { ApifyClient, type ScheduleCreateOrUpdateData, ScheduleActions } from 'apify-client';

import { findMissingEnvVars, sanitizeProcessEnv } from '../environment.js';
import { parseRunIdArg } from '../run_id.js';
import { FIXTURE_SCHEDULE_NAME, FIXTURE_TASK_NAME, isSweepable, type SweepCandidate } from './sweep.js';

sanitizeProcessEnv();

const FIXTURE_ACTOR = 'apify/normal-mode-test-actor';
const FIXTURE_TASK_INPUT = { firstNumber: 1, secondNumber: 2 };

/** Disabled on purpose: an enabled fixture would start a run on the eval account every night. */
const FIXTURE_SCHEDULE = {
cronExpression: '0 3 * * *',
timezone: 'UTC' as const,
isEnabled: false,
isExclusive: true,
};

/** `--dry-run` prints what the run would change and writes nothing. */
const IS_DRY_RUN = process.argv.includes('--dry-run');
/** Marks every line of a dry run, so its output cannot be read as changes that happened. */
const DRY = IS_DRY_RUN ? '[dry run] ' : '';

type Resource = SweepCandidate & { id: string };

/** Deletes every sweepable item from `list` and returns the fixture if it is there. */
async function sweep(
kind: string,
list: AsyncIterable<Resource>,
fixtureName: string,
runId: string | undefined,
remove: (id: string) => Promise<void>,
): Promise<Resource | undefined> {
// Read every page before deleting anything: offset paging skips entries when the
// collection shrinks underneath it, and a missed leftover fails the next run.
const items = [];
for await (const item of list) items.push(item);

const now = Date.now();
for (const item of items) {
if (!isSweepable(item, runId, now)) continue;
if (!IS_DRY_RUN) await remove(item.id);
console.log(`🗑️ ${DRY}Deleted leftover ${kind} "${item.name}" (${item.id})`);
}
return items.find((item) => item.name === fixtureName);
}

async function main() {
let runId: string | undefined;
try {
runId = parseRunIdArg(process.argv);
} catch (error) {
console.error(`❌ Error: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
const missing = findMissingEnvVars(['APIFY_TOKEN']);
if (missing.length > 0) {
console.error(`❌ Error: missing environment variable(s): ${missing.join(', ')}`);
process.exit(1);
}
const client = new ApifyClient({ token: process.env.APIFY_TOKEN });

// The deletes below hit whatever account APIFY_TOKEN points at, so name it first.
console.log(`👤 ${DRY}Account: ${(await client.user('me').get()).username ?? 'unknown'}`);
if (runId) console.log(`🧹 ${DRY}Tearing down run "${runId}"`);

const schedule = await sweep('schedule', client.schedules().list(), FIXTURE_SCHEDULE_NAME, runId, async (id) =>
client.schedule(id).delete(),
);
let task = await sweep('task', client.tasks().list(), FIXTURE_TASK_NAME, runId, async (id) =>
client.task(id).delete(),
);

// Reset the input: an eval agent may have mutated the fixture (e.g. resolving a
// name collision by updating the existing task).
if (task) {
if (!IS_DRY_RUN) await client.task(task.id).update({ input: FIXTURE_TASK_INPUT });
console.log(`♻️ ${DRY}Reset fixture task "${FIXTURE_TASK_NAME}" (${task.id})`);
} else {
if (!IS_DRY_RUN) {
const actor = await client.actor(FIXTURE_ACTOR).get();
if (!actor) throw new Error(`Fixture Actor "${FIXTURE_ACTOR}" not found`);
task = await client.tasks().create({
actId: actor.id,
name: FIXTURE_TASK_NAME,
title: 'Eval fixture (read-only)',
description: 'Permanent fixture for task-tool MCP agent evals. Do not modify or delete.',
input: FIXTURE_TASK_INPUT,
});
}
console.log(`🌱 ${DRY}Created fixture task "${FIXTURE_TASK_NAME}"${task ? ` (${task.id})` : ''}`);
}

// Annotated, not inferred: an array literal widens `type` to the whole enum, which the
// client's discriminated action union rejects.
const actions: ScheduleCreateOrUpdateData['actions'] = task
? [{ type: ScheduleActions.RunActorTask, actorTaskId: task.id }]
: [];
const scheduleFields = {
...FIXTURE_SCHEDULE,
title: 'Eval fixture (read-only)',
description: 'Permanent fixture for schedule-tool MCP agent evals. Do not delete; do not modify.',
actions,
};
// Reset everything an eval agent may have changed: enabled state, cadence, actions.
if (schedule) {
if (!IS_DRY_RUN) await client.schedule(schedule.id).update(scheduleFields);
console.log(`♻️ ${DRY}Reset fixture schedule "${FIXTURE_SCHEDULE_NAME}" (${schedule.id})`);
} else {
const created = IS_DRY_RUN
? undefined
: await client.schedules().create({ ...scheduleFields, name: FIXTURE_SCHEDULE_NAME });
console.log(`🌱 ${DRY}Created fixture schedule "${FIXTURE_SCHEDULE_NAME}"${created ? ` (${created.id})` : ''}`);
}

console.log(IS_DRY_RUN ? '✅ Dry run complete, nothing changed' : '✅ Fixtures ready');
}

void main();
Loading
Loading