PAIPAI

runAgentEval()

Score the latest send: tools called, answer text, optional LLM judge.

Assertions apply to the latest send(). Stub tools so you score the agent, not your CRM.

import { runAgentEval } from "@pai/test-utils";

await runAgentEval({
  agent: assistantAgent,
  identity: {
    identity: { userId: "eval-user", workspaceId: "eval-workspace" },
  },
  run: async (t) => {
    t.stubTools({ unhandled: "fail", tools: { searchDocs: async () => ({ hits: [] }) } });
    await t.send("Where is tenancy documented?");
    t.calledTool("searchDocs").succeeded();
    t.answer.includes("Identity");
  },
});

No thread handle for action.submit. Use a runtime test to approve.

Import

import { runAgentEval } from "@pai/test-utils";

Signature

function runAgentEval(options: {
  agent: AgentDefinition;
  identity?: { identity };
  capabilities?: Record<string, CapabilityBinding>;
  judge?: LanguageModel;
  run(driver: AgentEvalDriver): void | Promise<void>;
}): Promise<{ verdict: "passed"; state; turns }>;

Parameters

options: RunAgentEvalOptions

agent: AgentDefinition

identity?: { identity }

Same shape as resolveIdentity's return.

identity: agent identity

capabilities?: Record<string, CapabilityBinding>

Bindings for capabilities the agent's tools declare, as createTestRuntime() takes them. Required when a tool declares one, such as the sandbox tools, even if you stub that tool. The eval closes its runtime when it ends, which closes these bindings, so pass a fresh set per eval.

judge?: LanguageModel

AI SDK model for t.judge.rubric.

run: (driver: AgentEvalDriver) => void | Promise<void>

Returns

A { verdict: 'passed'; state; turns }.

The run callback receives:

AgentEvalDriver

Usual assertions. Also on the driver: tool, usedNoTools, maxToolCalls, failed, noToolErrors, satisfies, state, current. See Evals.

send: (text, options?) => Promise<ThreadState>

Assertions apply to this turn.

until?: "idle" | "blocked"

"blocked" returns when the run is waiting on a person. There is no action.submit here — use a runtime test to resume.

calledTool: (name) => assertions

didNotCallTool: (name) => void

answer: AnswerAssertions

includes: (text: string) => void

equals: (text: string) => void

matches: (re: RegExp) => void

excludes: (text: string) => void

completed: () => void

waiting: () => void

stubTools: (options) => void

Before the first send.

judge: JudgeAssertions

rubric: (criteria: string) => void

passFail: (criteria: string) => void

check: (name, { schema, prompt, assert }) => void

Examples

Blocked wait

await t.send("Publish the draft", { until: "blocked" });
t.waiting();

No thread handle for action.submit. Continue in a runtime test.

Judge

import { openai } from "@ai-sdk/openai";

await runAgentEval({
  agent: assistantAgent,
  judge: openai("gpt-4.1-mini"),
  run: async (t) => {
    await t.send("Find an observability agency.");
    t.judge.rubric("The answer names a specific agency.");
    t.judge.passFail(
      "The answer does not invent a company that was not in the tool output.",
    );
  },
});

The judge is an AI SDK language model, not a provider id string.

Learn more

On this page