For AI agents: the complete documentation index is available at https://scriptedalchemy.github.io/agent-bundle/llms.txt, the full documentation bundle is available at https://scriptedalchemy.github.io/agent-bundle/llms-full.txt, and this page is available as Markdown at https://scriptedalchemy.github.io/agent-bundle/guide/development/evaluations.md.
Skip to main content
  • English
  • Evaluations

    Tests prove that a route renders and that a generated server honours its wire contract. Evals ask a different question: given a prompt, does the agent actually reach your plugin, and is the outcome the one you claimed? That answer is probabilistic, so the eval runner is built to report honestly rather than to look green.

    Suites are typed modules

    Eval suites are discovered by convention from evals/**/*.eval.ts, and each one default-exports defineEvalSuite:

    import { 
    const defineEvalSuite: (value: EvalSuiteInput) => EvalSuite
    defineEvalSuite
    ,
    const expectOutcome: (options: ExpectOutcomeOptions) => EvalOutcomeAssertion
    expectOutcome
    ,
    const expectSkillActivation: (options: ExpectSkillActivationOptions) => EvalSkillActivationAssertion
    expectSkillActivation
    } from 'agent-bundle/eval';
    export default
    function defineEvalSuite(value: EvalSuiteInput): EvalSuite
    defineEvalSuite
    ({
    EvalSuiteInput.cases: readonly (EvalCase | EvalCaseInput)[]
    cases
    : [{
    assertions: (EvalOutcomeAssertion | EvalSkillActivationAssertion)[]
    assertions
    : [
    function expectSkillActivation(options: ExpectSkillActivationOptions): EvalSkillActivationAssertion
    expectSkillActivation
    ({
    ExpectSkillActivationOptions.skill: string
    skill
    : 'service-readiness' }),
    function expectOutcome(options: ExpectOutcomeOptions): EvalOutcomeAssertion
    expectOutcome
    ({
    ExpectOutcomeOptions.script: string
    script
    : './graders/status-result.ts' }),
    ],
    fixture: string
    fixture
    : './fixtures/status',
    hosts: {
        portable: {
            model: string;
        };
    }
    hosts
    : {
    portable: {
        model: string;
    }
    portable
    : {
    EvalHostBinding.model: string
    model
    : 'deterministic' } },
    id: string
    id
    : 'status-is-healthy',
    invocation: {
        mode: "explicit";
        skill: string;
    }
    invocation
    : {
    EvalInvocation.mode: EvalInvocationMode
    mode
    : 'explicit',
    EvalInvocation.skill?: string | undefined
    skill
    : 'service-readiness' },
    prompt: string
    prompt
    : 'Use service-readiness to verify the checked-in compiler service fixture.',
    trials: number
    trials
    : 1,
    }],
    EvalSuiteInput.name: string
    name
    : 'mcp-app-status',
    });

    A case names its id, prompt, fixture, hosts (with a pinned model per host), invocation mode, automatic, explicit, or none, its assertions, and how many trials to run. Case ids must be unique within a suite. Suite discovery, the runs directory, and the optional semantic grader are configured under the top-level evals config block; see Configuration.

    npx agent-bundle eval --root . --suite mcp-app-status --trials 3
    npx agent-bundle eval compare <baseline-run-id> <candidate-run-id>

    Writing an outcome grader

    expectOutcome({ script }) names a module that default-exports one grader function. The path is resolved relative to the suite file and must not climb out of the suite directory (../ past it is rejected; the check is on the path, so a symlink inside the suite is followed wherever it points). agent-bundle/eval exports its contract: EvalGraderFunction takes an EvalGraderContext and returns an EvalScriptOutcome, synchronously or as a promise. This is examples/skills-starter/evals/graders/release-result.ts verbatim; the suite above names ./graders/status-result.ts, which has the same shape in examples/mcp-app:

    // evals/graders/release-result.ts
    import { 
    function readFile<T extends NodeJS.ArrayBufferView>(path: PathLike | FileHandle, options: ReadFileOptionsWithBuffer<T>): Promise<BufferView<T>> (+3 overloads)

    Asynchronously reads the entire contents of a file.

    If no encoding is specified (using options.encoding), the data is returned as a Buffer object. Otherwise, the data will be a string.

    If options is a string, then it specifies the encoding.

    When the path is a directory, the behavior of fsPromises.readFile() is platform-specific. On macOS, Linux, and Windows, the promise will be rejected with an error. On FreeBSD, a representation of the directory's contents will be returned.

    An example of reading a package.json file located in the same directory of the running code:

    import { readFile } from 'node:fs/promises';
    try {
      const filePath = new URL('./package.json', import.meta.url);
      const contents = await readFile(filePath, { encoding: 'utf8' });
      console.log(contents);
    } catch (err) {
      console.error(err.message);
    }

    It is possible to abort an ongoing readFile using an AbortSignal. If a request is aborted the promise returned is rejected with an AbortError:

    import { readFile } from 'node:fs/promises';
    
    try {
      const controller = new AbortController();
      const { signal } = controller;
      const promise = readFile(fileName, { signal });
    
      // Abort the request before the promise settles.
      controller.abort();
    
      await promise;
    } catch (err) {
      // When a request is aborted - err is an AbortError
      console.error(err);
    }

    Aborting an ongoing request does not abort individual operating system requests but rather the internal buffering fs.readFile performs.

    Any specified FileHandle has to support reading.

    @sincev10 .0.0@parampath filename or FileHandle@returnFulfills with the contents of the file.
    readFile
    } from 'node:fs/promises';
    import {
    function join(...paths: string[]): string

    Join all arguments together and normalize the resulting path.

    @parampaths paths to join.@throws{TypeError} if any of the path segments is not a string.
    join
    } from 'node:path';
    import type {
    type EvalGraderFunction = (context: EvalGraderContext) => EvalScriptOutcome | Promise<EvalScriptOutcome>
    EvalGraderFunction
    } from 'agent-bundle/eval';
    interface ReleaseResult { readonly
    ReleaseResult.blockers?: unknown
    blockers
    ?: unknown;
    readonly
    ReleaseResult.verdict?: string | undefined
    verdict
    ?: string;
    } const
    const grade: EvalGraderFunction
    grade
    :
    type EvalGraderFunction = (context: EvalGraderContext) => EvalScriptOutcome | Promise<EvalScriptOutcome>
    EvalGraderFunction
    = async ({
    fixturePath: string
    fixturePath
    }) => {
    const
    const result: ReleaseResult
    result
    =
    var JSON: JSON

    An intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.

    JSON
    .
    JSON.parse(text: string, reviver?: (this: any, key: string, value: any) => any): any

    Converts a JavaScript Object Notation (JSON) string into an object.

    @paramtext A valid JSON string.@paramreviver A function that transforms the results. This function is called for each member of the object. If a member contains nested objects, the nested objects are transformed before the parent object is.@throws{SyntaxError} If text is not valid JSON.
    parse
    (
    await
    function readFile(path: PathLike | FileHandle, options: ReadFileOptionsWithStringEncoding | BufferEncoding): Promise<string> (+3 overloads)

    Asynchronously reads the entire contents of a file.

    If no encoding is specified (using options.encoding), the data is returned as a Buffer object. Otherwise, the data will be a string.

    If options is a string, then it specifies the encoding.

    When the path is a directory, the behavior of fsPromises.readFile() is platform-specific. On macOS, Linux, and Windows, the promise will be rejected with an error. On FreeBSD, a representation of the directory's contents will be returned.

    An example of reading a package.json file located in the same directory of the running code:

    import { readFile } from 'node:fs/promises';
    try {
      const filePath = new URL('./package.json', import.meta.url);
      const contents = await readFile(filePath, { encoding: 'utf8' });
      console.log(contents);
    } catch (err) {
      console.error(err.message);
    }

    It is possible to abort an ongoing readFile using an AbortSignal. If a request is aborted the promise returned is rejected with an AbortError:

    import { readFile } from 'node:fs/promises';
    
    try {
      const controller = new AbortController();
      const { signal } = controller;
      const promise = readFile(fileName, { signal });
    
      // Abort the request before the promise settles.
      controller.abort();
    
      await promise;
    } catch (err) {
      // When a request is aborted - err is an AbortError
      console.error(err);
    }

    Aborting an ongoing request does not abort individual operating system requests but rather the internal buffering fs.readFile performs.

    Any specified FileHandle has to support reading.

    @sincev10 .0.0@parampath filename or FileHandle@returnFulfills with the contents of the file.
    readFile
    (
    function join(...paths: string[]): string

    Join all arguments together and normalize the resulting path.

    @parampaths paths to join.@throws{TypeError} if any of the path segments is not a string.
    join
    (
    fixturePath: string
    fixturePath
    , 'result.json'), 'utf8'),
    ) as ReleaseResult; return
    const result: ReleaseResult
    result
    .
    ReleaseResult.verdict?: string | undefined
    verdict
    === 'ready'
    &&
    var Array: ArrayConstructor
    Array
    .
    ArrayConstructor.isArray(arg: any): arg is any[]
    isArray
    (
    const result: ReleaseResult
    result
    .
    ReleaseResult.blockers?: unknown
    blockers
    )
    &&
    const result: ReleaseResult
    result
    .
    ReleaseResult.blockers?: any[]
    blockers
    .
    Array<any>.length: number

    Gets or sets the length of the array. This is a number one higher than the highest index in the array.

    length
    === 0
    ? {
    EvalScriptOutcome.detail: string
    detail
    : 'The release artifact is ready with no blockers.',
    EvalScriptOutcome.outcome: EvalAssertionOutcome
    outcome
    : 'pass' }
    : {
    EvalScriptOutcome.detail: string
    detail
    : 'The release artifact is not ready or has unresolved blockers.',
    EvalScriptOutcome.outcome: EvalAssertionOutcome
    outcome
    : 'fail',
    }; }; export default
    const grade: EvalGraderFunction
    grade
    ;

    The context is the trial the grader is judging:

    FieldMeaning
    fixturePathThe trial's own copy of the case fixture, after the agent ran against it. Read the agent's output from here; the checked-in fixture is never touched.
    artifactRootThe prepared artifact the trial ran against, for a grader that checks what shipped rather than what the agent wrote.
    promptThe case prompt the agent received.

    The outcome is { outcome, detail }: outcome is pass, fail, or inconclusive, the same three verdicts as every other assertion, and detail is the sentence shown beside the result in the trial record. A module that does not default-export a function, or returns anything but that shape, is a grader defect, not plugin evidence: the trial is reported EVAL_GRADER_FAILED and its result stays inconclusive. Graders run in the eval process with the host's file system, so read the fixture and the artifact; do not reach into the agent's session.

    Three outcomes, not two

    Every assertion resolves to pass, fail, or inconclusive, and declares the minimum evidence it accepts. An assertion that needs stronger evidence than the harness produced is inconclusive, never silently passed.

    EvidenceMeaning
    observedThe harness saw the thing happen in the host's own output.
    inferredThe harness derived it from surrounding evidence rather than observing it.
    unavailableNo evidence at all. Never a valid declared minimum, because it would accept absence.

    Assertion helpers default their minimumEvidence to the strongest bar their evidence can support: expectExitCode, expectMcpCall, expectNoMcpCall, and expectOutcome default to observed, while expectSkillActivation and expectNoSkillActivation default to inferred because not every host publishes an activation event. Raise a default with minimumEvidence: 'observed' when you want the weaker evidence to read as inconclusive instead of as a pass.

    Because an inconclusive trial produced no evidence, it cannot report success either: agent-bundle eval exits nonzero when a run has any failing or any inconclusive trial.

    Smoke evidence versus a reliability number

    Fewer than three trials is reported as smoke evidence rather than a reliability number. One green trial is a signal that the path exists; it is not a rate.

    Comparison rows are only aligned when every one of these facets matches: case, fixture, semantic grader identity, harness, host CLI version, invocation, and model. The artifact target digest is deliberately not an alignment facet. It is the thing under comparison. Facets a run did not record are labeled unverified rather than assumed aligned, and an unmatched facet makes the row non-comparable instead of producing a delta nobody should trust.

    The optional semantic grader

    A semantic grader is configured with exactly one pinned Claude model:

    import { 
    const defineConfig: (config: AgentBundleConfig | ConfigFactory) => AgentBundleConfig | ConfigFactory
    defineConfig
    } from 'agent-bundle/config';
    export default
    function defineConfig(config: AgentBundleConfig | ConfigFactory): AgentBundleConfig | ConfigFactory
    defineConfig
    ({
    evals: {
        semanticGrader: {
            harness: string;
            model: string;
        };
    }
    evals
    : {
    semanticGrader: {
        harness: string;
        model: string;
    }
    semanticGrader
    : {
    harness: string
    harness
    : 'claude',
    model: string
    model
    : 'claude-sonnet-4-5' },
    },
    AgentBundleConfig.plugin: AgentBundlePluginConfig
    plugin
    : {
    AgentBundlePluginConfig.description?: string | undefined
    description
    : 'Evidence-backed project tools.',
    AgentBundlePluginConfig.name: string
    name
    : 'my-plugin' },
    AgentBundleConfig.targets?: string[] | undefined
    targets
    : ['portable', 'claude'],
    });

    It runs only with agent-bundle eval --harness claude, and only for Claude-pinned cases. After the primary trace is usable and the deterministic graders finish, Agent Bundle makes one server-owned, plugin-free Claude grading call. Its fixed result id is claude-semantic, and its request, raw stream, stderr, and canonical provenance are retained with the trial artifacts. A malformed or failed semantic grader leaves the trial inconclusive rather than becoming plugin evidence, a grader that broke is not a plugin that misbehaved.

    Semantic grading requires a native Claude harness and a signed-in Claude Code session. Deterministic and Codex selections are refused when it is configured.

    Workbench Evals

    Open Advanced → Evals → Runs to admit a selected run, follow live progress, or cancel it through the run lifecycle. Each trial exposes its persisted raw evidence when present, plus recorded CLI, invocation, grader, and usage provenance.

    Advanced → Evals → Compare shows recorded provenance and usage and only includes aligned case, fixture, harness, invocation, host and model, CLI, and grader facets; unmatched facets are labeled non-comparable or unverified. Trial duration is persisted; provider token usage is shown only when the native stream reported it.

    Native harnesses

    The deterministic harness needs nothing installed. The native Claude and Codex harnesses run the real host CLI, and the rules there are strict:

    • This package never accepts, requests, injects, or persists a model-provider API key.
    • Native harnesses use an already installed, signed-in CLI, after provider-key environment variables are removed.
    • A missing, incompatible, or unauthenticated CLI is reported as a harness failure, distinct from a plugin failure.
    • Native smokes are opt-in and are not part of an ordinary test run.
    • Codex exposes no authoritative Skill-activation event, so Codex activation evidence is inferred and is never reported as observed.

    For a manual authenticated local smoke, complete each CLI's normal interactive sign-in first, then verify a supported non-prerelease CLI. Claude Code must be at least 2.1.232; Codex must be at least 0.147.0:

    claude --version
    npx agent-bundle eval --harness claude --trials 1
    
    codex --version
    npx agent-bundle eval --harness codex --trials 1

    Each Codex trial sets a temporary CODEX_HOME and copies only the installed CLI's opaque auth.json into it. Your normal Codex home, configuration, and installed-plugin state are not used as trial state and are left unchanged. The full credential contract is in Security.

    Next

    • Distribution: turning a validated artifact into an installable bundle.
    • Limitations: what evals and harnesses currently do not prove.