outturn 0.1.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.
outturn-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wisdom Omons
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.
outturn-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,240 @@
1
+ Metadata-Version: 2.4
2
+ Name: outturn
3
+ Version: 0.1.0
4
+ Summary: Did the agent produce the right outcome? As a number.
5
+ Author: Wisdom Omons
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/OsasDTEch/outturn
8
+ Project-URL: Source, https://github.com/OsasDTEch/outturn
9
+ Keywords: voice-agents,llm,evaluation,testing,livekit,agents
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: Testing
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: PyYAML>=6.0
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=7.4; extra == "dev"
20
+ Provides-Extra: livekit
21
+ Requires-Dist: livekit-agents==1.8.2; extra == "livekit"
22
+ Dynamic: license-file
23
+
24
+ # outturn
25
+
26
+ **Did the agent produce the right outcome? As a number.**
27
+
28
+ A conversation does not end in words. It ends in an order, a booking, a routed ticket, a qualified lead. That is structured data, and structured data can be compared exactly. So whether your agent got it right does not have to be something you learn from a refund. It can be a percentage that runs on every commit.
29
+
30
+ ```
31
+ 100% simple_booking 8/8
32
+ 50% service_switch_duration 4/8 FLAKY
33
+ 4x wrong_field: duration_minutes (expected 90, got 60)
34
+ 100% never_confirmed 8/8
35
+
36
+ outcome accuracy: 83.3% (20/24 runs)
37
+ flaky scenarios (1): service_switch_duration
38
+ a scenario that passes sometimes is a bug that ships sometimes.
39
+ ```
40
+
41
+ ---
42
+
43
+ ## Why this exists
44
+
45
+ Most voice agent testing is a person calling the number and listening. That finds the bug in front of you and none of the others, it cannot run in CI, and it produces an opinion instead of a number.
46
+
47
+ The alternatives are not much better. Asserting on the transcript is asserting on phrasing, which changes every time you touch the prompt. Using a model to grade the conversation means one nondeterministic system judging another.
48
+
49
+ The outcome is the way out. It is the thing the customer actually receives, it is already structured, and it can be compared exactly.
50
+
51
+ ## Three design decisions
52
+
53
+ **Numbers are exact, words are fuzzy.** A quantity of 3 is not close to 2, and a 90 minute appointment is not close to a 60 minute one. But "deep tissue massage" and "Deep Tissue Massage (60min)" are the same service. So every number is compared exactly and every word by normalized similarity.
54
+
55
+ **Every scenario runs N times and reports a pass rate.** Agents are nondeterministic. One green run is not evidence. A bug that appears one run in five is invisible to a boolean test and obvious in a percentage, so outturn reports rates and flags anything that passed sometimes and failed sometimes as FLAKY. Intermittent is worse than broken, because intermittent hides.
56
+
57
+ **An outcome is repeated things plus scalars.** That is all. An order is entries plus a total. A booking is no entries at all, just fields. A triage is fields. One model, no special cases per domain, which is why the same engine handles an ordering agent and a scheduling agent without a line of new code.
58
+
59
+ ## Install
60
+
61
+ ```bash
62
+ pip install outturn
63
+ ```
64
+
65
+ Or from source, if you want the demo scenarios to play with:
66
+
67
+ ```bash
68
+ git clone https://github.com/OsasDTEch/outturn
69
+ cd outturn
70
+ pip install -e ".[dev]"
71
+ ```
72
+
73
+ ## Run it right now
74
+
75
+ Two deliberately imperfect demo agents ship with the repo, so you can see real output before writing any integration.
76
+
77
+ ```bash
78
+ outturn scenarios/order --adapter demo-order --runs 8 --seed 42
79
+ outturn scenarios/booking --adapter demo-booking --runs 8 --seed 42
80
+ ```
81
+
82
+ Each fails on purpose, because an eval you cannot fail is not an eval.
83
+
84
+ The ordering agent drops a modifier at random under load, which is what FLAKY looks like, and its scope guard is tuned too tight so "do you sell garlic bread" is treated as an off topic question rather than a customer trying to order. That second one is not a strawman, it is behaviour observed on a shipped restaurant agent.
85
+
86
+ The booking agent forgets to carry the duration across when the customer switches service mid call. A 90 minute massage in a 60 minute slot double books the therapist, and nobody finds out until the day.
87
+
88
+ ## Writing a scenario
89
+
90
+ Turns in, expected outcome out.
91
+
92
+ **An order**, which has line items:
93
+
94
+ ```yaml
95
+ id: modifier_stacking
96
+ description: >
97
+ Two modifiers on one item, added in a separate turn from the item itself.
98
+ tags: [order, modifiers]
99
+
100
+ turns:
101
+ - "Hi, can I get two large pepperoni pizzas"
102
+ - "Actually make those thin crust"
103
+ - "And extra cheese on them please"
104
+
105
+ expect:
106
+ entries:
107
+ - name: pepperoni pizza
108
+ quantity: 2
109
+ unit_price: 12.00
110
+ tags: [thin crust, extra cheese]
111
+ total: 24.00
112
+ ```
113
+
114
+ **A booking**, which has none:
115
+
116
+ ```yaml
117
+ id: service_switch_duration
118
+ turns:
119
+ - "Can I book a deep tissue massage for Friday"
120
+ - "Actually make it a sports massage instead"
121
+ - "4:00 pm, and yes please book it"
122
+
123
+ expect:
124
+ service: sports massage
125
+ day: friday
126
+ time: "16:00"
127
+ duration_minutes: 90
128
+ confirmed: true
129
+ ```
130
+
131
+ Same engine. Anything numeric under an entry becomes an exact comparison. Anything at the top level is a field: numbers and booleans exact, strings fuzzy.
132
+
133
+ You can also assert on a reply, though use it sparingly since phrasing is the brittle part:
134
+
135
+ ```yaml
136
+ turns:
137
+ - user: "Do you sell garlic bread?"
138
+ expect_reply_contains: ["garlic bread"]
139
+ ```
140
+
141
+ ## Connecting your own agent
142
+
143
+ Implement three methods.
144
+
145
+ ```python
146
+ from outturn.models import Outcome, Entry
147
+
148
+ class MyAgent:
149
+ name = "my-agent"
150
+
151
+ def reset(self) -> None:
152
+ """Fresh conversation. Called before every run."""
153
+ self.session = start_session()
154
+
155
+ def send(self, text: str) -> str:
156
+ """One caller turn in, the agent's reply out."""
157
+ return self.session.turn(text)
158
+
159
+ def outcome(self) -> Outcome:
160
+ """The structured result, right now."""
161
+ return Outcome(
162
+ entries=tuple(
163
+ Entry(name=l.name,
164
+ numbers={"quantity": l.qty, "unit_price": l.price},
165
+ tags=tuple(l.modifiers))
166
+ for l in self.session.order.lines
167
+ ),
168
+ fields={"total": self.session.order.total},
169
+ )
170
+ ```
171
+
172
+ Then point outturn at it. **Your agent lives in your repo, not in this one.**
173
+
174
+ ```bash
175
+ outturn scenarios/ --adapter myproject.agents:MyAgent --runs 10
176
+ ```
177
+
178
+ Anything importable works. If the object is missing one of the three methods,
179
+ outturn says so before the run starts rather than failing with an
180
+ AttributeError halfway through.
181
+
182
+ If your agent cannot hand back a structured outcome, outturn cannot help you, and that is itself the finding. State that lives only in the model's context is not inspectable, and what is not inspectable is not testable. Move the outcome into code and the tool works.
183
+
184
+ ## LiveKit
185
+
186
+ There is a driver at `outturn/adapters/livekit.py`. It takes a session factory and one callable that reads your state and returns an Outcome, because the outcome does not live in LiveKit, it lives in whatever your own function tools wrote it to.
187
+
188
+ **Verified against livekit-agents 1.8.2.** Tested with the ollama.com cloud API (model `gemma4:31b`, OpenAI-compatible endpoint). Confirmed:
189
+
190
+ - A session starts with no room and no audio.
191
+ - State written by function tools persists across multiple `session.run()` calls on the same session, so multi-turn scenarios work as written.
192
+ - The assistant reply is a `ChatMessageEvent` in `result.events` with `item.content[0]` as the text.
193
+
194
+ Pin your own install to `livekit-agents==1.8.2` until you have tested against a newer version. This API is young and the `RunResult` shape has changed between releases.
195
+
196
+ ## In CI
197
+
198
+ ```bash
199
+ outturn scenarios/booking --adapter demo-booking --runs 10 --threshold 0.95
200
+ ```
201
+
202
+ Exits non zero if overall accuracy falls below the threshold. Every bug you fix becomes a scenario, so it can never come back silently. `--json` gives machine readable output for tracking accuracy over time.
203
+
204
+ ## The scenarios worth writing
205
+
206
+ The ones that break agents, roughly in order of how often they do:
207
+
208
+ - **Corrections mid call.** The customer changes their mind after the agent has already recorded something. This is where most outcomes go wrong, and the failure is usually a field that did not get updated alongside the one that did.
209
+ - **Reference without naming.** "Make it three" with no item named.
210
+ - **Cancellation.** Added, then removed. Do the derived values follow?
211
+ - **Confirmation.** Did the agent act on intent rather than on an actual yes? Booking a customer who was still thinking is a real and expensive failure.
212
+ - **Name collisions.** Two items or two services that sound alike over a phone line.
213
+ - **Illegal combinations.** Extra cheese on a coke. Should be rejected by schema, not accepted politely and discovered later.
214
+ - **Out of scope questions that are really orders.** "Do you sell X" is a customer trying to buy X.
215
+
216
+ ## What this does not test
217
+
218
+ outturn drives your agent through text. That is fast enough to run in CI on every commit, and it isolates the reasoning and outcome layer from the audio layer.
219
+
220
+ It does not test speech to text, endpointing or turn taking, and those are real sources of failure, particularly on telephony where audio is narrowband and degrades worst on exactly what matters here: names, numbers and proper nouns.
221
+
222
+ **So this measures whether your agent understands correctly, not whether it hears correctly.** Both matter. For the timing half of the picture, see [voice-latency-profiler](https://github.com/OsasDTEch/voice-latency-profiler).
223
+
224
+ ## Status
225
+
226
+ v0.1. The core works and is tested, 31 tests. Roadmap, roughly in order:
227
+
228
+ - [x] LiveKit driver verified against livekit-agents 1.8.2 (ollama.com cloud, `gemma4:31b`)
229
+ - [ ] Path assertions: which tools were called, with which arguments
230
+ - [ ] Pipecat driver
231
+ - [ ] Audio mode, TTS in and STT out, for end to end runs
232
+ - [ ] Accuracy tracked over time, so regressions show as a trend
233
+
234
+ `PRD.md` and `TRD.md` in this repo cover the reasoning and the internals.
235
+
236
+ This project supersedes [voice-agent-evals](https://github.com/OsasDTEch/voice-agent-evals), which asserted on carts only. An order is one kind of outcome among many, and the narrower version was useful to about a tenth of the agents worth testing.
237
+
238
+ ## Licence
239
+
240
+ MIT. Built by [Wisdom Omons](https://linkedin.com/in/omons-wisdom).
@@ -0,0 +1,217 @@
1
+ # outturn
2
+
3
+ **Did the agent produce the right outcome? As a number.**
4
+
5
+ A conversation does not end in words. It ends in an order, a booking, a routed ticket, a qualified lead. That is structured data, and structured data can be compared exactly. So whether your agent got it right does not have to be something you learn from a refund. It can be a percentage that runs on every commit.
6
+
7
+ ```
8
+ 100% simple_booking 8/8
9
+ 50% service_switch_duration 4/8 FLAKY
10
+ 4x wrong_field: duration_minutes (expected 90, got 60)
11
+ 100% never_confirmed 8/8
12
+
13
+ outcome accuracy: 83.3% (20/24 runs)
14
+ flaky scenarios (1): service_switch_duration
15
+ a scenario that passes sometimes is a bug that ships sometimes.
16
+ ```
17
+
18
+ ---
19
+
20
+ ## Why this exists
21
+
22
+ Most voice agent testing is a person calling the number and listening. That finds the bug in front of you and none of the others, it cannot run in CI, and it produces an opinion instead of a number.
23
+
24
+ The alternatives are not much better. Asserting on the transcript is asserting on phrasing, which changes every time you touch the prompt. Using a model to grade the conversation means one nondeterministic system judging another.
25
+
26
+ The outcome is the way out. It is the thing the customer actually receives, it is already structured, and it can be compared exactly.
27
+
28
+ ## Three design decisions
29
+
30
+ **Numbers are exact, words are fuzzy.** A quantity of 3 is not close to 2, and a 90 minute appointment is not close to a 60 minute one. But "deep tissue massage" and "Deep Tissue Massage (60min)" are the same service. So every number is compared exactly and every word by normalized similarity.
31
+
32
+ **Every scenario runs N times and reports a pass rate.** Agents are nondeterministic. One green run is not evidence. A bug that appears one run in five is invisible to a boolean test and obvious in a percentage, so outturn reports rates and flags anything that passed sometimes and failed sometimes as FLAKY. Intermittent is worse than broken, because intermittent hides.
33
+
34
+ **An outcome is repeated things plus scalars.** That is all. An order is entries plus a total. A booking is no entries at all, just fields. A triage is fields. One model, no special cases per domain, which is why the same engine handles an ordering agent and a scheduling agent without a line of new code.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install outturn
40
+ ```
41
+
42
+ Or from source, if you want the demo scenarios to play with:
43
+
44
+ ```bash
45
+ git clone https://github.com/OsasDTEch/outturn
46
+ cd outturn
47
+ pip install -e ".[dev]"
48
+ ```
49
+
50
+ ## Run it right now
51
+
52
+ Two deliberately imperfect demo agents ship with the repo, so you can see real output before writing any integration.
53
+
54
+ ```bash
55
+ outturn scenarios/order --adapter demo-order --runs 8 --seed 42
56
+ outturn scenarios/booking --adapter demo-booking --runs 8 --seed 42
57
+ ```
58
+
59
+ Each fails on purpose, because an eval you cannot fail is not an eval.
60
+
61
+ The ordering agent drops a modifier at random under load, which is what FLAKY looks like, and its scope guard is tuned too tight so "do you sell garlic bread" is treated as an off topic question rather than a customer trying to order. That second one is not a strawman, it is behaviour observed on a shipped restaurant agent.
62
+
63
+ The booking agent forgets to carry the duration across when the customer switches service mid call. A 90 minute massage in a 60 minute slot double books the therapist, and nobody finds out until the day.
64
+
65
+ ## Writing a scenario
66
+
67
+ Turns in, expected outcome out.
68
+
69
+ **An order**, which has line items:
70
+
71
+ ```yaml
72
+ id: modifier_stacking
73
+ description: >
74
+ Two modifiers on one item, added in a separate turn from the item itself.
75
+ tags: [order, modifiers]
76
+
77
+ turns:
78
+ - "Hi, can I get two large pepperoni pizzas"
79
+ - "Actually make those thin crust"
80
+ - "And extra cheese on them please"
81
+
82
+ expect:
83
+ entries:
84
+ - name: pepperoni pizza
85
+ quantity: 2
86
+ unit_price: 12.00
87
+ tags: [thin crust, extra cheese]
88
+ total: 24.00
89
+ ```
90
+
91
+ **A booking**, which has none:
92
+
93
+ ```yaml
94
+ id: service_switch_duration
95
+ turns:
96
+ - "Can I book a deep tissue massage for Friday"
97
+ - "Actually make it a sports massage instead"
98
+ - "4:00 pm, and yes please book it"
99
+
100
+ expect:
101
+ service: sports massage
102
+ day: friday
103
+ time: "16:00"
104
+ duration_minutes: 90
105
+ confirmed: true
106
+ ```
107
+
108
+ Same engine. Anything numeric under an entry becomes an exact comparison. Anything at the top level is a field: numbers and booleans exact, strings fuzzy.
109
+
110
+ You can also assert on a reply, though use it sparingly since phrasing is the brittle part:
111
+
112
+ ```yaml
113
+ turns:
114
+ - user: "Do you sell garlic bread?"
115
+ expect_reply_contains: ["garlic bread"]
116
+ ```
117
+
118
+ ## Connecting your own agent
119
+
120
+ Implement three methods.
121
+
122
+ ```python
123
+ from outturn.models import Outcome, Entry
124
+
125
+ class MyAgent:
126
+ name = "my-agent"
127
+
128
+ def reset(self) -> None:
129
+ """Fresh conversation. Called before every run."""
130
+ self.session = start_session()
131
+
132
+ def send(self, text: str) -> str:
133
+ """One caller turn in, the agent's reply out."""
134
+ return self.session.turn(text)
135
+
136
+ def outcome(self) -> Outcome:
137
+ """The structured result, right now."""
138
+ return Outcome(
139
+ entries=tuple(
140
+ Entry(name=l.name,
141
+ numbers={"quantity": l.qty, "unit_price": l.price},
142
+ tags=tuple(l.modifiers))
143
+ for l in self.session.order.lines
144
+ ),
145
+ fields={"total": self.session.order.total},
146
+ )
147
+ ```
148
+
149
+ Then point outturn at it. **Your agent lives in your repo, not in this one.**
150
+
151
+ ```bash
152
+ outturn scenarios/ --adapter myproject.agents:MyAgent --runs 10
153
+ ```
154
+
155
+ Anything importable works. If the object is missing one of the three methods,
156
+ outturn says so before the run starts rather than failing with an
157
+ AttributeError halfway through.
158
+
159
+ If your agent cannot hand back a structured outcome, outturn cannot help you, and that is itself the finding. State that lives only in the model's context is not inspectable, and what is not inspectable is not testable. Move the outcome into code and the tool works.
160
+
161
+ ## LiveKit
162
+
163
+ There is a driver at `outturn/adapters/livekit.py`. It takes a session factory and one callable that reads your state and returns an Outcome, because the outcome does not live in LiveKit, it lives in whatever your own function tools wrote it to.
164
+
165
+ **Verified against livekit-agents 1.8.2.** Tested with the ollama.com cloud API (model `gemma4:31b`, OpenAI-compatible endpoint). Confirmed:
166
+
167
+ - A session starts with no room and no audio.
168
+ - State written by function tools persists across multiple `session.run()` calls on the same session, so multi-turn scenarios work as written.
169
+ - The assistant reply is a `ChatMessageEvent` in `result.events` with `item.content[0]` as the text.
170
+
171
+ Pin your own install to `livekit-agents==1.8.2` until you have tested against a newer version. This API is young and the `RunResult` shape has changed between releases.
172
+
173
+ ## In CI
174
+
175
+ ```bash
176
+ outturn scenarios/booking --adapter demo-booking --runs 10 --threshold 0.95
177
+ ```
178
+
179
+ Exits non zero if overall accuracy falls below the threshold. Every bug you fix becomes a scenario, so it can never come back silently. `--json` gives machine readable output for tracking accuracy over time.
180
+
181
+ ## The scenarios worth writing
182
+
183
+ The ones that break agents, roughly in order of how often they do:
184
+
185
+ - **Corrections mid call.** The customer changes their mind after the agent has already recorded something. This is where most outcomes go wrong, and the failure is usually a field that did not get updated alongside the one that did.
186
+ - **Reference without naming.** "Make it three" with no item named.
187
+ - **Cancellation.** Added, then removed. Do the derived values follow?
188
+ - **Confirmation.** Did the agent act on intent rather than on an actual yes? Booking a customer who was still thinking is a real and expensive failure.
189
+ - **Name collisions.** Two items or two services that sound alike over a phone line.
190
+ - **Illegal combinations.** Extra cheese on a coke. Should be rejected by schema, not accepted politely and discovered later.
191
+ - **Out of scope questions that are really orders.** "Do you sell X" is a customer trying to buy X.
192
+
193
+ ## What this does not test
194
+
195
+ outturn drives your agent through text. That is fast enough to run in CI on every commit, and it isolates the reasoning and outcome layer from the audio layer.
196
+
197
+ It does not test speech to text, endpointing or turn taking, and those are real sources of failure, particularly on telephony where audio is narrowband and degrades worst on exactly what matters here: names, numbers and proper nouns.
198
+
199
+ **So this measures whether your agent understands correctly, not whether it hears correctly.** Both matter. For the timing half of the picture, see [voice-latency-profiler](https://github.com/OsasDTEch/voice-latency-profiler).
200
+
201
+ ## Status
202
+
203
+ v0.1. The core works and is tested, 31 tests. Roadmap, roughly in order:
204
+
205
+ - [x] LiveKit driver verified against livekit-agents 1.8.2 (ollama.com cloud, `gemma4:31b`)
206
+ - [ ] Path assertions: which tools were called, with which arguments
207
+ - [ ] Pipecat driver
208
+ - [ ] Audio mode, TTS in and STT out, for end to end runs
209
+ - [ ] Accuracy tracked over time, so regressions show as a trend
210
+
211
+ `PRD.md` and `TRD.md` in this repo cover the reasoning and the internals.
212
+
213
+ This project supersedes [voice-agent-evals](https://github.com/OsasDTEch/voice-agent-evals), which asserted on carts only. An order is one kind of outcome among many, and the narrower version was useful to about a tenth of the agents worth testing.
214
+
215
+ ## Licence
216
+
217
+ MIT. Built by [Wisdom Omons](https://linkedin.com/in/omons-wisdom).
@@ -0,0 +1,5 @@
1
+ """outturn: did the agent produce the right outcome? As a number."""
2
+ from .models import Discrepancy, Entry, Kind, Outcome, Scenario, Turn
3
+
4
+ __version__ = "0.1.0"
5
+ __all__ = ["Outcome", "Entry", "Scenario", "Turn", "Discrepancy", "Kind", "__version__"]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,68 @@
1
+ """Adapter lookup.
2
+
3
+ Two ways to name an adapter:
4
+
5
+ --adapter demo-booking
6
+ one of the demo agents that ship with outturn
7
+
8
+ --adapter myproject.agents:MyAgent
9
+ anything importable. Your agent lives in your repo, not in this one.
10
+
11
+ The second form is the point. You should never have to edit a file inside
12
+ this package to test your own agent.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import importlib
17
+
18
+ from .base import Agent
19
+ from .demo_booking import DemoBookingAgent
20
+ from .demo_order import DemoOrderAgent
21
+
22
+ BUILTIN: dict[str, type] = {
23
+ DemoOrderAgent.name: DemoOrderAgent,
24
+ DemoBookingAgent.name: DemoBookingAgent,
25
+ }
26
+
27
+
28
+ class AdapterError(ValueError):
29
+ pass
30
+
31
+
32
+ def _load_path(spec: str) -> type:
33
+ """Import 'package.module:ClassName' or 'package.module.ClassName'."""
34
+ if ":" in spec:
35
+ module_name, _, attr = spec.partition(":")
36
+ else:
37
+ module_name, _, attr = spec.rpartition(".")
38
+ if not module_name or not attr:
39
+ raise AdapterError(
40
+ f"{spec!r} is not a known adapter and does not look like an import path. "
41
+ f"Use 'module:ClassName', or one of: {', '.join(sorted(BUILTIN))}"
42
+ )
43
+ try:
44
+ module = importlib.import_module(module_name)
45
+ except ImportError as e:
46
+ raise AdapterError(f"could not import {module_name!r}: {e}") from e
47
+ try:
48
+ return getattr(module, attr)
49
+ except AttributeError as e:
50
+ raise AdapterError(f"{module_name!r} has no attribute {attr!r}") from e
51
+
52
+
53
+ def get(spec: str) -> Agent:
54
+ """Resolve an adapter name or import path into an instance."""
55
+ cls = BUILTIN.get(spec) or _load_path(spec)
56
+
57
+ agent = cls() if isinstance(cls, type) else cls # a factory function is fine too
58
+
59
+ for method in ("reset", "send", "outcome"):
60
+ if not callable(getattr(agent, method, None)):
61
+ raise AdapterError(
62
+ f"{spec!r} is missing {method}(). An adapter needs reset(), send(text) "
63
+ f"and outcome()."
64
+ )
65
+ return agent
66
+
67
+
68
+ __all__ = ["Agent", "AdapterError", "BUILTIN", "get", "DemoOrderAgent", "DemoBookingAgent"]
@@ -0,0 +1,25 @@
1
+ """The adapter contract. Three methods.
2
+
3
+ reset() start a fresh conversation
4
+ send(text) one caller turn in, the agent's reply out
5
+ outcome() the structured result, right now
6
+
7
+ If your agent cannot hand back a structured outcome, outturn cannot help you,
8
+ and that is itself the finding. State that lives only inside the model's
9
+ context is not inspectable, and what is not inspectable is not testable.
10
+ Move the outcome into code and the tool works.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from typing import Protocol, runtime_checkable
15
+
16
+ from ..models import Outcome
17
+
18
+
19
+ @runtime_checkable
20
+ class Agent(Protocol):
21
+ name: str
22
+
23
+ def reset(self) -> None: ...
24
+ def send(self, text: str) -> str: ...
25
+ def outcome(self) -> Outcome: ...
@@ -0,0 +1,75 @@
1
+ """A deliberately imperfect appointment booking agent.
2
+
3
+ This one exists to prove the point of the whole rewrite: a booking has no
4
+ line items at all. It is a handful of scalar fields. The same engine handles
5
+ it without a single special case.
6
+
7
+ Its planted flaw is the one real booking agents actually have: it hears the
8
+ service and the day, and forgets to carry the duration across when the
9
+ customer changes their mind mid call.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import random
14
+ import re
15
+
16
+ from ..models import Outcome
17
+
18
+ SERVICES = {
19
+ "deep tissue massage": 60,
20
+ "swedish massage": 60,
21
+ "sports massage": 90,
22
+ "facial": 45,
23
+ }
24
+ _DAYS = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")
25
+
26
+
27
+ class DemoBookingAgent:
28
+ name = "demo-booking"
29
+
30
+ def __init__(self, forget_duration_rate: float = 0.4) -> None:
31
+ self.forget_duration_rate = forget_duration_rate
32
+ self._state: dict = {}
33
+
34
+ def reset(self) -> None:
35
+ self._state = {"confirmed": False}
36
+
37
+ def send(self, text: str) -> str:
38
+ t = text.lower().strip()
39
+ changed = False
40
+
41
+ for service, minutes in SERVICES.items():
42
+ if service in t or service.split()[0] in t:
43
+ switching = "service" in self._state and self._state["service"] != service
44
+ self._state["service"] = service
45
+ # the planted flaw: on a switch it sometimes keeps the old duration
46
+ if not switching or random.random() > self.forget_duration_rate:
47
+ self._state["duration_minutes"] = minutes
48
+ changed = True
49
+ break
50
+
51
+ m = re.search(r"\b(\d{1,2})[:.](\d{2})\s*(am|pm)?\b", t)
52
+ if m:
53
+ hour, minute, ampm = int(m.group(1)), m.group(2), m.group(3)
54
+ if ampm == "pm" and hour < 12:
55
+ hour += 12
56
+ self._state["time"] = f"{hour:02d}:{minute}"
57
+ changed = True
58
+
59
+ for day in _DAYS:
60
+ if day in t:
61
+ self._state["day"] = day
62
+ changed = True
63
+ break
64
+
65
+ if any(w in t for w in ("yes", "confirm", "book it", "that works", "go ahead")):
66
+ self._state["confirmed"] = True
67
+ return "Booked. You will get a confirmation by text."
68
+
69
+ if changed:
70
+ bits = [str(self._state.get(k)) for k in ("service", "day", "time") if self._state.get(k)]
71
+ return "Got it, " + ", ".join(bits) + ". Shall I book that?"
72
+ return "Sorry, I did not catch that."
73
+
74
+ def outcome(self) -> Outcome:
75
+ return Outcome(entries=(), fields=dict(self._state))