sonar-eval 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ venv/
6
+ .pytest_cache/
7
+ .ruff_cache/
8
+ .mypy_cache/
9
+ dist/
10
+ build/
11
+ .env
12
+ *.local.yaml
13
+ .DS_Store
14
+
15
+ # Collector persistent queue (runtime state)
16
+ collector/queue/*
17
+ !collector/queue/.gitkeep
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matthew Ahmon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,209 @@
1
+ Metadata-Version: 2.5
2
+ Name: sonar-eval
3
+ Version: 0.3.0
4
+ Summary: Evaluation harness SDK for agentic AI chatbots, built on sonar-tracing.
5
+ Author-email: Matthew Ahmon <matthew.ahmon@gmail.com>
6
+ Maintainer-email: Matthew Ahmon <matthew.ahmon@gmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: agents,ai,evaluation,llm,sonar,testing
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: httpx>=0.27
20
+ Requires-Dist: sonar-tracing>=0.1
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
23
+ Requires-Dist: pytest>=8; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # sonar-eval
27
+
28
+ Evaluation harness SDK for agentic AI chatbots, built on
29
+ [`sonar-tracing`](https://pypi.org/project/sonar-tracing/).
30
+
31
+ The harness drives your bot through simulated conversations, optionally injects
32
+ faults into its tool calls, and grades the transcript it pulls back from
33
+ `sonar-ingest`. You write one small adapter for your bot; the trial loop,
34
+ fault injection, assertions, LLM grading, and pass@k / pass^k rollups are generic.
35
+
36
+ ```bash
37
+ pip install sonar-eval
38
+ ```
39
+
40
+ ## The mental model
41
+
42
+ `conversation_id` is the spine of the whole design. For each trial the harness:
43
+
44
+ 1. mints a `conversation_id` via `sonar_tracing.new_conversation()` — this is the
45
+ **trial id**, the **fault-injection scope key**, and the **join key** all at once;
46
+ 2. hands it to your `BotAdapter`, which makes the bot trace under that id (in a
47
+ real service, by POSTing it to an app-internal endpoint the app threads into
48
+ `tracing.identity(conversation_id=...)`);
49
+ 3. lets a **user simulator** (an LLM playing the human) drive turns until it emits
50
+ the termination signal or hits `max_turns`. The simulator sees *only* the
51
+ messages delivered back to it — never the bot's tools or spans;
52
+ 4. pulls the whole conversation back out of ingest by that id
53
+ (`GET /v1/conversations/{id}`) and reconstructs the tool-call transcript;
54
+ 5. runs programmatic **assertions** (tier 1) and an optional LLM **rubric grader**
55
+ (tier 2), then repeats `k` times and rolls the results up per assertion.
56
+
57
+ The grader scores what actually happened — the span tree in Postgres — not
58
+ whatever the adapter chose to return. That is why `send()` returns only delivered
59
+ messages and the transcript comes from ingest.
60
+
61
+ ## What you provide
62
+
63
+ Two things are project-specific: an `LLMClient` (the harness never imports a
64
+ vendor SDK) and a `BotAdapter` for your bot.
65
+
66
+ ```python
67
+ from sonar_eval import BaseBotAdapter, Delivered, LLMConfig, Message, Session, TrialContext
68
+
69
+
70
+ class MyLLM:
71
+ """Wrap whatever model client you use. Return the completion text."""
72
+
73
+ async def complete(self, config: LLMConfig, system: str, messages: list[Message]) -> str:
74
+ ... # call your provider with `system` + `messages`, return the text
75
+
76
+
77
+ class MyBotAdapter(BaseBotAdapter):
78
+ name = "support-bot"
79
+
80
+ async def setup_trial(self, ctx: TrialContext) -> Session:
81
+ # Isolate/seed state for this trial and make the bot trace under
82
+ # ctx.conversation_id. For a real service: POST ctx.conversation_id
83
+ # (and, for real fidelity, serialize_faults(ctx.faults)) to your bot's
84
+ # internal endpoint so it wraps its turns in identity(conversation_id=...).
85
+ handle = await open_session(ctx.conversation_id, seed=ctx.seed.data)
86
+ return Session(conversation_id=ctx.conversation_id, handle=handle)
87
+
88
+ async def send(self, session: Session, user_msg: str, media=None) -> Delivered:
89
+ reply = await session.handle.send(user_msg) # drive one user turn
90
+ return Delivered(messages=(reply,)) # only what the user sees
91
+
92
+ async def teardown_trial(self, session: Session) -> None:
93
+ await session.handle.close() # safe after a failed setup
94
+ ```
95
+
96
+ ## Running a scenario
97
+
98
+ ```python
99
+ import asyncio
100
+
101
+ from sonar_eval import (
102
+ Assertion, Fidelity, LLMConfig, Orchestrator, ScenarioConfig, Severity,
103
+ TaskConfig, no_tool_errors, tool_was_called,
104
+ )
105
+
106
+ task = TaskConfig(
107
+ ingest_base_url="http://localhost:4319",
108
+ tenant="acme",
109
+ user_llm=LLMConfig(model="claude-sonnet-5"),
110
+ grader_llm=LLMConfig(model="claude-opus-5"),
111
+ # project defaults to "eval-fullcontent" so the grader sees unredacted tool I/O
112
+ )
113
+
114
+ scenario = ScenarioConfig(
115
+ name="order-a-coffee",
116
+ user_instructions="You want a large oat-milk latte. Order it, then stop.",
117
+ k=5,
118
+ assertions=[
119
+ # capability: passes if it held on ANY of the k trials (pass@k)
120
+ Assertion("looked-up-menu", Severity.CAPABILITY, tool_was_called("menu_lookup")),
121
+ # safety-critical: passes only if it held on EVERY trial (pass^k == 1.0)
122
+ Assertion("no-tool-errors", Severity.SAFETY_CRITICAL, no_tool_errors()),
123
+ ],
124
+ grader_rubric="Did the bot confirm the exact order before charging? Score 0..1.",
125
+ )
126
+
127
+ orch = Orchestrator(task, MyBotAdapter(), MyLLM())
128
+ report = asyncio.run(orch.run_scenario(scenario))
129
+
130
+ print(report.passed) # True only if every assertion passed
131
+ for a in report.assertions:
132
+ print(a.name, a.severity, a.pass_at_k, a.pass_hat_k)
133
+ print(report.mean_grader_score) # None unless a grader ran
134
+ ```
135
+
136
+ Built-in checks: `tool_was_called(name)`, `no_tool_errors()`, `max_turns(n)`,
137
+ `max_total_tokens(n)`. A check is any `Callable[[Transcript, ConversationLog], bool]`,
138
+ so you can write your own over the pulled transcript.
139
+
140
+ Grading is opt-in: pass a `grader_factory` to the orchestrator and set
141
+ `grader_rubric` on the scenario.
142
+
143
+ ```python
144
+ from sonar_eval import Grader
145
+
146
+ orch = Orchestrator(
147
+ task, MyBotAdapter(), MyLLM(),
148
+ grader_factory=lambda s: Grader(MyLLM(), s.grader_llm, s.grader_rubric),
149
+ store=None, # defaults to NullStore; use JSONFileStore("results/") to persist
150
+ )
151
+ ```
152
+
153
+ ## Fault injection
154
+
155
+ Faults are declared once and applied by whichever mechanism the scenario's
156
+ fidelity selects. Rules are declarative so trials stay reproducible (pass^k needs
157
+ determinism).
158
+
159
+ ```python
160
+ from sonar_eval import Action, FaultRule, FaultSpec
161
+
162
+ faults = FaultSpec(rules=(
163
+ # the 2nd call to payment_api raises, every trial
164
+ FaultRule(target="payment_api", action=Action.ERROR, when_call_index=1),
165
+ # a content-aware fault (mocked fidelity only): fail if it tried to overcharge
166
+ FaultRule(
167
+ target="payment_api", action=Action.ERROR,
168
+ when=lambda call: call.args.get("amount", 0) > 100,
169
+ ),
170
+ ))
171
+ scenario = ScenarioConfig(name="payment-outage", user_instructions="...", faults=faults)
172
+ ```
173
+
174
+ - **`Fidelity.MOCKED`** (default): the adapter routes its mocked dependencies
175
+ through a harness-owned `ProxyInterceptor` built from the spec. Fully in-process;
176
+ the `when` content predicate works here.
177
+ - **`Fidelity.REAL`**: the adapter posts `serialize_faults(ctx.faults)` to your
178
+ app's internal endpoint and the app's own tool dispatch honours it. A `when`
179
+ predicate cannot cross a process boundary, so `serialize_faults()` raises on one
180
+ rather than silently dropping it — use `when_call_index` for real-fidelity runs.
181
+
182
+ Actions: `ERROR`, `TIMEOUT`, `REPLACE_OUTPUT` (returns `payload`), `LATENCY`
183
+ (sleeps `payload` seconds).
184
+
185
+ ## Metrics
186
+
187
+ Results are rolled up **per assertion**, not just per scenario, so a scenario can
188
+ legitimately pass its capability bar while failing a safety gate:
189
+
190
+ - **capability → pass@k** — passes if the assertion held on at least one of `k` trials;
191
+ - **safety-critical → pass^k** — passes only if it held on every trial.
192
+
193
+ `ScenarioReport.passed` is true only when every assertion passes under its own rule.
194
+ Records are written through a pluggable `ResultStore` (`JSONFileStore` /
195
+ `NullStore` ship in the box).
196
+
197
+ ## App-side integration
198
+
199
+ For the harness to drive your bot, the bot must accept a `conversation_id` per
200
+ conversation and thread it straight into `identity(conversation_id=...)` — and, to
201
+ be *drivable*, expose a way for the simulator to hand it that id. See the
202
+ "Grouping runs into a conversation" section of
203
+ [`docs/IMPLEMENTATION_STRANDS.md`](../../docs/IMPLEMENTATION_STRANDS.md) /
204
+ [`docs/IMPLEMENTATION_LANGCHAIN.md`](../../docs/IMPLEMENTATION_LANGCHAIN.md), and
205
+ [`DESIGN.md` §10](../../DESIGN.md) for the full rationale.
206
+
207
+ ## License
208
+
209
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,184 @@
1
+ # sonar-eval
2
+
3
+ Evaluation harness SDK for agentic AI chatbots, built on
4
+ [`sonar-tracing`](https://pypi.org/project/sonar-tracing/).
5
+
6
+ The harness drives your bot through simulated conversations, optionally injects
7
+ faults into its tool calls, and grades the transcript it pulls back from
8
+ `sonar-ingest`. You write one small adapter for your bot; the trial loop,
9
+ fault injection, assertions, LLM grading, and pass@k / pass^k rollups are generic.
10
+
11
+ ```bash
12
+ pip install sonar-eval
13
+ ```
14
+
15
+ ## The mental model
16
+
17
+ `conversation_id` is the spine of the whole design. For each trial the harness:
18
+
19
+ 1. mints a `conversation_id` via `sonar_tracing.new_conversation()` — this is the
20
+ **trial id**, the **fault-injection scope key**, and the **join key** all at once;
21
+ 2. hands it to your `BotAdapter`, which makes the bot trace under that id (in a
22
+ real service, by POSTing it to an app-internal endpoint the app threads into
23
+ `tracing.identity(conversation_id=...)`);
24
+ 3. lets a **user simulator** (an LLM playing the human) drive turns until it emits
25
+ the termination signal or hits `max_turns`. The simulator sees *only* the
26
+ messages delivered back to it — never the bot's tools or spans;
27
+ 4. pulls the whole conversation back out of ingest by that id
28
+ (`GET /v1/conversations/{id}`) and reconstructs the tool-call transcript;
29
+ 5. runs programmatic **assertions** (tier 1) and an optional LLM **rubric grader**
30
+ (tier 2), then repeats `k` times and rolls the results up per assertion.
31
+
32
+ The grader scores what actually happened — the span tree in Postgres — not
33
+ whatever the adapter chose to return. That is why `send()` returns only delivered
34
+ messages and the transcript comes from ingest.
35
+
36
+ ## What you provide
37
+
38
+ Two things are project-specific: an `LLMClient` (the harness never imports a
39
+ vendor SDK) and a `BotAdapter` for your bot.
40
+
41
+ ```python
42
+ from sonar_eval import BaseBotAdapter, Delivered, LLMConfig, Message, Session, TrialContext
43
+
44
+
45
+ class MyLLM:
46
+ """Wrap whatever model client you use. Return the completion text."""
47
+
48
+ async def complete(self, config: LLMConfig, system: str, messages: list[Message]) -> str:
49
+ ... # call your provider with `system` + `messages`, return the text
50
+
51
+
52
+ class MyBotAdapter(BaseBotAdapter):
53
+ name = "support-bot"
54
+
55
+ async def setup_trial(self, ctx: TrialContext) -> Session:
56
+ # Isolate/seed state for this trial and make the bot trace under
57
+ # ctx.conversation_id. For a real service: POST ctx.conversation_id
58
+ # (and, for real fidelity, serialize_faults(ctx.faults)) to your bot's
59
+ # internal endpoint so it wraps its turns in identity(conversation_id=...).
60
+ handle = await open_session(ctx.conversation_id, seed=ctx.seed.data)
61
+ return Session(conversation_id=ctx.conversation_id, handle=handle)
62
+
63
+ async def send(self, session: Session, user_msg: str, media=None) -> Delivered:
64
+ reply = await session.handle.send(user_msg) # drive one user turn
65
+ return Delivered(messages=(reply,)) # only what the user sees
66
+
67
+ async def teardown_trial(self, session: Session) -> None:
68
+ await session.handle.close() # safe after a failed setup
69
+ ```
70
+
71
+ ## Running a scenario
72
+
73
+ ```python
74
+ import asyncio
75
+
76
+ from sonar_eval import (
77
+ Assertion, Fidelity, LLMConfig, Orchestrator, ScenarioConfig, Severity,
78
+ TaskConfig, no_tool_errors, tool_was_called,
79
+ )
80
+
81
+ task = TaskConfig(
82
+ ingest_base_url="http://localhost:4319",
83
+ tenant="acme",
84
+ user_llm=LLMConfig(model="claude-sonnet-5"),
85
+ grader_llm=LLMConfig(model="claude-opus-5"),
86
+ # project defaults to "eval-fullcontent" so the grader sees unredacted tool I/O
87
+ )
88
+
89
+ scenario = ScenarioConfig(
90
+ name="order-a-coffee",
91
+ user_instructions="You want a large oat-milk latte. Order it, then stop.",
92
+ k=5,
93
+ assertions=[
94
+ # capability: passes if it held on ANY of the k trials (pass@k)
95
+ Assertion("looked-up-menu", Severity.CAPABILITY, tool_was_called("menu_lookup")),
96
+ # safety-critical: passes only if it held on EVERY trial (pass^k == 1.0)
97
+ Assertion("no-tool-errors", Severity.SAFETY_CRITICAL, no_tool_errors()),
98
+ ],
99
+ grader_rubric="Did the bot confirm the exact order before charging? Score 0..1.",
100
+ )
101
+
102
+ orch = Orchestrator(task, MyBotAdapter(), MyLLM())
103
+ report = asyncio.run(orch.run_scenario(scenario))
104
+
105
+ print(report.passed) # True only if every assertion passed
106
+ for a in report.assertions:
107
+ print(a.name, a.severity, a.pass_at_k, a.pass_hat_k)
108
+ print(report.mean_grader_score) # None unless a grader ran
109
+ ```
110
+
111
+ Built-in checks: `tool_was_called(name)`, `no_tool_errors()`, `max_turns(n)`,
112
+ `max_total_tokens(n)`. A check is any `Callable[[Transcript, ConversationLog], bool]`,
113
+ so you can write your own over the pulled transcript.
114
+
115
+ Grading is opt-in: pass a `grader_factory` to the orchestrator and set
116
+ `grader_rubric` on the scenario.
117
+
118
+ ```python
119
+ from sonar_eval import Grader
120
+
121
+ orch = Orchestrator(
122
+ task, MyBotAdapter(), MyLLM(),
123
+ grader_factory=lambda s: Grader(MyLLM(), s.grader_llm, s.grader_rubric),
124
+ store=None, # defaults to NullStore; use JSONFileStore("results/") to persist
125
+ )
126
+ ```
127
+
128
+ ## Fault injection
129
+
130
+ Faults are declared once and applied by whichever mechanism the scenario's
131
+ fidelity selects. Rules are declarative so trials stay reproducible (pass^k needs
132
+ determinism).
133
+
134
+ ```python
135
+ from sonar_eval import Action, FaultRule, FaultSpec
136
+
137
+ faults = FaultSpec(rules=(
138
+ # the 2nd call to payment_api raises, every trial
139
+ FaultRule(target="payment_api", action=Action.ERROR, when_call_index=1),
140
+ # a content-aware fault (mocked fidelity only): fail if it tried to overcharge
141
+ FaultRule(
142
+ target="payment_api", action=Action.ERROR,
143
+ when=lambda call: call.args.get("amount", 0) > 100,
144
+ ),
145
+ ))
146
+ scenario = ScenarioConfig(name="payment-outage", user_instructions="...", faults=faults)
147
+ ```
148
+
149
+ - **`Fidelity.MOCKED`** (default): the adapter routes its mocked dependencies
150
+ through a harness-owned `ProxyInterceptor` built from the spec. Fully in-process;
151
+ the `when` content predicate works here.
152
+ - **`Fidelity.REAL`**: the adapter posts `serialize_faults(ctx.faults)` to your
153
+ app's internal endpoint and the app's own tool dispatch honours it. A `when`
154
+ predicate cannot cross a process boundary, so `serialize_faults()` raises on one
155
+ rather than silently dropping it — use `when_call_index` for real-fidelity runs.
156
+
157
+ Actions: `ERROR`, `TIMEOUT`, `REPLACE_OUTPUT` (returns `payload`), `LATENCY`
158
+ (sleeps `payload` seconds).
159
+
160
+ ## Metrics
161
+
162
+ Results are rolled up **per assertion**, not just per scenario, so a scenario can
163
+ legitimately pass its capability bar while failing a safety gate:
164
+
165
+ - **capability → pass@k** — passes if the assertion held on at least one of `k` trials;
166
+ - **safety-critical → pass^k** — passes only if it held on every trial.
167
+
168
+ `ScenarioReport.passed` is true only when every assertion passes under its own rule.
169
+ Records are written through a pluggable `ResultStore` (`JSONFileStore` /
170
+ `NullStore` ship in the box).
171
+
172
+ ## App-side integration
173
+
174
+ For the harness to drive your bot, the bot must accept a `conversation_id` per
175
+ conversation and thread it straight into `identity(conversation_id=...)` — and, to
176
+ be *drivable*, expose a way for the simulator to hand it that id. See the
177
+ "Grouping runs into a conversation" section of
178
+ [`docs/IMPLEMENTATION_STRANDS.md`](../../docs/IMPLEMENTATION_STRANDS.md) /
179
+ [`docs/IMPLEMENTATION_LANGCHAIN.md`](../../docs/IMPLEMENTATION_LANGCHAIN.md), and
180
+ [`DESIGN.md` §10](../../DESIGN.md) for the full rationale.
181
+
182
+ ## License
183
+
184
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,38 @@
1
+ [project]
2
+ name = "sonar-eval"
3
+ dynamic = ["version"]
4
+ description = "Evaluation harness SDK for agentic AI chatbots, built on sonar-tracing."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.11"
9
+ authors = [{ name = "Matthew Ahmon", email = "matthew.ahmon@gmail.com" }]
10
+ maintainers = [{ name = "Matthew Ahmon", email = "matthew.ahmon@gmail.com" }]
11
+ keywords = ["evaluation", "ai", "agents", "llm", "testing", "sonar"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Intended Audience :: Developers",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Topic :: Software Development :: Libraries :: Python Modules",
19
+ "Topic :: Software Development :: Testing",
20
+ "Typing :: Typed",
21
+ ]
22
+ dependencies = [
23
+ "sonar-tracing>=0.1",
24
+ "httpx>=0.27",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ dev = ["pytest>=8", "pytest-asyncio>=0.24"]
29
+
30
+ [build-system]
31
+ requires = ["hatchling"]
32
+ build-backend = "hatchling.build"
33
+
34
+ [tool.hatch.version]
35
+ path = "src/sonar_eval/version.py"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/sonar_eval"]
@@ -0,0 +1,101 @@
1
+ """sonar-eval - evaluation harness SDK for agentic AI chatbots.
2
+
3
+ Built on sonar-tracing: the harness drives an agent through simulated conversations,
4
+ injects faults into its tool calls, and grades the transcript it pulls back from
5
+ sonar-ingest by conversation_id.
6
+
7
+ from sonar_eval import Orchestrator, TaskConfig, ScenarioConfig
8
+ """
9
+
10
+ from .adapter import (
11
+ BaseBotAdapter,
12
+ BotAdapter,
13
+ Delivered,
14
+ Seed,
15
+ Session,
16
+ TrialContext,
17
+ load_adapter,
18
+ validate_bot_adapter,
19
+ )
20
+ from .assertions import (
21
+ Assertion,
22
+ AssertionEngine,
23
+ AssertionResult,
24
+ Severity,
25
+ max_total_tokens,
26
+ max_turns,
27
+ no_tool_errors,
28
+ tool_was_called,
29
+ )
30
+ from .config import Fidelity, Persona, ScenarioConfig, TaskConfig
31
+ from .grader import Grader, GraderResult
32
+ from .interceptor import (
33
+ Action,
34
+ FaultRule,
35
+ FaultSpec,
36
+ InjectedFault,
37
+ InterceptDecision,
38
+ ProxyInterceptor,
39
+ ToolCall,
40
+ ToolInterceptor,
41
+ serialize_faults,
42
+ )
43
+ from .llm import LLMClient, LLMConfig, Message
44
+ from .metrics import AssertionMetric, ScenarioReport, summarize
45
+ from .orchestrator import Orchestrator, TrialResult
46
+ from .simulator import ConversationLog, Turn, UserSimulator
47
+ from .store import JSONFileStore, NullStore, ResultStore
48
+ from .transcript import IngestClient, ToolInvocation, Transcript
49
+ from .version import __version__
50
+
51
+ __all__ = [
52
+ "Action",
53
+ "Assertion",
54
+ "AssertionEngine",
55
+ "AssertionMetric",
56
+ "AssertionResult",
57
+ "BaseBotAdapter",
58
+ "BotAdapter",
59
+ "ConversationLog",
60
+ "Delivered",
61
+ "FaultRule",
62
+ "FaultSpec",
63
+ "Fidelity",
64
+ "Grader",
65
+ "GraderResult",
66
+ "IngestClient",
67
+ "InjectedFault",
68
+ "InterceptDecision",
69
+ "JSONFileStore",
70
+ "LLMClient",
71
+ "LLMConfig",
72
+ "Message",
73
+ "NullStore",
74
+ "Orchestrator",
75
+ "Persona",
76
+ "ProxyInterceptor",
77
+ "ResultStore",
78
+ "ScenarioConfig",
79
+ "ScenarioReport",
80
+ "Seed",
81
+ "Session",
82
+ "Severity",
83
+ "TaskConfig",
84
+ "ToolCall",
85
+ "ToolInterceptor",
86
+ "ToolInvocation",
87
+ "Transcript",
88
+ "TrialContext",
89
+ "TrialResult",
90
+ "Turn",
91
+ "UserSimulator",
92
+ "__version__",
93
+ "load_adapter",
94
+ "max_total_tokens",
95
+ "max_turns",
96
+ "no_tool_errors",
97
+ "serialize_faults",
98
+ "summarize",
99
+ "tool_was_called",
100
+ "validate_bot_adapter",
101
+ ]
@@ -0,0 +1,122 @@
1
+ """The bot adapter contract.
2
+
3
+ One thin interface the developer implements once per bot. It absorbs the stack
4
+ differences (auth, DB, transport) so the rest of the harness stays generic, and it is
5
+ where per-trial isolation and seeding happen.
6
+
7
+ Two things are threaded through every trial:
8
+
9
+ conversation_id minted by the harness via ``sonar_tracing.new_conversation()``. The
10
+ adapter must make the bot stamp it on its spans - for a service like
11
+ Cafeina that means POSTing it to an app-internal endpoint so the app
12
+ wraps its turns in ``tracing.identity(conversation_id=...)``. It is the
13
+ join key the grader later uses to pull the transcript back from ingest.
14
+ faults a FaultSpec. In mocked fidelity the adapter routes its mocks through a
15
+ ProxyInterceptor built from it; in real fidelity it posts
16
+ ``serialize_faults(faults)`` to the app alongside conversation_id.
17
+
18
+ ``send()`` returns only what a human user would see. The tool-call transcript is NOT
19
+ returned here - it is pulled from sonar-ingest by conversation_id, which keeps the user
20
+ simulator honest (it can only ever see delivered messages).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import importlib
26
+ from dataclasses import dataclass, field
27
+ from typing import Any, Protocol
28
+
29
+ from .config import Fidelity
30
+ from .interceptor import FaultSpec
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Seed:
35
+ """Project-defined seed state: authorizations, DB rows, stored data.
36
+
37
+ Opaque to the harness; the adapter is the only thing that interprets ``data``.
38
+ """
39
+
40
+ data: dict[str, Any] = field(default_factory=dict)
41
+
42
+
43
+ @dataclass
44
+ class Session:
45
+ """Opaque per-trial handle the adapter owns. Carries the join key for convenience."""
46
+
47
+ conversation_id: str
48
+ handle: Any = None
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Delivered:
53
+ """Exactly what a human user would see for one turn."""
54
+
55
+ messages: tuple[str, ...] = ()
56
+ media: tuple[Any, ...] = ()
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class TrialContext:
61
+ conversation_id: str
62
+ seed: Seed
63
+ faults: FaultSpec
64
+ fidelity: Fidelity
65
+
66
+
67
+ class BotAdapter(Protocol):
68
+ name: str
69
+
70
+ async def setup_trial(self, ctx: TrialContext) -> Session:
71
+ """Isolate + seed state for one trial, wire fault injection, return a handle."""
72
+
73
+ async def send(self, session: Session, user_msg: str, media: Any = None) -> Delivered:
74
+ """Deliver one user turn; return only what the user would see back."""
75
+
76
+ async def teardown_trial(self, session: Session) -> None:
77
+ """Reset in lockstep. Must be safe to call after a failed setup."""
78
+
79
+
80
+ class BaseBotAdapter:
81
+ """Optional convenience base so a minimal adapter is a few lines."""
82
+
83
+ name: str = "unnamed"
84
+
85
+ async def setup_trial(self, ctx: TrialContext) -> Session:
86
+ return Session(conversation_id=ctx.conversation_id)
87
+
88
+ async def send(self, session: Session, user_msg: str, media: Any = None) -> Delivered:
89
+ return Delivered()
90
+
91
+ async def teardown_trial(self, session: Session) -> None:
92
+ return None
93
+
94
+
95
+ _REQUIRED_METHODS = ("setup_trial", "send", "teardown_trial")
96
+
97
+
98
+ def validate_bot_adapter(candidate: Any) -> None:
99
+ """Structural check with a useful message.
100
+
101
+ ``runtime_checkable`` Protocols cannot verify the ``name`` attribute, and a missing
102
+ async method would otherwise surface deep inside the trial loop.
103
+ """
104
+ if not isinstance(getattr(candidate, "name", None), str):
105
+ raise TypeError(f"{candidate!r} is missing a string `name` attribute")
106
+ not_callable = [m for m in _REQUIRED_METHODS if not callable(getattr(candidate, m, None))]
107
+ if not_callable:
108
+ raise TypeError(f"{candidate!r} is missing methods: {not_callable}")
109
+
110
+
111
+ def load_adapter(path: str) -> BotAdapter:
112
+ """Load an adapter from a ``module.path:attribute`` string and validate it.
113
+
114
+ The attribute may be an instance or a zero-arg class/factory.
115
+ """
116
+ module_path, _, attr = path.partition(":")
117
+ if not attr:
118
+ raise ValueError(f"adapter path must be 'module:attr', got {path!r}")
119
+ obj = getattr(importlib.import_module(module_path), attr)
120
+ candidate = obj() if isinstance(obj, type) else obj
121
+ validate_bot_adapter(candidate)
122
+ return candidate