Test and improve skills
Compare skill instructions against concrete cases and review proposed improvements before adopting them.
Handwritten and agent-written skills need the same evidence. Test whether a skill helps on its intended tasks, stays out of unrelated work, and avoids known failures. Also test combinations you actually deploy; two useful skills can give conflicting instructions.
Run a small review check
This example checks whether a reviewer finds an ignored write error and leaves the clean case alone. It compares a declared skill with no declared skill. It is a wiring example with two fixtures, not proof of review quality.
Install Marbles and a CLI provider plus
zod@4.4.3. Create skills/review/SKILL.md in the automation project:
---
name: review-write-errors
description: Review code that writes files and reports completion.
---
Trace write failures to the caller. Report an ignored-write-error when code
swallows the failure and still reports success. Do not flag a write whose
rejection reaches the caller. Cite the line that supports each finding.
Create .foundry/marbles/_eval/evaluate.ts:
Complete evaluate.ts
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import {
agent,
artifact,
defineSkill,
Output,
type SessionRef,
type SessionReply,
skills,
step,
} from "@foundry.sh/marbles";
import { z } from "zod";
const cases = [
{
code: 'await saveFile("result.txt", text).catch(() => {});\nreturn "saved";',
expected: ["ignored-write-error"],
id: "ignored-failure",
},
{
code: 'await saveFile("result.txt", text);\nreturn "saved";',
expected: [],
id: "propagated-failure",
},
];
const findings = z.object({
findings: z.array(z.object({ evidence: z.string(), rule: z.string() })),
});
const trials = artifact({
name: "Skill evaluation trials",
type: "application/json",
});
const digest = (text: string) =>
createHash("sha256").update(text).digest("hex");
type TrialResult = { id: string; elapsedMs: number } & (
| {
passed: boolean;
output: z.infer<typeof findings>;
session: SessionRef;
usage: SessionReply["usage"];
}
| { passed: false; error: string }
);
export const evaluateSkill = step("skill-eval")
.input(
z.object({
model: z.string().min(1).optional(),
provider: z.enum(["codex", "claude-code"]),
variant: z.enum(["baseline", "candidate"]),
})
)
.config({ timeout: "5m" })
.do(async ({ input, agents, workspaces, report, run, signal }) => {
const manifest =
input.variant === "candidate"
? await readFile(
join(workspaces.current.root, "skills/review/SKILL.md"),
"utf8"
)
: "";
if (input.variant === "candidate" && !manifest.trim()) {
throw new Error("The candidate skill is empty.");
}
const selected = manifest
? skills.none().add(defineSkill({ manifest }))
: skills.none();
const reviewer = agent({
model: input.model,
name: `eval-reviewer-${digest(JSON.stringify([input, manifest]))}`,
prompt:
"Review only the supplied code. Use the supplied skill when applicable. Report ignored-write-error if a failed save is swallowed and success is still returned. Return no findings for code that propagates the failure. Assume saveFile rejects on failure. Do not use tools or edit files.",
provider: input.provider,
skills: selected,
});
const results: TrialResult[] = [];
for (const fixture of cases) {
const started = Date.now();
try {
const session = await agents.session(reviewer);
const reply = await session.generate(fixture.code, {
output: Output.object({ schema: findings }),
});
const output = findings.parse(reply.output);
const actual = output.findings.map((finding) => finding.rule).sort();
const passed =
JSON.stringify(actual) ===
JSON.stringify([...fixture.expected].sort()) &&
output.findings.every((finding) => finding.evidence.trim());
results.push({
elapsedMs: Date.now() - started,
id: fixture.id,
output,
passed,
session: session.ref,
usage: reply.usage,
});
} catch (error) {
if (signal.aborted) {
throw error;
}
results.push({
elapsedMs: Date.now() - started,
error: String(error),
id: fixture.id,
passed: false,
});
}
}
const passed = results.every((result) => result.passed);
const version = await report.artifact(
trials,
{
"cases.json": JSON.stringify(cases, null, 2),
"skill.md": manifest || "No declared skill in this baseline.",
"trial.json": JSON.stringify(
{
input,
passed,
results,
runId: run.id,
skillDigest: manifest ? digest(manifest) : null,
suiteDigest: digest(JSON.stringify(cases)),
},
null,
2
),
},
{
body: results
.map(
(result) => `- ${result.id}: ${result.passed ? "passed" : "failed"}`
)
.join("\n"),
title: `Skill evaluation: ${input.variant} ${passed ? "passed" : "failed"}`,
}
);
return { passed, versionId: version.id };
});
Then create .foundry/marbles/skill-evals.ts:
import "./_eval/evaluate";
Run each variant with the same provider and model. Omitting model uses
that provider’s default:
marbles roll skill-eval --input '{"variant":"baseline","provider":"codex"}'
marbles roll skill-eval --input '{"variant":"candidate","provider":"codex"}'
Each completed run writes a Skill evaluation trials artifact version with the exact
skill text, cases, replies, grades, session references, and reported usage.
The same report.artifact(...) call posts a Feed summary of which cases passed
or failed. Read that summary to spot failures; use the artifact files for the
detailed comparison. Artifacts and the Feed
explains why both are retained.
Compare the returned version IDs; the latest version alone is not the pair.
A completed run can contain failed cases. Read passed and the case results.
A timeout or cancellation can stop the step before that write. Include those
failed runs when comparing results.
The grader checks rule IDs and requires evidence text. A person must still check whether that evidence is valid. The example does not test repository navigation or tool use. CLI configuration and repository instructions remain part of the environment, including in the baseline. Use isolated fixtures and controlled permissions for a stronger comparison. The no-tools instruction is a prompt; enforce restrictions through session controls when the evaluation requires them.
Capture the skill you actually tested
The step reads SKILL.md when the run starts and constructs a defineSkill
from those bytes. The skill resolver
caches content-backed skills by their contents. This avoids reusing a stale
directory-based skill after a file edit.
The example tests a single-file skill. For a skill with scripts or references,
capture those files too and pass them as defineSkill({ manifest, files }).
Hash and retain the whole set. Alternatively, start a fresh process that loads
the pinned directory. Recreating skills.none().add(path) in a running host
does not refresh its cached directory recipe.
Improve a skill from failures
Give an improvement agent a failed case, its transcript, the expected result, and the current skill. Ask it to propose a small change and explain why the change should fix the failure. Keep the patch on a branch or in a candidate directory, separate from the active skill.
Run the candidate against the full regression suite and held-out cases. Test the skill alone, without it, and with the other skills normally selected. Include cases where it should not apply. Inspect false positives and tool use as well as successful outputs.
A person reviews the evidence and adopts a specific version. Preserve the baseline and the reason for promotion or rejection. This is the self-improvement loop: agents propose and test changes, while the owner controls what becomes active. Bound attempts and model calls so an unsuccessful change ends in a handoff instead of endless rewrites.
The inspector can supply these failure cases. Next, monitor the skill file to rerun the check after edits.