eval

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 30, 2026 License: BSD-2-Clause Imports: 30 Imported by: 0

Documentation

Overview

Package eval is a behavioral eval harness for jargo bots. It drives a real bot over RTVI, plays scripted conversation turns, and asserts on the semantic events the bot emits. That is a level above unit tests, which check one processor at a time.

A scenario is a YAML file of turns and the events each turn should produce. The same scenario runs from a Go test (the bot hosted in-process, see Run) or from the command line against a running bot. In text mode each user turn is delivered as RTVI send-text, so the audio processors sit idle; in audio mode (Options.UserTTS) it is synthesized and streamed as microphone audio, so the bot's own VAD, turn detection and STT run for real. Transcribing the bot's audio back, to assert on what was actually heard, builds on the same core later.

Expectation fields

event: <name>            required: the event to match
within_ms: <int>         latency budget, measured from the turn's user input
text_contains: <str>     substring check on the event's text, case-sensitive
eval: <str>              criterion an LLM judge checks the bot's reply against
                         (llm_response and tts_response, which carry its text)
name: <str>              for function_call: the tool the call must be to
args: <mapping>          for function_call: an argument subset the call must carry
calls: <list>            for function_call: several calls, in any order
absent: true             assert the event does NOT arrive

Turn fields

user: <str>              what the user says, sent as text or synthesized
dtmf: <str>              keypad keys the user presses; quote it, # is a comment
expect: <list>           the events to match, in order
send_after: <mapping>    when to send this turn's input (see below)

user and dtmf are mutually exclusive, and both are optional: a turn with neither only waits and asserts, which is what a bot-first scenario needs to check an opening greeting. expect is optional too, for a turn that only sends.

Scenario fields

name: <str>              required: the scenario's name
turns: <list>            the turns, played in order
context: <list>          messages the bot's context starts from

Any value can come from a separate file with !include, resolved against the scenario file's directory, so scenarios can share a block they all need:

turns: !include shared_turns.yaml

Asserting on the bot's reply

Two events carry the bot's own words, and they sit at different points in the pipeline:

llm_response   the text the model produced (bot-llm-text)
tts_response   the text the TTS reports speaking (bot-tts-text)

llm_response is available in both modes. tts_response is audio mode only: a text-mode turn asks for no spoken response, so no TTS runs and no segment is produced. Assert on it when what matters is that the reply reached synthesis rather than that the model wrote it, which is the difference between a turn that answered and a turn that was heard.

A turn often answers in more than one response: an interim filler ("Let me check on that.") and then the answer. So a content check on either of them aggregates. It accumulates the text of successive segments and re-checks on each, until the check passes, the judge rejects, or within_ms expires. A missing substring is not a failure on its own, because more text may follow, which is why an assertion on text the bot never produces waits out its whole budget. Set within_ms on one to keep that short.

A judge grades the conversation rather than one reply on its own: it is given each user turn and each segment of the bot's reply, so a terse answer is read against the question it answers. It may also answer "continue", meaning the reply so far is only filler and the criterion should be judged again once more arrives.

A function_call expectation holds the set of calls the turn should make. They are matched by name in any order and the expectation passes only when every one is found. Write a single call with the name/args shorthand:

expect:
  - event: function_call
    name: get_weather
    args: {city: Paris}

and several under calls:, where an entry is a bare tool name or a mapping:

expect:
  - event: function_call
    calls:
      - get_weather
      - {name: get_restaurants, args: {city: Paris}}

args is a subset check: every argument listed must be present with that value, and any further argument the model passed is ignored. A bare function_call, with neither name nor calls, matches whatever the bot calls. Asserting on a call's name or its arguments requires the bot to report them, which the harness arranges for itself (see Handler).

absent: true inverts an expectation. It passes only when no event of that type arrives before the within_ms budget expires, and fails as soon as one does, which is how "must not answer twice" is written:

expect:
  - event: llm_response
    eval: "answers the question"
  - event: llm_response
    absent: true
    within_ms: 5000

It matches on event type alone, so no content or call check may accompany it, and it waits out its whole budget: set within_ms explicitly to keep the quiet window short.

The same two-expectation shape is how "this tool was called, and not again" is written. The first expectation claims the call, and the absent one behind it holds a quiet window open that a repeat trips:

expect:
  - event: function_call
    name: dispatch_alert
  - event: function_call
    absent: true
    within_ms: 3000

That is the assertion to reach for against a provider that re-requests a call it already made, where running the tool twice is the expensive fault. Because absent matches on event type, the window forbids any further call, not only another dispatch_alert, so put it on a turn whose tool calls are all listed above it.

Scheduling a turn

A turn's input goes out as soon as the previous turn finishes, unless the turn carries send_after. That waits for an event to have been seen, then waits delay_ms longer, which is how an interruption is written:

turns:
  - user: "tell me about Paris"
    expect:
      - event: llm_started
  - user: "actually, tell me about Tokyo"
    send_after: {event: llm_started, delay_ms: 500}
    expect:
      - event: bot_interrupted

An event seen earlier in the run anchors the delay at that earlier sighting, so the turn may fire at once. The wait for the event is bounded at 30s, and a turn whose schedule never fires reports the schedule rather than any of its expectations.

event is optional. On its own, delay_ms is a pure time delay measured from the previous turn's send, for pacing turns where there is no event to anchor on.

Diagnosing a run

A Result carries more than its failures: how long the run took, every event the bot emitted whether or not the scenario asserted on it, and a timestamped trace of what the harness itself decided. Together they are what makes a scenario that failed once and passed the next time readable. Options.OnProgress reports the same as it happens, for a caller watching a long run.

Index

Constants

View Source
const (
	// VerdictYes means the reply satisfies the criterion.
	VerdictYes = "yes"
	// VerdictNo means the reply gives a substantive answer that fails it.
	VerdictNo = "no"
	// VerdictContinue means the reply so far is only an interim or filler
	// utterance, and the criterion should be judged again once more text arrives.
	VerdictContinue = "continue"
)

The verdicts a judge can return.

View Source
const (
	// EventUserStartedSpeaking fires when the bot's VAD detects the user's speech
	// beginning (audio mode only).
	EventUserStartedSpeaking = "user_started_speaking"
	// EventUserStoppedSpeaking fires when the user's turn ends (audio mode only).
	EventUserStoppedSpeaking = "user_stopped_speaking"
	// EventUserTranscription carries the bot's STT transcription of the user's
	// speech (audio mode only).
	EventUserTranscription = "user_transcription"
	// EventLLMStarted fires when the bot begins generating a response.
	EventLLMStarted = "llm_started"
	// EventLLMResponse carries the bot's LLM text, joined across the response.
	EventLLMResponse = "llm_response"
	// EventTTSResponse carries the text the bot's TTS reports speaking, one
	// segment as each arrives (audio mode only: a text-mode turn asks for no
	// spoken response, so no TTS runs and no segment is ever produced).
	EventTTSResponse = "tts_response"
	// EventFunctionCall fires when the bot invokes a tool.
	EventFunctionCall = "function_call"
	// EventBotInterrupted fires when the bot's in-flight output is cut off, by a
	// barge-in or by a turn sent with send_after. It is the event a barge-in
	// scenario asserts on.
	EventBotInterrupted = "bot_interrupted"
	// EventVADUserStartedSpeaking is the raw VAD signal, ungated by turn
	// detection (audio mode only). Useful as a timing anchor when a turn strategy
	// gates or defers the turn-level user_stopped_speaking.
	EventVADUserStartedSpeaking = "vad_user_started_speaking"
	// EventVADUserStoppedSpeaking is the raw VAD signal for the end of speech
	// (audio mode only).
	EventVADUserStoppedSpeaking = "vad_user_stopped_speaking"
)

Event names a scenario can assert on. These are the friendly names scenarios use; the harness maps the bot's RTVI server messages onto them.

View Source
const (
	// StatusTurn heads a turn, carrying its input as the event.
	StatusTurn = "turn"
	// StatusMatched means the expectation was met.
	StatusMatched = "matched"
	// StatusFailed means the event arrived but did not satisfy the expectation.
	StatusFailed = "failed"
	// StatusTimeout means nothing of the expectation's kind arrived at all
	// before its budget expired.
	StatusTimeout = "timeout"
)

The Progress statuses.

Variables

This section is empty.

Functions

func Handler

func Handler(buildBot Bot) http.Handler

Handler serves buildBot over RTVI on a plain WebSocket: it accepts one client per connection, wires the transport into the bot's pipeline, and runs it until the client disconnects. Mount it in a bot's own HTTP server to expose an eval endpoint that `jargo eval run`, or any RTVI WebSocket client, can drive. The in-process Host uses it too.

The endpoint speaks RTVI through the eval serializer, which additionally understands the harness's own control messages. Serving the bot's production RTVI endpoint to the harness instead works, but a scenario asserting on tool arguments will not see them: raising the report level is deliberately something only this serializer allows.

func Run

func Run(t *testing.T, path string, buildBot Bot)

Run plays the scenario at path against buildBot, hosted in-process over a loopback WebSocket, and reports every unmet expectation through t. Text mode: each user turn is injected as RTVI send-text, so audio processors sit idle. For an LLM judge or audio mode, use RunWith.

func RunWith

func RunWith(t *testing.T, path string, buildBot Bot, opts Options)

RunWith is Run with explicit Options (a judge, audio mode, or both).

func RunWithJudge

func RunWithJudge(t *testing.T, path string, buildBot Bot, judge Judge)

RunWithJudge is Run with an LLM judge for the scenario's `judge:` assertions (see NewLLMJudge). Pass nil when no scenario uses `judge:`.

Types

type Bot

type Bot func(in, out processor.Processor) *pipeline.Worker

Bot builds the bot's pipeline task around the harness-provided transport endpoints. The pipeline must include an rtvi.Processor so the harness can drive it (send-text) and observe its events. The builder is transport- agnostic: the harness supplies a WebSocket transport's input and output.

type Event

type Event struct {
	// Kind is one of the Event* name constants.
	Kind string
	// Text is the reply text on an llm_response, the spoken text on a
	// tts_response, or the transcript on a user_transcription. Empty for events
	// that carry no text.
	Text string
	// Function is the tool name on a function_call.
	Function string
	// Args are the arguments the model produced for a function_call.
	Args map[string]any
}

Event is a friendly, translated view of an RTVI server message: the level a scenario asserts on. A run reports every one it saw, so a scenario that failed can be read against what the bot actually did.

func (Event) String

func (e Event) String() string

String renders an Event as a short label: the call signature for a tool call, the text for anything that carries some, the name alone otherwise.

type Expectation

type Expectation struct {
	// Event is the friendly event name to match (see the Event constants).
	Event string `yaml:"event"`
	// TextContains, when set, requires the event's text to contain this
	// substring, case-sensitively. Applies to llm_response and tts_response.
	TextContains string `yaml:"text_contains,omitempty"`
	// Eval, when set, is a natural-language criterion an LLM judge checks the
	// bot's reply against. Applies to llm_response and tts_response, the two
	// events carrying text the bot itself produced.
	Eval string `yaml:"eval,omitempty"`
	// Name is the single-call shorthand for Calls: the tool name a function_call
	// event must match.
	Name string `yaml:"name,omitempty"`
	// Args is the single-call shorthand for Calls: the argument subset the
	// matched call must carry.
	Args map[string]any `yaml:"args,omitempty"`
	// Calls is the set of tool calls the turn should make, for a function_call
	// event. They are matched by name in any order and the expectation passes
	// only when all of them are found. Built from `calls:`, or from the
	// `name:`/`args:` shorthand, by normalizeCalls.
	Calls []FunctionCall `yaml:"calls,omitempty"`
	// Absent inverts the expectation: it passes only when no event of this type
	// arrives before the WithinMS budget expires, and fails as soon as one does.
	// It matches on event type only, so no content check may accompany it.
	Absent bool `yaml:"absent,omitempty"`
	// WithinMS is the latency budget for the event, measured from the user input;
	// zero uses the harness default.
	WithinMS int `yaml:"within_ms,omitempty"`
	// contains filtered or unexported fields
}

Expectation is one assertion about an event the bot emits.

func (*Expectation) UnmarshalYAML

func (e *Expectation) UnmarshalYAML(node *yaml.Node) error

UnmarshalYAML decodes an expectation, recording whether `calls:` was written out. The distinction matters: a missing `calls:` falls back to the `name:`/`args:` shorthand, whereas an empty one is a mistake.

type Failure

type Failure struct {
	// Turn is the 1-based turn number.
	Turn int
	// Expectation is the 1-based expectation index within the turn.
	Expectation int
	// Event is the event name that was expected.
	Event string
	// Reason explains what went wrong.
	Reason string
}

Failure is one unmet expectation in a scenario run.

func (Failure) String

func (f Failure) String() string

String renders a failure as a single line.

type FunctionCall

type FunctionCall struct {
	// Name is the tool name to match. An empty name matches any call, which is
	// what a bare function_call expectation asserts.
	Name string `yaml:"name,omitempty"`
	// Args, when set, is a subset check on the call's arguments: every listed
	// key must be present with the listed value, and any further argument the
	// model passed is ignored.
	Args map[string]any `yaml:"args,omitempty"`
}

FunctionCall is one expected tool call within a function_call expectation.

func (*FunctionCall) UnmarshalYAML

func (c *FunctionCall) UnmarshalYAML(node *yaml.Node) error

UnmarshalYAML accepts either a bare tool name or a {name, args} mapping, so a calls: list can mix the two.

type Judge

type Judge interface {
	// AddUserMessage records a user turn in the conversation.
	AddUserMessage(text string)
	// AddAssistantMessage appends a segment of the bot's current reply.
	AddAssistantMessage(text string)
	// Evaluate judges the conversation so far against criterion. A judge that
	// cannot answer reports VerdictNo with the reason, rather than failing: an
	// unavailable judge is a failed assertion, not a broken run.
	Evaluate(ctx context.Context, criterion string) JudgeVerdict
}

Judge decides whether the bot's most recent reply satisfies a natural-language criterion, which is what a scenario's `judge:` assertion asks. It is fed the conversation as the scenario plays: the harness records each user turn and each segment of the bot's reply, and Evaluate judges the most recent reply in that context. That is what lets a terse or ambiguous reply be resolved, one that would not make sense on its own.

A judge is per-scenario, because the conversation it holds is. The harness treats it as optional, so a scenario with no `judge:` assertion needs none.

type JudgeVerdict

type JudgeVerdict struct {
	// Verdict is VerdictYes, VerdictNo or VerdictContinue.
	Verdict string
	// Reason is a one-sentence justification.
	Reason string
	// RawResponse is the judge model's raw text, for diagnostics.
	RawResponse string
}

JudgeVerdict is the outcome of a single judge call.

func (JudgeVerdict) Passed

func (v JudgeVerdict) Passed() bool

Passed reports whether the verdict is a definite yes.

type LLMJudge

type LLMJudge struct {
	// contains filtered or unexported fields
}

LLMJudge evaluates criteria with an LLM. It runs each judgement as a one-shot generation off to the side of the pipeline (like the summarizer), so give it its own service instance, ideally a small, fast model. Verdicts are cached by (criterion, conversation) so re-runs are stable and a repeated assertion over an unchanged conversation pays only one round-trip.

func NewLLMJudge

func NewLLMJudge(inf llm.Inferencer) *LLMJudge

NewLLMJudge builds a judge backed by inf, an LLM service that can answer a conversation once, e.g. eval.NewLLMJudge(chat.NewLLM(chat.LLMConfig{APIKey: key})).

func (*LLMJudge) AddAssistantMessage

func (j *LLMJudge) AddAssistantMessage(text string)

AddAssistantMessage appends a streamed segment of the bot's current reply. The reply may arrive in several segments, and each is added as its own message, so the accumulated conversation is exactly what the judge sees: there is no separate commit step.

func (*LLMJudge) AddUserMessage

func (j *LLMJudge) AddUserMessage(text string)

AddUserMessage records a user turn, so a later reply is judged in context (a terse "that's four" answering "what is two plus two?", say).

func (*LLMJudge) Evaluate

func (j *LLMJudge) Evaluate(ctx context.Context, criterion string) JudgeVerdict

Evaluate judges whether the bot's most recent reply satisfies criterion. The judge's own answer is never written back into the conversation.

type Manifest

type Manifest struct {
	// Concurrency is how many scenarios run at once; zero uses a default.
	Concurrency int `yaml:"concurrency"`
	// Suite is the list of bots and their scenarios.
	Suite []SuiteEntry `yaml:"suite"`
	// contains filtered or unexported fields
}

Manifest lists the bots to test and the scenarios to run against each. Scenario paths are resolved relative to the manifest file, so a manifest is portable.

func LoadManifest

func LoadManifest(path string) (*Manifest, error)

LoadManifest reads and validates a suite manifest.

type Options

type Options struct {
	// Judge grades `judge:` assertions; nil is fine when no scenario uses one.
	// It holds the conversation it grades against, so give each scenario its own
	// rather than reusing one across scenarios.
	Judge Judge
	// UserTTS selects audio mode. When set, each user turn is synthesized with
	// this TTS service and streamed to the bot as microphone audio (so the bot's
	// real VAD, turn detection and STT run), instead of the text-mode send-text.
	// Any jargo TTS service works, e.g. cartesia.NewTTS(...).
	UserTTS *tts.Base
	// OnProgress, when set, is called as each turn and expectation resolves, for
	// a caller reporting a long run as it happens rather than at the end. It runs
	// on the harness's own goroutine, so keep it quick.
	OnProgress func(Progress)
}

Options configures a scenario run.

type Progress

type Progress struct {
	// Turn is the 1-based turn number.
	Turn int
	// Expectation is the 1-based expectation index, or 0 for a turn-level
	// record: the turn's header, or a schedule that never fired.
	Expectation int
	// Event is the expectation's event name, or the turn's input for a header.
	Event string
	// Status is "turn", "matched", "failed" or "timeout".
	Status string
	// Detail is the failure reason, or what was matched.
	Detail string
}

Progress reports how one turn or expectation resolved, as it happens.

type Result

type Result struct {
	// Scenario is the scenario's name.
	Scenario string
	// Failures lists every unmet expectation; empty means the scenario passed.
	Failures []Failure
	// Duration is how long the run took.
	Duration time.Duration
	// Events is every event the bot emitted, in order, whether or not a scenario
	// asserted on it. It is what a failure is read against: what the bot actually
	// did, rather than only what it was expected to do.
	Events []Event
	// DebugLog is a timestamped trace of the harness's own decisions: events as
	// they arrived, what each expectation waited for, what the judge said. It is
	// what makes a run that failed once and passed the next time diagnosable.
	DebugLog []string
}

Result is the outcome of running one scenario.

func Host

func Host(ctx context.Context, scenario *Scenario, buildBot Bot, opts Options) (Result, error)

Host stands up buildBot on an in-process loopback WebSocket and plays scenario against it, returning the result. Run wraps this for the go-test path; callers that want the Result directly (custom reporting, benchmarks) can use it.

func RunURL

func RunURL(ctx context.Context, scenario *Scenario, botURL string, judge Judge) (Result, error)

RunURL plays scenario against a bot already listening at botURL (a ws:// or wss:// RTVI endpoint). It backs the command-line runner.

func (Result) Passed

func (r Result) Passed() bool

Passed reports whether every expectation was met.

func (Result) String

func (r Result) String() string

String renders a human-readable summary of the run.

type Scenario

type Scenario struct {
	// Name identifies the scenario in reports; required.
	Name string `yaml:"name"`
	// Turns are played in order.
	Turns []Turn `yaml:"turns"`
	// Context is the conversation the bot's context should start from. When set,
	// the harness sends it once the bot is ready, replacing whatever context the
	// bot built for itself. Leave it out to test the bot's own opening state.
	Context []frames.Message `yaml:"context,omitempty"`
	// contains filtered or unexported fields
}

Scenario is a scripted conversation and the events it should produce.

func Load

func Load(path string) (*Scenario, error)

Load reads and validates a scenario YAML file.

func (*Scenario) UnmarshalYAML

func (s *Scenario) UnmarshalYAML(node *yaml.Node) error

UnmarshalYAML decodes a scenario, recording whether `turns:` was written out.

type SendAfter

type SendAfter struct {
	// Event is the event to schedule from, or empty for a pure delay.
	Event string `yaml:"event,omitempty"`
	// DelayMS is how long to wait after the event was seen, or, with no event,
	// after the previous turn's send.
	DelayMS int `yaml:"delay_ms,omitempty"`
}

SendAfter schedules when a turn's input is sent. The harness waits for Event to have been seen, either earlier in the run or arriving now, then waits DelayMS longer before sending. That is how a barge-in is written: `send_after: {event: llm_started, delay_ms: 500}` interrupts 500ms after the bot started responding.

Event is optional. On its own, DelayMS is a pure time delay measured from the previous turn's send, with no event to anchor on.

type SuiteEntry

type SuiteEntry struct {
	// BotURL is the bot's RTVI WebSocket endpoint (ws:// or wss://).
	BotURL string `yaml:"bot_url"`
	// Scenarios are scenario file paths, resolved relative to the manifest.
	Scenarios []string `yaml:"scenarios"`
}

SuiteEntry is one bot and the scenarios to play against it.

type SuiteResult

type SuiteResult struct {
	// BotURL is the bot the scenario ran against.
	BotURL string
	// Scenario is the scenario file path.
	Scenario string
	// Result is the scenario result; zero-valued when Err is set.
	Result Result
	// Err is non-nil when the scenario could not be loaded or run.
	Err error
}

SuiteResult is the outcome of running one scenario against one bot.

func RunSuite

func RunSuite(ctx context.Context, m *Manifest, newJudge func() Judge) []SuiteResult

RunSuite runs every scenario in the manifest against its bot, up to Concurrency at once, and returns one result per scenario in manifest order.

newJudge builds the judge for one scenario, and is called once per scenario rather than once for the suite: a judge holds the conversation it grades against, so scenarios running at the same time cannot share one. It may return nil, and may itself be nil, when no scenario uses `judge:`.

func (SuiteResult) Passed

func (r SuiteResult) Passed() bool

Passed reports whether the scenario ran and every expectation was met.

type Turn

type Turn struct {
	// User is the text the user "says" this turn (sent as RTVI send-text).
	User string `yaml:"user,omitempty"`
	// DTMF is a keypad sequence the user presses, one frame per key. Mutually
	// exclusive with User. Quote it in YAML: an unquoted # starts a comment.
	DTMF string `yaml:"dtmf,omitempty"`
	// Expect lists the events to match, in order, after the turn's input.
	// Optional: a turn may just send input, with the assertion on a later turn.
	Expect []Expectation `yaml:"expect,omitempty"`
	// SendAfter, when set, schedules when the turn's input is sent rather than
	// sending it as soon as the previous turn finishes.
	SendAfter *SendAfter `yaml:"send_after,omitempty"`
	// contains filtered or unexported fields
}

Turn is one step of a scenario: input for the bot, expectations about what it does, or both.

A turn drives the bot one of two ways: the harness sends a user utterance, or it sends a sequence of keypad presses. The two are mutually exclusive, and both are optional. A turn with neither only waits and asserts, which is what a bot-first scenario needs: nothing to say, only an opening greeting to check.

func (*Turn) UnmarshalYAML

func (t *Turn) UnmarshalYAML(node *yaml.Node) error

UnmarshalYAML decodes a turn, reading its keypad sequence from the text as written.

dtmf is not decoded like the other fields because YAML reinterprets an unquoted number before any of this sees it: `dtmf: 012` would arrive as 10 and `dtmf: 0x10` as 16, silently rewriting the keys the scenario typed. Taking the raw scalar keeps every digit, so `dtmf: 123` still works unquoted while a leading zero or a hex-looking token reaches the keypad check with its characters intact, and is rejected there rather than misread here.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL