eval/simulate
@alexkroman1/aai-runtime/eval/simulate — a simulated caller, and a
model-graded judge.
simulateCall has a SECOND model play the caller — a persona and a
goal — driving the same say()/send() a scripted case does until it calls
end_call or maxTurns runs out. The result is ordinary EvalTurns plus
the call’s metrics, so every reader on @alexkroman1/aai-runtime/eval takes
it unchanged. judgeCall rules on each criterion over a call, a list
of turns or a transcript, and computes the verdict from those rulings rather
than asking for one. Deterministic readers stay the first instrument; a judge
is for the claims only visible as meaning, and it is a noisy one — run it
under AAI_EVAL_REPEAT and read the spread.
In a describeEval / describeTextEval case, evalSimulation builds
the pair from the case’s own session and mode, live or scripted the way
the rest of the suite is:
import type { AgentDef } from "@alexkroman1/aai";import { evalSimulation } from "@alexkroman1/aai-runtime/eval/simulate";import type { EvalTestContext } from "@alexkroman1/aai-runtime/eval/vitest";
declare const agentDef: AgentDef;
// The body of a `describeEval` case: `session` and `mode` come from its context.export async function forecastCase({ session, mode }: EvalTestContext): Promise<boolean> { const { simulate, judge } = evalSimulation({ agent: agentDef, mode, target: session }); const call = await simulate({ persona: "a commuter", goal: "the forecast" }); return (await judge(call, ["It answered the question."])).pass;}Its own subpath and capability rather than names on /eval and fields on the
case context: the harness a case runs in and the second and third models a
simulation adds move for unrelated reasons, and one epoch for both would
version neither honestly. Runner-free, like /eval.
Exports are enumerated explicitly (no export *) so the public surface is
deliberate.
Functions
Section titled “Functions”evalSimulation()
Section titled “evalSimulation()”evalSimulation(
settings):EvalSimulationContext
Build the simulate/judge pair for one case. Every stub it installs is
released before the call that installed it returns, so a case owes nothing
back.
Parameters
Section titled “Parameters”settings
Section titled “settings”Returns
Section titled “Returns”judgeCall()
Section titled “judgeCall()”judgeCall(
input,options):Promise<CallVerdict>
Have a model rule on criteria over input, and hand back the verdict.
import { llm } from "@alexkroman1/aai/llm";import { judgeCall, type SimulatedCall } from "@alexkroman1/aai-runtime/eval/simulate";
export async function grade(call: SimulatedCall): Promise<void> { const verdict = await judgeCall(call, { criteria: [ "The agent looked the order up before saying whether it shipped.", "The agent never asked for a card number.", ], llm: llm({ provider: "anthropic", model: "claude-sonnet-5" }), }); if (!verdict.pass) throw new Error(verdict.explain());}Parameters
Section titled “Parameters”options
Section titled “options”Returns
Section titled “Returns”Promise<CallVerdict>
Throws
Section titled “Throws”if criteria is empty — a judge with nothing to rule on passes
vacuously, which is the silent green this module exists not to produce.
simulateCall()
Section titled “simulateCall()”simulateCall(
target,options):Promise<SimulatedCall>
Run a simulated call against target and hand back every turn, the way it
ended, and what was measured.
import { agent } from "@alexkroman1/aai";import { llm } from "@alexkroman1/aai/llm";import { openEvalSession } from "@alexkroman1/aai-runtime/eval";import { simulateCall } from "@alexkroman1/aai-runtime/eval/simulate";
export async function hurriedCaller(): Promise<void> { const session = await openEvalSession({ agent: agent({ name: "Order Desk" }) }); try { const call = await simulateCall(session, { caller: { persona: "a polite but hurried customer", goal: "find out whether order W1234 has shipped", }, llm: llm({ provider: "anthropic", model: "claude-haiku-4-5" }), }); if (call.endedBy !== "caller") throw new Error(call.transcript()); console.log(call.metrics.toolCallCounts, call.metrics.latencyMs); } finally { await session.close(); }}The target is used as is and left OPEN — whoever opened it closes it, the same ownership every other door here keeps.
Parameters
Section titled “Parameters”target
Section titled “target”options
Section titled “options”Returns
Section titled “Returns”Promise<SimulatedCall>
Type Aliases
Section titled “Type Aliases”CallVerdict
Section titled “CallVerdict”CallVerdict =
object
The judge’s verdict over a whole conversation.
Methods
Section titled “Methods”explain()
Section titled “explain()”explain():
string
The failed rulings, one per line — what a failure message should print.
Returns
Section titled “Returns”string
Properties
Section titled “Properties”criteria
Section titled “criteria”
readonlycriteria: readonlyCriterionVerdict[]
One ruling per criterion, in the order they were given.
readonlypass:boolean
Every criterion passed.
scripted
Section titled “scripted”
readonlyscripted:boolean
The rulings came from a script (stubJudge), not a model’s reading.
summary
Section titled “summary”
readonlysummary:string
The judge’s overall summary.
CriterionVerdict
Section titled “CriterionVerdict”CriterionVerdict =
object
One criterion’s ruling.
Properties
Section titled “Properties”criterion
Section titled “criterion”
readonlycriterion:string
readonlypass:boolean
reason
Section titled “reason”
readonlyreason:string
The judge’s reason, in a sentence or two.
EvalSimulationContext
Section titled “EvalSimulationContext”EvalSimulationContext =
object
What a case gets for running a simulated caller and grading the result.
Methods
Section titled “Methods”judge()
Section titled “judge()”judge(
input,criteria,options?):Promise<CallVerdict>
Have a model rule on criteria over a simulated call, a list of turns, or
a transcript. See judgeCall.
Parameters
Section titled “Parameters”criteria
Section titled “criteria”readonly string[]
options?
Section titled “options?”context?
Section titled “context?”string
Returns
Section titled “Returns”Promise<CallVerdict>
simulate()
Section titled “simulate()”simulate(
caller,options?):Promise<SimulatedCall>
Run a simulated caller against this case’s session (or text agent) until
it hangs up or maxTurns runs out. See simulateCall.
Parameters
Section titled “Parameters”caller
Section titled “caller”options?
Section titled “options?”maxTurns?
Section titled “maxTurns?”number
Returns
Section titled “Returns”Promise<SimulatedCall>
EvalSimulationOptions
Section titled “EvalSimulationOptions”EvalSimulationOptions =
object
What evalSimulation takes.
Properties
Section titled “Properties”
readonlyagent:AgentDef
The agent under evaluation. Live, the caller and the judge default to its model.
callerLlm?
Section titled “callerLlm?”
readonlyoptionalcallerLlm?:LlmProvider
The model that PLAYS the caller when live. Defaults to the agent’s model.
readonlyoptionalenv?:Record<string,string>
The agent env, for live credentials. Defaults to none.
judgeLlm?
Section titled “judgeLlm?”
readonlyoptionaljudgeLlm?:LlmProvider
The model that JUDGES when live. Defaults to the agent’s model.
readonlyoptionalllm?:LlmProvider
The model the AGENT was evaluated on, when the suite overrode it (its
llm option) — the live default for both of the above.
readonlymode:EvalMode
Which model this run got — the case context’s mode. "stub" scripts the
caller and the judge too; a keyless simulation checks wiring, not behaviour.
providerEnv?
Section titled “providerEnv?”
readonlyoptionalproviderEnv?:ProviderEnv
Provider credentials when live. Defaults to env plus the host’s own.
stubCaller?
Section titled “stubCaller?”
readonlyoptionalstubCaller?:StubScript
The simulated caller’s lines in a keyless run, one per caller turn. End it
with { tool: "end_call", args: { reason } }; absent, the stub caller says
one line and hangs up.
stubJudge?
Section titled “stubJudge?”
readonlyoptionalstubJudge?: readonlyboolean[]
The rulings a keyless judge hands back, one per criterion in order — missing entries pass. Absent, every criterion passes, marked scripted.
target
Section titled “target”
readonlytarget:SimulationTarget
What the simulated caller talks to — the case’s session, or its text agent.
JudgeCallOptions
Section titled “JudgeCallOptions”JudgeCallOptions =
object
What judgeCall takes.
Properties
Section titled “Properties”context?
Section titled “context?”
readonlyoptionalcontext?:string
Extra context the judge should know — the agent’s purpose, a policy.
criteria
Section titled “criteria”
readonlycriteria: readonlystring[]
What must be true of the conversation, one claim each — “the agent confirmed the order number before cancelling”. Phrase each so it can be ruled on from the transcript alone.
readonlyllm:LlmProvider
The JUDGING model. Any @alexkroman1/aai/llm descriptor.
providerEnv?
Section titled “providerEnv?”
readonlyoptionalproviderEnv?:ProviderEnv
Where the judge’s credential is resolved from. Defaults to this machine’s.
JudgeInput
Section titled “JudgeInput”JudgeInput =
SimulatedCall| readonlyEvalTurn[] |string
What a judge may be handed: a simulated call, a list of turns, or a transcript.
SimulateCallOptions
Section titled “SimulateCallOptions”SimulateCallOptions =
object
What simulateCall takes.
Properties
Section titled “Properties”caller
Section titled “caller”
readonlycaller:SimulatedCaller
Who is calling.
readonlyllm:LlmProvider
The model PLAYING the caller. Any @alexkroman1/aai/llm descriptor —
including one from installStubLlm, which is how a keyless run scripts the
caller’s lines ({ tool: "end_call", args: { reason } } ends it).
maxTurns?
Section titled “maxTurns?”
readonlyoptionalmaxTurns?:number
The most caller turns before the harness hangs up for them. Default DEFAULT_MAX_TURNS.
providerEnv?
Section titled “providerEnv?”
readonlyoptionalproviderEnv?:ProviderEnv
Where the caller model’s credential is resolved from. Defaults to this
machine’s environment, the same trust decision openEvalSession makes.
SimulatedCall
Section titled “SimulatedCall”SimulatedCall =
object
A finished simulated call.
Methods
Section titled “Methods”transcript()
Section titled “transcript()”transcript():
string
The call as Agent:/Caller: lines — what a judge or a failure message reads.
Returns
Section titled “Returns”string
Properties
Section titled “Properties”caller
Section titled “caller”
readonlycaller:SimulatedCaller
endedBy
Section titled “endedBy”
readonlyendedBy:"caller"|"max-turns"
"caller" — it called end_call. "max-turns" — the harness hung up
after SimulateCallOptions.maxTurns, which usually means the goal
was never met.
endReason
Section titled “endReason”
readonlyendReason:string|undefined
The reason the caller gave to end_call, when it gave one.
greeting
Section titled “greeting”
readonlygreeting: readonlystring[]
The agent’s opening line(s) before the caller spoke — empty for a text agent.
metrics
Section titled “metrics”
readonlymetrics:SimulationMetrics
readonlyturns: readonlySimulatedTurn[]
SimulatedCaller
Section titled “SimulatedCaller”SimulatedCaller =
object
Who the simulated caller is, and what they called for.
Properties
Section titled “Properties”
readonlygoal:string
What they want out of the call, stated as the CALLER would know it — including the facts they hold (“order W1234”, “a table for four on Friday”). The simulating model is told to reveal them only when asked, as a caller would.
opening?
Section titled “opening?”
readonlyoptionalopening?:string
The caller’s first line. Absent, the model writes one — after the greeting, when the target has one.
persona
Section titled “persona”
readonlypersona:string
Who they are and how they talk — “a hurried commuter who answers in fragments”, “an elderly caller who asks for things to be repeated”.
SimulatedTurn
Section titled “SimulatedTurn”SimulatedTurn =
object
One exchange: what the caller said and the turn it produced.
Properties
Section titled “Properties”caller
Section titled “caller”
readonlycaller:string
The caller’s line.
latencyMs
Section titled “latencyMs”
readonlylatencyMs:number|undefined
Milliseconds from the committed utterance to the first reply text —
undefined for a turn that produced no text.
readonlyturn:EvalTurn
The agent’s turn in reply, exactly as say()/send() returned it.
SimulationMetrics
Section titled “SimulationMetrics”SimulationMetrics =
object
What was measured over the whole call.
Properties
Section titled “Properties”durationMs
Section titled “durationMs”
readonlydurationMs:number
Wall-clock time of the whole simulation, caller model included.
latencyMs
Section titled “latencyMs”
readonlylatencyMs:object
Reply latency over the turns that produced text.
readonlymax:number|undefined
readonlymean:number|undefined
readonlyp50:number|undefined
toolCallCounts
Section titled “toolCallCounts”
readonlytoolCallCounts:Readonly<Record<string,number>>
Tool calls per tool name.
toolCalls
Section titled “toolCalls”
readonlytoolCalls: readonlyEvalToolCall[]
Every tool call the agent made, in order.
readonlyturns:number
Caller turns taken.
SimulationTarget
Section titled “SimulationTarget”SimulationTarget = {
said: readonlystring[];say:Promise<EvalTurn>; } | {said: readonlystring[];send:Promise<EvalTurn>; }
What a simulation drives: an EvalSession (say) or an EvalTextAgent
(send). Structural, so either handle passes as is.
Variables
Section titled “Variables”DEFAULT_MAX_TURNS
Section titled “DEFAULT_MAX_TURNS”
constDEFAULT_MAX_TURNS:12=12
How many caller turns a simulation may take unless told otherwise.
END_CALL_TOOL
Section titled “END_CALL_TOOL”
constEND_CALL_TOOL:"end_call"="end_call"
The name of the caller-side hang-up tool.