sonar-eval
Evaluation harness SDK for agentic AI chatbots, built on
sonar-tracing.
The harness drives your bot through simulated conversations, optionally injects
faults into its tool calls, and grades the transcript it pulls back from
sonar-ingest. You write one small adapter for your bot; the trial loop,
fault injection, assertions, LLM grading, and pass@k / pass^k rollups are generic.
pip install sonar-eval
The mental model
conversation_id is the spine of the whole design. For each trial the harness:
- mints a
conversation_idviasonar_tracing.new_conversation()— this is the trial id, the fault-injection scope key, and the join key all at once; - hands it to your
BotAdapter, which makes the bot trace under that id (in a real service, by POSTing it to an app-internal endpoint the app threads intotracing.identity(conversation_id=...)); - lets a user simulator (an LLM playing the human) drive turns until it emits
the termination signal or hits
max_turns. The simulator sees only the messages delivered back to it — never the bot's tools or spans; - pulls the whole conversation back out of ingest by that id
(
GET /v1/conversations/{id}) and reconstructs the tool-call transcript; - runs programmatic assertions (tier 1) and an optional LLM rubric grader
(tier 2), then repeats
ktimes and rolls the results up per assertion.
The grader scores what actually happened — the span tree in Postgres — not
whatever the adapter chose to return. That is why send() returns only delivered
messages and the transcript comes from ingest.
What you provide
Two things are project-specific: an LLMClient (the harness never imports a
vendor SDK) and a BotAdapter for your bot.
from sonar_eval import BaseBotAdapter, Delivered, LLMConfig, Message, Session, TrialContext
class MyLLM:
"""Wrap whatever model client you use. Return the completion text."""
async def complete(self, config: LLMConfig, system: str, messages: list[Message]) -> str:
... # call your provider with `system` + `messages`, return the text
class MyBotAdapter(BaseBotAdapter):
name = "support-bot"
async def setup_trial(self, ctx: TrialContext) -> Session:
# Isolate/seed state for this trial and make the bot trace under
# ctx.conversation_id. For a real service: POST ctx.conversation_id
# (and, for real fidelity, serialize_faults(ctx.faults)) to your bot's
# internal endpoint so it wraps its turns in identity(conversation_id=...).
handle = await open_session(ctx.conversation_id, seed=ctx.seed.data)
return Session(conversation_id=ctx.conversation_id, handle=handle)
async def send(self, session: Session, user_msg: str, media=None) -> Delivered:
reply = await session.handle.send(user_msg) # drive one user turn
return Delivered(messages=(reply,)) # only what the user sees
async def teardown_trial(self, session: Session) -> None:
await session.handle.close() # safe after a failed setup
Running a scenario
import asyncio
from sonar_eval import (
Assertion, Fidelity, LLMConfig, Orchestrator, ScenarioConfig, Severity,
TaskConfig, no_tool_errors, tool_was_called,
)
task = TaskConfig(
ingest_base_url="http://localhost:4319",
tenant="acme",
user_llm=LLMConfig(model="claude-sonnet-5"),
grader_llm=LLMConfig(model="claude-opus-5"),
# project defaults to "eval-fullcontent" so the grader sees unredacted tool I/O
)
scenario = ScenarioConfig(
name="order-a-coffee",
user_instructions="You want a large oat-milk latte. Order it, then stop.",
k=5,
assertions=[
# capability: passes if it held on ANY of the k trials (pass@k)
Assertion("looked-up-menu", Severity.CAPABILITY, tool_was_called("menu_lookup")),
# safety-critical: passes only if it held on EVERY trial (pass^k == 1.0)
Assertion("no-tool-errors", Severity.SAFETY_CRITICAL, no_tool_errors()),
],
grader_rubric="Did the bot confirm the exact order before charging? Score 0..1.",
)
orch = Orchestrator(task, MyBotAdapter(), MyLLM())
report = asyncio.run(orch.run_scenario(scenario))
print(report.passed) # True only if every assertion passed
for a in report.assertions:
print(a.name, a.severity, a.pass_at_k, a.pass_hat_k)
print(report.mean_grader_score) # None unless a grader ran
Built-in checks: tool_was_called(name), no_tool_errors(), max_turns(n),
max_total_tokens(n). A check is any Callable[[Transcript, ConversationLog], bool],
so you can write your own over the pulled transcript.
Grading is opt-in: pass a grader_factory to the orchestrator and set
grader_rubric on the scenario.
from sonar_eval import Grader
orch = Orchestrator(
task, MyBotAdapter(), MyLLM(),
grader_factory=lambda s: Grader(MyLLM(), s.grader_llm, s.grader_rubric),
store=None, # defaults to NullStore; use JSONFileStore("results/") to persist
)
Fault injection
Faults are declared once and applied by whichever mechanism the scenario's fidelity selects. Rules are declarative so trials stay reproducible (pass^k needs determinism).
from sonar_eval import Action, FaultRule, FaultSpec
faults = FaultSpec(rules=(
# the 2nd call to payment_api raises, every trial
FaultRule(target="payment_api", action=Action.ERROR, when_call_index=1),
# a content-aware fault (mocked fidelity only): fail if it tried to overcharge
FaultRule(
target="payment_api", action=Action.ERROR,
when=lambda call: call.args.get("amount", 0) > 100,
),
))
scenario = ScenarioConfig(name="payment-outage", user_instructions="...", faults=faults)
Fidelity.MOCKED(default): the adapter routes its mocked dependencies through a harness-ownedProxyInterceptorbuilt from the spec. Fully in-process; thewhencontent predicate works here.Fidelity.REAL: the adapter postsserialize_faults(ctx.faults)to your app's internal endpoint and the app's own tool dispatch honours it. Awhenpredicate cannot cross a process boundary, soserialize_faults()raises on one rather than silently dropping it — usewhen_call_indexfor real-fidelity runs.
Actions: ERROR, TIMEOUT, REPLACE_OUTPUT (returns payload), LATENCY
(sleeps payload seconds).
Metrics
Results are rolled up per assertion, not just per scenario, so a scenario can legitimately pass its capability bar while failing a safety gate:
- capability → pass@k — passes if the assertion held on at least one of
ktrials; - safety-critical → pass^k — passes only if it held on every trial.
ScenarioReport.passed is true only when every assertion passes under its own rule.
Records are written through a pluggable ResultStore (JSONFileStore /
NullStore ship in the box).
App-side integration
For the harness to drive your bot, the bot must accept a conversation_id per
conversation and thread it straight into identity(conversation_id=...) — and, to
be drivable, expose a way for the simulator to hand it that id. See the
"Grouping runs into a conversation" section of
docs/IMPLEMENTATION_STRANDS.md /
docs/IMPLEMENTATION_LANGCHAIN.md, and
DESIGN.md §10 for the full rationale.
License
MIT — see LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file sonar_eval-0.3.0.tar.gz.
File metadata
- Download URL: sonar_eval-0.3.0.tar.gz
- Upload date:
- Size: 18.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d269c4a073f6913d73631ffff6125898ef039d2eaed51c65c83104cab1de0d0b
|
|
| MD5 |
0ac38833b7cd31e8671dacfbc423569d
|
|
| BLAKE2b-256 |
7141dbaa67936a4d2c30acea45eaeb02f7cdafd04360c12e6e20be714544a035
|
Provenance
The following attestation bundles were made for sonar_eval-0.3.0.tar.gz:
Publisher:
publish.yml on deep-dive-mexico/sonar
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sonar_eval-0.3.0.tar.gz -
Subject digest:
d269c4a073f6913d73631ffff6125898ef039d2eaed51c65c83104cab1de0d0b - Sigstore transparency entry: 2551968713
- Sigstore integration time:
-
Permalink:
deep-dive-mexico/sonar@7825098eb016a8d1ab4bda01e9e2e4c42f0cd76d -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/deep-dive-mexico
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@7825098eb016a8d1ab4bda01e9e2e4c42f0cd76d -
Trigger Event:
release
-
Statement type:
File details
Details for the file sonar_eval-0.3.0-py3-none-any.whl.
File metadata
- Download URL: sonar_eval-0.3.0-py3-none-any.whl
- Upload date:
- Size: 21.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
670ca186c29e1a48ac1a962628bde8dc7858868c6be11ad5513f3e37a3ad665a
|
|
| MD5 |
2e55b6b7c637a831f57b400342ed869e
|
|
| BLAKE2b-256 |
c05d5bbabcc16df0c367ab22659bc2e84266f2dcdca4e9566c79a6aff9f893e8
|
Provenance
The following attestation bundles were made for sonar_eval-0.3.0-py3-none-any.whl:
Publisher:
publish.yml on deep-dive-mexico/sonar
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sonar_eval-0.3.0-py3-none-any.whl -
Subject digest:
670ca186c29e1a48ac1a962628bde8dc7858868c6be11ad5513f3e37a3ad665a - Sigstore transparency entry: 2551969159
- Sigstore integration time:
-
Permalink:
deep-dive-mexico/sonar@7825098eb016a8d1ab4bda01e9e2e4c42f0cd76d -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/deep-dive-mexico
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@7825098eb016a8d1ab4bda01e9e2e4c42f0cd76d -
Trigger Event:
release
-
Statement type: