operonx.app¶
The application layer: what puts work into an Operon. Services listen
(Service), jobs loop over items (Job), and Application declares
both. Graphs never import
this package; the dependency runs one way.
Guides: Deployment, Jobs, The playground bridge, Evals.
Application¶
Application
¶
Application(
manifest: Union[Manifest, str],
*,
services: Sequence[ServeSpec] = (),
jobs: Sequence[Any] = (),
on_startup: Sequence[Any] = (),
root: Union[str, Path, None] = None,
src: Sequence[str] = ("src", "."),
resources: Optional[str] = "resources.yaml",
description: str = "",
trace: Optional[Sequence[Any]] = None,
)
See the module docstring.
Source code in operonx/app/application.py
Attributes¶
graphs
property
¶
Every graph the manifest names, once each, with who uses it; a door with variants contributes one graph per variant.
jobs
property
¶
The declared jobs, tracing where the application says, each recording under the project's jobs folder unless told otherwise.
Methods:¶
find
classmethod
¶
The nearest operonx.toml at or above start — or, when that
file says [project] app = "module:APP", the object it points at.
Source code in operonx/app/application.py
bootstrap
¶
Make the project importable and its resources resolvable. Once.
Source code in operonx/app/application.py
serve
¶
Boot every listener the manifest declares, and block. host= /
port= bind elsewhere than declared (port: one listener only).
Source code in operonx/app/application.py
asgi
¶
asgi(
*,
port: Optional[int] = None,
only: Optional[Sequence[str]] = None,
startup: bool = True,
) -> Any
One listener's ASGI app — for a process that runs uvicorn itself, or
a test client. By port, or the only one. startup=False leaves the
on_startup hooks — the application's and the services' — to
another process.
Source code in operonx/app/application.py
run
async
¶
Run a job once and return its record.
describe
¶
The three lists as plain data: what --list and the studio read.
Names of things only; a TOML application is described without
importing the project.
Source code in operonx/app/application.py
GraphRef
dataclass
¶
GraphRef(
name: str,
entry: str,
used_by: Tuple[str, ...] = tuple(),
bind: Dict[str, Any] = dict(),
graph: Any = None,
)
A graph the application knows: by name, by entry point, and by who uses it. Compiled on demand, never at load.
Methods:¶
compile
¶
An Operon for this graph, the way a served graph is compiled:
every unbound parameter becomes a runtime input.
Source code in operonx/app/application.py
Declaring it in Python¶
Service
¶
Service(
name: str,
listener: Listener,
*,
graph: Any = None,
app: Any = None,
session: Optional[str] = None,
max_inflight: Optional[int] = None,
concurrency: Optional[int] = None,
trace: Optional[Sequence[str]] = None,
variants: Optional[Mapping[str, Mapping[str, Any]]] = None,
on_session: Any = None,
on_close: Any = None,
on_startup: Sequence[Any] = (),
resume: Any = None,
description: str = "",
key_ops: Optional[Sequence[str]] = None,
playground: Any = None,
replay: bool = False,
input: Optional[str] = None,
**options: Any,
) -> ServeSpec
One endpoint, as :class:ServeSpec — the same record [[serve]]
parses to, with objects where the manifest has module:attr.
graph is a module-level @graph (variants bind its parameters), app an
ASGI app for an asgi listener. on_startup hooks run before the
listener accepts, in each of its worker processes and nowhere else — a
model warmed for the call workers is not warmed again for an admin
port. The door's inputs are the graph's own runtime parameters; the
door ops declare themselves with @op(door=...).
key_ops names the ops whose latency the team watches first (time
to first audio, the LLM call) — a dashboard pins them above the rest,
the way show_keys names the outputs that stand for an op.
playground is the codec the studio's playground drives this door
with (:class:operonx.app.play.Codec, or "module:attr") — needed
only when the door's protocol is its own; http and websocket doors have
built-in ones.
resume is the graph that continues a run this http door left
waiting for a human (an agent's approval): the door also answers
POST <path>/resume with it, with the door's own codec, hooks and
streaming. A websocket door resumes on its connection and takes none.
input="payload" hands a graph without doors the whole request body
as that one parameter, instead of filling parameters by the body's
fields: for a payload someone else shapes (a mail server's webhook).
replay=True records what clients send this door — text and JSON as
they are, audio and bytes only counted — on each run, so a real session
can be replayed in the playground later. Off by default: a script holds
what users typed, and lives as long as the run.
Source code in operonx/app/declare.py
178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 | |
websocket
¶
websocket(
path: str,
port: int = 8000,
host: str = "0.0.0.0",
workers: int = 1,
codec: Optional[str] = None,
) -> Listener
A websocket door: each text frame is one ingress item, decoded by
codec — "json" (the default) or "text". Bytes frames are
bytes. A frame the codec cannot read is answered with an error frame
and does not reach the graph.
Source code in operonx/app/declare.py
http
¶
http(
method: str,
path: str,
port: int = 8000,
host: str = "0.0.0.0",
workers: int = 1,
codec: Optional[str] = None,
) -> Listener
An HTTP door: the body is the one ingress item, decoded by codec
— "json" (the default) or "text". An empty body is None; a
body the codec cannot read is answered 400 and starts no run.
Source code in operonx/app/declare.py
asgi
¶
env
¶
${NAME:default} for Python: the variable's value, cast to the
default's type when the default is a number.
Source code in operonx/app/declare.py
The manifest¶
Manifest
dataclass
¶
Manifest(
project: Dict[str, Any],
serves: Tuple[ServeSpec, ...],
graphs: Tuple[GraphSpec, ...],
fixtures: Dict[str, Dict[str, Any]],
resources_overlay: Optional[str],
source: Optional[Path],
on_startup: Tuple[Any, ...] = (),
src: Tuple[str, ...] = (".",),
base: Optional[Path] = None,
tracing: Any = None,
queues: Tuple[Any, ...] = (),
jobs_dir: Optional[str] = None,
)
A parsed, validated operonx.toml.
Attributes¶
app_entry
property
¶
[project] app = "module:APP" — the application declared in
Python, which this file then only points at.
Methods:¶
listeners
¶
Endpoints grouped onto the servers that will carry them.
Source code in operonx/app/manifest.py
find
classmethod
¶
Walk up from start to the first operonx.toml.
Source code in operonx/app/manifest.py
ServeSpec
dataclass
¶
ServeSpec(
name: str,
kind: str,
graph: Any = "",
path: str = "/",
method: str = "POST",
host: str = "0.0.0.0",
port: int = 8000,
session: str = "per_request",
max_inflight: Optional[int] = None,
on_session: Any = None,
on_close: Any = None,
app: Any = None,
resume: Any = None,
description: str = "",
options: Dict[str, Any] = dict(),
variants: Dict[str, Dict[str, Any]] = dict(),
workers: int = 1,
on_startup: Tuple[Any, ...] = (),
trace_own: Optional[Tuple[Any, ...]] = None,
trace_from: str = "default",
)
One endpoint: a transport, a graph, and how runs are minted.
Attributes:
| Name | Type | Description |
|---|---|---|
name |
str
|
Identifies the endpoint in logs, metrics and
|
kind |
str
|
A registered transport name ( |
graph |
Any
|
|
session |
str
|
One of :data: |
max_inflight |
Optional[int]
|
Bound on undelivered items. Required for
:data: |
on_session |
Any
|
|
on_close |
Any
|
|
app |
Any
|
For |
resume |
Any
|
For an |
Attributes¶
listener
property
¶
Endpoints sharing this pair share one server.
Grouping by (host, port) rather than by a declared server name
keeps a second concept out of the manifest: a listener is only ever
an address, so let the address be the identity.
Methods:¶
resume_spec
¶
The door resume adds: POST <path>/resume running the
resume graph, named <name>.resume, with this door's codec,
hooks, trace and address. None when there is no resume graph.
Source code in operonx/app/manifest.py
Tracing¶
[tracing] in operonx.toml: which trace sinks are on, and the precedence
between it and trace= in Python. See Tracing.
tracing
¶
[tracing] in operonx.toml — which trace sinks are on, in one place.
::
[tracing]
sinks = ["local", "trace_langfuse:edupia", "trace_clickhouse:default"]
[tracing.services.call] # one service, overridden
sinks = ["local", "trace_langfuse:edupia"]
[tracing.jobs.backfill_call_logs]
sinks = [] # this job is not traced
The file says which sinks a run goes to; resources.yaml keeps how to
reach each one. "local" is the built-in local consumer (the one a job
records to when nothing is configured); any other entry is a resource key,
resolved through the hub exactly as trace=[...] entries are.
Precedence, most specific first:
[tracing.services.<name>]/[tracing.jobs.<name>];- the service's or job's own
trace=(Python, ortrace =on its[[serve]]/[[job]]block); [tracing] sinks;Application(trace=...)(or[project] trace);- the built-in default: a job records locally, a service traces nothing.
The toml is the operator's switch, so its per-service entry beats
everything; a setting on one service beats any project-wide one, wherever
it was written. sinks = [] is "not traced" at its level, never "not
set".
Every level resolves to one list handed to one engine, and the engine
hands its one WorkflowTrace to each consumer, so every sink of a run
sees the same trace id — the caller's ?trace_id= included.
Classes¶
Tracing
dataclass
¶
Tracing(
sinks: Optional[Tuple[str, ...]] = None,
services: Dict[str, Tuple[str, ...]] = dict(),
jobs: Dict[str, Tuple[str, ...]] = dict(),
)
A parsed [tracing] table. sinks is None when the table
does not set it — the levels below then decide.
Functions:¶
parse_tracing
¶
[tracing] as a :class:Tracing, or None when absent.
Every mistake raises here, naming the key: a typo such as sink =
must not read as "nothing configured" and quietly trace nowhere.
Source code in operonx/app/tracing.py
pick
¶
pick(
*,
overrides: Sequence[Tuple[Optional[Sequence[Any]], str]],
own: Optional[Sequence[Any]],
own_label: str,
tracing: Optional[Tracing],
app: Optional[Sequence[Any]],
) -> Tuple[Optional[List[Any]], str]
The sinks one service or job uses, and the level they came from.
overrides are the [tracing.*.<name>] lists that name it, most
specific first (a step's own entry, then its job of steps').
None at a level means "not set there"; [] is a choice. Returns
(None, "default") when no level says anything.
Source code in operonx/app/tracing.py
settle_serves
¶
settle_serves(
serves: Sequence[ServeSpec],
app: Optional[Sequence[Any]],
tracing: Optional[Tracing],
) -> Tuple[ServeSpec, ...]
Each service with options["trace"] set to what it will use.
A pure function of what was declared (trace_own), so applying it
again — the file's [tracing] after a Python application already
took its own default — gives the same answer as applying it once.
Source code in operonx/app/tracing.py
check_names
¶
check_names(
tracing: Optional[Tracing],
where: str,
services: Optional[Sequence[ServeSpec]],
jobs: Optional[Iterable[str]],
) -> None
Every [tracing.services.<n>] names a service with runs, every
[tracing.jobs.<n>] a job. None skips that check — a job of steps'
members, say, are not known until it is imported.
Source code in operonx/app/tracing.py
check_sinks
¶
Every trace sink these services and jobs name is in the resource
hub — checked as they start, so a sink missing from
resources.yaml stops the start naming the key and the level
that chose it, instead of surfacing as a stack trace from inside an
engine (or, in a pooled worker, in a log nobody reads).
Source code in operonx/app/tracing.py
Services¶
compile_graph
¶
compile_graph(
entry: Any,
*,
bind: Optional[Dict[str, Any]] = None,
trace: Any = None,
concurrency: Optional[int] = None,
where: str = "graph",
) -> Any
An Operon from a module:attr entry point, the way anything
declared in the manifest is compiled.
Every parameter of the graph factory becomes a runtime input port. A
graph compiled without declaring them keeps the literal defaults it
was built with, which is the failure mode where a deep op holds
None forever and every call fails on it.
With bind — a variant of one door — the named parameters of the
@graph are fixed at build time and the rest stay runtime inputs.
Each bound value that reads as module:attr is loaded; anything
else is a literal; the decorator passes a static value into the body
as-is. A plain function that builds and returns a graph is refused:
graphs are defined at module level (guide 05).
Source code in operonx/app/serve/app.py
ingress
async
¶
Yield each item the session receives, one per item.
transient=True is what makes per-item dispatch affordable: the
item is delivered to its consumer and the cell released with the
context, so a long-lived session does not retain everything that ever
crossed it. Without it a call retains every packet it received —
measured at 23 KB per item before the fix.
The generator ending is end-of-input, and it is the only such signal. Downstream ops drain and the run completes on its own; nothing outside cancels it. That is deliberate — work that has to happen after the peer has gone, like writing a call record, only survives if the run is allowed to finish.
items is an escape hatch for tests and for direct
engine.start(): an iterable there is used when no session exists.
Source code in operonx/app/serve/ops.py
egress
async
¶
Write one item back to the session that minted this run.
A missing session is not an error — the graph is simply not being served — but it is reported, because a graph that believes it is replying to someone and is not would otherwise look identical to one that succeeded.
Source code in operonx/app/serve/ops.py
current_session
¶
The session that minted the run this op is running inside.
Reads the run's state through the same ContextVar SCRATCH uses, so
it is bound inside an op body and unbound everywhere else. Returns
None when the graph was started directly rather than served —
which is what makes an ingress-bearing graph still testable by
calling engine.start() with ordinary inputs.
Source code in operonx/app/serve/protocol.py
serve_session
async
¶
serve_session(
engine: Any,
session: Session,
request: Optional[RunRequest] = None,
metadata: Optional[Dict[str, Any]] = None,
timeout: Optional[float] = None,
on_start: Optional[Callable[[Any], None]] = None,
reply: bool = False,
) -> Any
Run engine for one session, and return its handle when it ends.
The session is seeded into the run's scratch under a reserved key, so
ingress and egress resolve it from the run rather than from a
string the author had to remember.
The run is always allowed to finish. A peer that disappears surfaces as
the session's recv ending, which ends ingress, which drains the
graph — one signal travelling one way, instead of a race between a
cancel and a teardown.
metadata is merged onto the run's trace before it ends, so every
consumer sees it: a job tags its runs here (job, job_run,
key and the same three as tags) and a transport could name
its route the same way. tags extend the trace's list; other keys
are set.
timeout is a deadline in seconds for the whole run. Past it the
run is cancelled and :class:RunTimeout is raised — the one case in
which a transport cancels the run it minted, and it does so because
the caller asked for exactly that. A job's item_timeout is this.
on_start is called with the run's handle as soon as the run has
started, before it is drained — for a caller that follows the run
while it happens (the playground watches handle.trace grow, so
a canvas can light each op as it finishes). A callback that raises
is logged; it never stops the run.
reply=True is the doorless shape: when the run ends without an
unhandled error, its outputs (without the $ keys) are sent to the
session as its one reply, before the session is closed. A failed run
sends nothing.
Source code in operonx/app/serve/runner.py
Session
¶
Bases: Protocol
One connection, for as long as it lives.
recv ending is the only end-of-input signal, and a transport
never cancels the run it minted. That is not a new rule — it is what
the callbot already relies on: terminal_events writes the CRM record
after the caller has hung up, so a framework that killed the run on
disconnect would silently lose the record for every completed call.
A transport wanting cancellation cancels the run explicitly.
RunRequest
dataclass
¶
RunRequest(
inputs: Dict[str, Any] = dict(),
scratch: Dict[str, Any] = dict(),
trace_id: Optional[str] = None,
request_id: Optional[str] = None,
user_id: Optional[str] = None,
session_id: Optional[str] = None,
variant: Optional[str] = None,
)
A connection, turned into the arguments for one run.
Returned by a project's on_session hook. Turning
?call_id=&customer_info= into real inputs, refusing an unknown
agent, hitting a customer store — that is project logic, and no amount
of TOML will express it. One declared hook is the honest answer;
frameworks that pretended config was enough grew a plugin system
instead.
Returning None from the hook refuses the connection.
variant names one of the door's [serve.variants] — the compiled
graph this session runs. Required when the door declares variants,
refused when it names one the door does not have.
BoundedSession
¶
A Session whose inbound side has a ceiling.
The bound lives here rather than in the ingress op, because this is where items enter. When the buffer is full the transport stops reading its socket, and for TCP that is the connection's own flow control — correct, free, and applied before anything is allocated inside the graph.
It is mandatory for stream transports rather than defaulted, because operonx guards concurrency and — since transient ports — retention, but it has never guarded volume. An unbounded queue behind a network socket is precisely how this project's Channels came to exist, and a ceiling nobody chose is how one gets shipped again.
Subclasses implement :meth:_send. Anything that overflows is counted
and reported, never dropped in silence.
Source code in operonx/app/serve/protocol.py
Methods:¶
feed
async
¶
feed_nowait
¶
Push without waiting. False means the bound was hit.
Counted rather than silent — a transport that drops has to be able to say how much.
Source code in operonx/app/serve/protocol.py
end_input
¶
No more inbound items. recv drains what is left, then stops.
Source code in operonx/app/serve/protocol.py
send
async
¶
Write one item out. False means it did not reach the peer.
A bool rather than None because a caller that paces audio has
to report what actually landed: the callbot's play_frame sends
forty-odd chunks per spoken frame and its audit line — chunks, ok,
fail, the indices that failed — is the row you read when audio
sounds wrong. Swallowing per-item failure here would have forced
that op to keep talking to the socket directly, which is the one
coupling the transport interface exists to remove.
Failure is reported, never raised: a peer that goes away mid-reply is ordinary, and one lost frame must not tear down a run that still has a record to write.
Source code in operonx/app/serve/protocol.py
run_failed
async
¶
The run this session minted ended with an error.
Called by the serve runner after the run, before on_close. A
transport whose peer would otherwise wait in silence tells it
here; the default does nothing, because a transport such as HTTP
answers from its own endpoint (a 500). Never pass the error
text on: it is a traceback, and it stays in the log and the trace.
Source code in operonx/app/serve/protocol.py
Evals¶
Eval
¶
Eval(
name: str,
*,
graph: Any,
dataset: Any,
evaluators: Sequence[Any] = (),
threshold: Optional[float] = None,
repeats: int = 1,
cluster: Optional[str] = None,
gate: Optional[Gate] = None,
root: Union[str, Path, None] = None,
record_dir: Union[str, Path, None] = None,
scores: Any = None,
scores_timeout: float = 10.0,
variant: Optional[str] = None,
judge_cache: bool = True,
judge_concurrency: int = 8,
**kwargs: Any,
)
Bases: Job
A dataset, a graph, evaluators — run as a job with origin=eval.
Everything but the arguments below is a :class:Job argument
(concurrency, timeout, inputs, input,
trace…).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dataset
|
Any
|
A :class: |
required |
evaluators
|
Sequence[Any]
|
Functions judging each case (see the module docstring). |
()
|
threshold
|
Optional[float]
|
With it, the run fails when the pass rate is under it;
without, it fails when any case fails. With a |
None
|
repeats
|
int
|
Runs per case. Above 1, each case is |
1
|
cluster
|
Optional[str]
|
The case field that groups cases (one scenario's
variants, one conversation's turns) for the clustered SE.
Without it a case's own |
None
|
gate
|
Optional[Gate]
|
A :class: |
None
|
root
|
Union[str, Path, None]
|
The project root, where the fingerprint asks git for the commit (the manifest's directory; else the working directory). |
None
|
scores
|
Any
|
Where the experiment, its items and every check's score
are written as the run goes: a
:class: |
None
|
scores_timeout
|
float
|
How long the run waits, at its end, for the store
to take what is queued. What it did not take is counted and
logged; the job record still holds every verdict, and
:func: |
10.0
|
variant
|
Optional[str]
|
A free label for what this experiment tries ("prompt v7"), kept on its record and its experiment row. |
None
|
judge_cache
|
bool
|
With a score store, a judge's verdict is kept there,
keyed by its version and what it was shown, and an unchanged
case is not judged again (no call). |
True
|
judge_concurrency
|
int
|
Judge runs in flight at once, over all cases. |
8
|
Source code in operonx/app/evals/job.py
141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 | |
Methods:¶
begin
¶
The runner opened the record: the judges get their context (the
eval's sinks, this run's ids, the cache); with scores=, the
experiment's running row goes to the store before any case.
Source code in operonx/app/evals/job.py
run
async
¶
Run the eval once; with scores=, then wait (up to
scores_timeout) for the store to take the experiment.
Source code in operonx/app/evals/job.py
judge
async
¶
After a case's run: its evaluators, and the verdict on its record.
Source code in operonx/app/evals/job.py
summarize
¶
What run.json says about the eval, and the run's status.
Without a gate: failed when a trial failed, or with a threshold,
when the rate is under it (1.9.0, unchanged). With one: whatever
the gate decides (see :mod:operonx.app.evals.gate).
Source code in operonx/app/evals/job.py
rescore
async
¶
rescore(
run_id: str,
evaluators: Optional[Sequence[Any]] = None,
*,
store: Any = None,
dataset: Any = None,
scores: Any = None,
) -> Any
Judge this eval's recorded run run_id again without running the
graph (:func:~operonx.app.evals.rescore). Without
evaluators, the eval's own — its judges left out, and named in
skipped. store is the run store its traces went to; scores
a score store the new scores go to.
Source code in operonx/app/evals/job.py
Dataset
¶
Dataset(
path: Union[str, Path],
*,
split: Optional[str] = None,
tags: Optional[Sequence[str]] = None,
ids: Optional[Sequence[str]] = None,
sample: Optional[int] = None,
)
A JSONL file of cases. Reading is lazy; adding appends and dedupes.
:meth:select gives a view of some of the cases (a split, tags, ids,
a stable sample) — the same file, read through the selection.
Source code in operonx/app/evals/dataset.py
Attributes¶
selection
property
¶
What :meth:select chose, as data ({} for every case).
Methods:¶
select
¶
select(
*,
split: Optional[str] = None,
tags: Optional[Sequence[str]] = None,
ids: Optional[Sequence[str]] = None,
sample: Optional[int] = None,
) -> "Dataset"
The cases of split, carrying any of tags, among ids (each
must exist), then the sample of them with the smallest
sha256(id) — the same N on every machine and every run. A
criterion given replaces this view's own; one not given is kept.
Source code in operonx/app/evals/dataset.py
all_rows
¶
Every case in the file, archived ones too, whatever the selection.
Source code in operonx/app/evals/dataset.py
rows
¶
The selected active cases, in file order (archived ones are left out).
Source code in operonx/app/evals/dataset.py
problems
¶
What is wrong with the file, each with its line (0: the file).
Source code in operonx/app/evals/dataset.py
add
¶
Append rows whose id is not already there; returns the ids added.
Source code in operonx/app/evals/dataset.py
update
¶
Change one case in place and return it as it now reads.
changes maps keys of :data:EDITABLE to their new values; None
removes the key ("status": "active" too: active is the default).
The file is rewritten through a temporary file beside it, the
case's line changed and every other line kept byte for byte, so a
diff shows exactly the edit. A change that would make the case
malformed, an unknown or duplicated case, and a file another writer
changed meanwhile are refused with the file untouched.
Source code in operonx/app/evals/dataset.py
210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 | |
exact
¶
Passes when the output (or its field, dotted) equals expected.
Source code in operonx/app/evals/evaluators.py
contains
¶
Passes when the output text contains every needle — the given ones,
else expected (a string or a list of strings).
Source code in operonx/app/evals/evaluators.py
fuzzy
¶
Similarity of the output text to expected (0–1); passes at threshold.
Source code in operonx/app/evals/evaluators.py
json_match
¶
Passes when the output object agrees with expected on keys
(every key expected has, when none are named); score is the share
that agree.
Source code in operonx/app/evals/evaluators.py
llm_judge
¶
An LLM grades the case against rubric: {passed, score, reason}
parsed from its JSON answer, with the call's cost and usage kept on
the verdict. resource is an llm: key (or a bare name) the hub
resolves. The 1.9.0 judge, now a traced, versioned, cached
:class:Judge; :func:judge (PASS/FAIL with a reason) is the one
to reach for.
Source code in operonx/app/evals/judges.py
Gate
dataclass
¶
Gate(
threshold: PerMetric = None,
baseline: Optional[str] = None,
tolerance: PerMetric = None,
metrics: Optional[Sequence[str]] = None,
must_pass_tag: Optional[str] = "critical",
max_error_rate: float = 0.05,
alpha: float = 0.05,
strict: bool = False,
bootstrap: int = 2000,
)
When an eval run passes, as data. See the module docstring.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
threshold
|
PerMetric
|
Absolute floor(s): a number for the |
None
|
baseline
|
Optional[str]
|
What to compare against: |
None
|
tolerance
|
PerMetric
|
How large a drop matters, per gated metric (a number,
or |
None
|
metrics
|
Optional[Sequence[str]]
|
The metrics compared against the baseline that can gate
(default |
None
|
must_pass_tag
|
Optional[str]
|
Cases with this tag are a deterministic tier (see
the module docstring). |
'critical'
|
max_error_rate
|
float
|
The share of trials that may error (failed or
timed out) before the run is an infrastructure |
0.05
|
alpha
|
float
|
The significance level, after Holm adjustment. |
0.05
|
strict
|
bool
|
An |
False
|
bootstrap
|
int
|
Bootstrap replicates for each comparison's CI. |
2000
|
compare
¶
compare — any two experiments, paired, the way the gate compares them.
The gate compares a run with its baseline as the run finishes; this is the
same comparison (:func:~operonx.app.evals.gate.compare_runs) for any two
experiments after the fact — from records or the score store::
a = load_experiment("20261004T101500-000001", store=store)
b = load_experiment("20261004T113000-000002", store=store)
got = compare(a, b, tolerance=0.03)
got["verdict"], got["comparison"]["tests"][0]["diff"]
Without a tolerance every metric is reported — difference, interval,
p-value — and nothing is judged: a verdict needs someone to have said
how large a drop matters (D15).
Classes¶
Functions:¶
compare
¶
compare(
a: ExperimentData,
b: ExperimentData,
*,
tolerance: PerMetric = None,
metrics: Optional[Sequence[str]] = None,
alpha: float = 0.05,
strict: bool = False,
bootstrap: int = 2000,
) -> Dict[str, Any]
b against a (the baseline), on the cases both hold unchanged.
Returns {"baseline", "candidate", "verdict", "exit_code",
"reasons", "warnings", "comparison"}; verdict and exit_code
are None without a tolerance. metrics are the gated ones
(default ["pass"]; Holm-adjusted), the rest exploratory (BH).
Source code in operonx/app/evals/compare.py
calibrate
¶
The noise floor and the power of an eval — evidence before a tolerance.
calibrate. Run the same code k times (an A/A test) and measure how
much the numbers move on their own. Each case passes with its own
probability p_i, estimated from its k·r trials; a case that passed every
trial is simulated as deterministic, so more runs see more of the rare
flakes. The tolerance the gate needs is then measured through the gate:
pairs of synthetic A/A runs drawn from those p_i are compared by
:func:~operonx.app.evals.gate.compare_runs — the same interval, McNemar
or bootstrap, clusters and all — and a gated metric passes exactly when
ci_lo ≥ −tolerance. The tolerance at which 95% of A/A comparisons
pass is the 95th percentile of −ci_lo. It includes the case-sampling
uncertainty the interval carries, not just the flakes: 40 cases that
always pass still cannot rule out a 9-point drop. The table gives it for
r = 1, 2, 3, 5 repeats (and the configured r)::
got = calibrate([exp1, exp2, exp3], target=0.03)
got["rows"], got["recommended_repeats"], got["notes"]
power. :func:~operonx.app.evals.stats.paired_sample_size with the
discordance two experiments of the eval actually show
(:func:discordance).
Classes¶
Functions:¶
calibrate
¶
calibrate(
experiments: Sequence[ExperimentData],
*,
target: Optional[float] = None,
alpha: float = 0.05,
repeats: Sequence[int] = DEFAULT_REPEATS,
simulations: int = 200,
bootstrap: int = 2000,
) -> Dict[str, Any]
The A/A noise floor of experiments — runs of one eval on one commit — and the tolerance the gate needs at each number of repeats.
target is the tolerance wanted (the gate's): the recommendation is the fewest repeats that reach it. See the module docstring.
Source code in operonx/app/evals/calibrate.py
68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 | |
discordance
¶
The share of the cases two experiments share (unchanged) on which
they differ — mean |s_B − s_A| of the pass share, which for one
trial per case is the McNemar discordance (b + c) / n. Returns
(p_d, n).
Source code in operonx/app/evals/calibrate.py
load_experiment
¶
load_experiment(
ref: Union[str, Path, JobRun],
*,
store: Optional[ScoreStore] = None,
record_dirs: Iterable[Union[str, Path]] = (),
) -> ExperimentData
An experiment by ref: a :class:JobRun, a record directory, or an
id — looked up under record_dirs first (<dir>/<eval>/<id>: a
record holds the expected values and every verdict field), then in
store.
Source code in operonx/app/evals/experiments.py
ExperimentData
dataclass
¶
ExperimentData(
experiment_id: str,
eval: str,
status: str,
started: Optional[str],
ended: Optional[str],
summary: Dict[str, Any],
items: List[Dict[str, Any]] = list(),
source: str = "",
path: Optional[Path] = None,
)
One experiment: summary in run.json["eval"]'s shape (counts,
metrics, gate, fingerprint, reliability…), and one
item per trial — key, case, repeat, status,
passed, checks (name → {passed, score?, reason?, …}),
error, output, expected (records only), tags,
cluster, case_hash, trace_id, ms, cost_usd.
source says where it was read: record <path> or store.
Methods:¶
trials
¶
The items as the statistics read them (gate.outcomes).
Source code in operonx/app/evals/experiments.py
outcomes
¶
from_run
classmethod
¶
An eval's job record.
Source code in operonx/app/evals/experiments.py
from_experiment
classmethod
¶
One store row as an experiment with its summary and no items — what a list shows, read without the items and scores.
Source code in operonx/app/evals/experiments.py
from_store
classmethod
¶
An experiment's rows in store: the experiment, its items, and
each item's scores as its checks. None when it is not there.
Source code in operonx/app/evals/experiments.py
TraceView
¶
TraceView(
trace_id: str,
*,
rows: Optional[Sequence[Mapping[str, Any]]] = None,
meta: Optional[Mapping[str, Any]] = None,
trace: Any = None,
)
See the module docstring. Build one with :meth:from_trace,
:meth:from_rows, :meth:from_record or :meth:from_store.
Source code in operonx/app/evals/traceview.py
Attributes¶
counted
property
¶
The executions that count, in start order: every row but a retried attempt and the steps recorded under one.
input
property
¶
What came into the run: the first op's inputs at the root, or —
when that op takes none, a door's ingress or a source — what it put
out. None for a run with no ops.
output
property
¶
What the run answered: the last op's outputs at the root.
None for a run with no ops.
summary
property
¶
The run store's summary of these rows (its totals, its counts).
cost_usd
property
¶
Every priced call's cost; None when nothing was priced.
Methods:¶
from_trace
classmethod
¶
from_rows
classmethod
¶
from_rows(
rows: Sequence[Mapping[str, Any]], meta: Optional[Mapping[str, Any]] = None
) -> "TraceView"
A stored run: its rows (the shape nodes.jsonl holds) and its
meta.json (trace_id, metadata, duration_ms, …).
Source code in operonx/app/evals/traceview.py
from_record
classmethod
¶
A :class:~operonx.telemetry.runs.RunRecord (store.get_run).
Source code in operonx/app/evals/traceview.py
from_store
classmethod
¶
The run trace_id from a run store.
Source code in operonx/app/evals/traceview.py
ops
¶
ops(
name: Optional[str] = None,
*,
type: Optional[str] = None,
under: Optional[str] = None,
status: Optional[str] = None,
) -> List[OpRow]
Executions that count (:attr:counted) matching every filter
given. name is an op's name or a dotted tail of its full name
("handle.classify"); under is an enclosing subgraph's name
(the root's name — the variable that held the engine — never
matches).
Source code in operonx/app/evals/traceview.py
path
¶
path(
*,
types: Optional[Sequence[str]] = None,
collapse: bool = False,
children: bool = False,
) -> List[str]
Op names in start order, of the executions that count. Without
types, every execution but routing and containers (branch,
graph); with it, only those types. collapse merges
consecutive repeats (a generator's yields). children adds the
steps ops recorded with child(), in their start order.
Source code in operonx/app/evals/traceview.py
llm_calls
¶
Executions that report cost_usd — LLM calls, as the run
store counts them.
tool_calls
¶
Every tool call the counted LLM calls asked for, in order, with the tool message an op returned for it when one did.
Source code in operonx/app/evals/traceview.py
errors
¶
as_text
¶
The run as a model reads it (a judge's trace_summary): one
line per step of :meth:path — name, type, status, time, and its
outputs clipped to one line — cut to limit characters.
Source code in operonx/app/evals/traceview.py
OpRow
dataclass
¶
OpRow(
op_id: str,
op_name: str,
op_full_name: str,
op_type: str,
ctx: Tuple[str, ...],
inputs: Any,
outputs: Any,
status: str,
error: Optional[str],
start: float,
duration_ms: float,
is_yield: bool = False,
start_time: float = 0.0,
attempt: int = 1,
attrs: Mapping[str, Any] = dict(),
)
One execution of one op. start is wall-clock epoch seconds;
cost_usd is what the execution reported (None: it reported
no price, or is not an LLM call — priced says which).
ToolCall
dataclass
¶
ToolCall(
name: str,
args: Any = dict(),
id: Optional[str] = None,
op_id: Optional[str] = None,
result: Any = None,
status: Optional[str] = None,
)
A tool call an LLM made: its name and arguments (a dict, or the raw
text when it was not JSON), the call id, the LLM execution that made
it, and — when an op returned the tool message for that id (the agents'
dispatch does) — its result and status.
trajectory
¶
Evaluators over the run, not just its output: the path, the tool calls, one op's output, and the budget.
Each takes the case's trace (a :class:~.traceview.TraceView)::
evaluators=[
trajectory.ops(["classify", "lookup_order", "reply"], mode="strict"),
trajectory.tool_calls([{"name": "lookup", "args": {"order_id": "42"}}],
mode="superset", args="subset"),
trajectory.op_output("classify", exact("kind")),
budget(ms=1500, cost_usd=0.002, llm_calls=2),
]
Modes (AgentEvals'): strict — the same calls in the same order;
unordered — the same calls in any order; subset — nothing beyond
the reference (every actual call matches a distinct reference call);
superset — at least the reference (every reference call matches a
distinct actual call). Repeats count: a reference with one a is not a
superset of a, a's needs twice. Matching is a maximum matching, so a
loose reference entry is never used up by the call a stricter one needed.
Arguments of a tool call: exact — equal; subset — every
argument the reference gives is in the call with the same value (the call
may have more); ignore — names only. A reference entry may be a bare
name, or omit args: it then matches on the name alone.
Without a reference, the case's trajectory gives it
({"ops": [...], "tool_calls": [{"name", "args"}]}); a case with
neither is an error on that case, never a silent pass. The score is the
share of the reference matched (subset: of the actual calls).
Functions:¶
ops
¶
ops(
reference: Optional[Sequence[str]] = None,
*,
mode: str = "strict",
types: Optional[Sequence[str]] = None,
collapse: bool = False,
name: Optional[str] = None,
) -> Callable
The op path (trace.path(types=…, collapse=…)) against reference.
Source code in operonx/app/evals/trajectory.py
tool_calls
¶
tool_calls(
reference: Optional[Sequence[Any]] = None,
*,
mode: str = "strict",
args: str = "exact",
name: Optional[str] = None,
) -> Callable
The tool calls the LLM calls made (trace.tool_calls()) against
reference: names, and arguments as args says.
Source code in operonx/app/evals/trajectory.py
op_output
¶
check (any evaluator) on one op's outputs instead of the case's:
output is that execution's outputs (at="first" or "last"),
and the verdict's op is its op_id. An op that never ran fails.
Source code in operonx/app/evals/trajectory.py
budget
¶
budget(
ms: Optional[float] = None,
cost_usd: Optional[float] = None,
tokens: Optional[int] = None,
llm_calls: Optional[int] = None,
*,
name: str = "budget",
) -> Callable
Limits on the case's run, each inclusive: ms (the graph run's
duration), cost_usd, tokens (in + out), llm_calls. A cost
limit over a run with unpriced calls fails: that cost is unknown.
Source code in operonx/app/evals/trajectory.py
rescore
async
¶
rescore(
run: Union[JobRun, str, Path],
evaluators: Sequence[Any],
*,
store: Any = None,
dataset: Any = None,
scores: Any = None,
) -> Rescored
Judge run (a :class:JobRun, or its directory) again with
evaluators. store is the run store its traces went to — needed
when an evaluator reads trace; dataset overrides the dataset
path the run recorded; scores (a ScoreStore) receives the new
scores, one per check per judged run, ids by trace and evaluator
version — beside the experiment's own, never over them.
Source code in operonx/app/evals/rescoring.py
Rescored
dataclass
¶
Rescored(
run_id: str,
eval: str,
verdicts: Dict[str, Dict[str, Any]],
summary: Dict[str, Any],
skipped: List[str] = list(),
)
A rescore's outcome: the run it judged again, each item's new
verdict (by item key), the summary numbers run.json["eval"] would
hold for them, and the evaluators left out (an eval's judges).
publish
¶
An eval run as ScoreStore rows — written live, or published from its record.
One set of converters turns a job record into an
:class:~operonx.telemetry.scores.Experiment, its
:class:~operonx.telemetry.scores.ExperimentItem rows and one
:class:~operonx.telemetry.scores.Score per check per item. An eval with
scores= sends them while it runs (through :class:ScoreWriter, so a
slow or dead store costs the run nothing); :func:publish sends a
recorded run — one whose store was down, or one run before the store
existed. Both use the same converters on the same record, and score ids
are derived from what was judged, so publishing what was already written
writes nothing new.
Classes¶
ScoreWriter
¶
A ScoreStore behind a :class:BackgroundWriter: :meth:submit
queues a row and returns; one thread writes batches, retries, and past
that drops and counts. :meth:finish waits up to a timeout, stops,
and says what was lost.
Source code in operonx/app/evals/publish.py
Functions:¶
experiment_of
¶
A job record (finished or just started) as its experiment row.
Source code in operonx/app/evals/publish.py
item_of
¶
One recorded item as its row; None for one with no verdict
(skipped on resume: it belongs to the run that judged it).
Source code in operonx/app/evals/publish.py
check_score
¶
check_score(
score_name: str,
check: Mapping[str, Any],
*,
source: str,
evaluator_version: str,
**ids: Any,
) -> Score
One check's verdict as a score: numeric when it gave a score.
Source code in operonx/app/evals/publish.py
scores_of
¶
One item's checks as scores. run_meta is the record's (or, live,
the eval's describe() with its fingerprint): evaluator versions and
which checks are judges come from it.
Source code in operonx/app/evals/publish.py
publish
¶
Write a recorded eval run (a :class:JobRun or its directory) to
store: its experiment, items and scores. Safe to repeat.
Source code in operonx/app/evals/publish.py
fingerprint
¶
An experiment's identity: what produced its numbers.
Two eval runs are comparable when they ran the same cases with the same evaluators; the difference between them is meant to be the system under test. The fingerprint records each of those, as short hashes::
code_version, version_dirty the git commit at the eval's root, and
whether the tree had changes no commit holds
graph_hash the graph's topology, literal params and
inline prompts, and each op's source
config_hash the resolved resources its ops use (model,
temperature, endpoint) — secrets left out
models the models those resources name (what a
judge of the same model would favour)
dataset_version the cases, sorted by id
evaluators, evaluators_hash each evaluator's version, and all of them
operonx_version the engine
Every hash is sha256 over canonical JSON (sorted keys, no whitespace), cut to 12 hex characters. Nothing here runs per case.
Functions:¶
fingerprint
¶
fingerprint(
*,
graph: Any,
rows: Sequence[Mapping],
evaluators: Mapping[str, Any],
root: Union[str, Path, None] = None,
code: Optional[Mapping[str, Any]] = None,
) -> Dict[str, Any]
The fingerprint of an experiment (see the module docstring).
graph is the compiled root GraphOp; evaluators maps each
evaluator's name to it; root is where git is asked for the commit,
unless code already holds what origin.code_version said (an
eval asks git in the background, while its cases run). A graph that
cannot serialize (a cycle-rewritten loop refuses to) has
graph_hash, config_hash and models None and says why.
Source code in operonx/app/evals/fingerprint.py
case_hash
¶
What a case asks and expects: an edited expected is a new case
even though its id stayed.
dataset_version
¶
evaluator_version
¶
An evaluator's eval_version when it declares one; else a hash
of its source and of what its closure and defaults hold — so
contains("a") and contains("b") differ, and an llm_judge
whose rubric changed is a new version. An @op is its body.
Source code in operonx/app/evals/fingerprint.py
stats
¶
The statistics an eval reports and gates on, in pure Python.
Core operonx has no numpy, and none of this needs it: every function here
works on plain lists of floats and is deterministic — the bootstrap takes
a seed, and :func:seed_of derives one from text, never from hash()
(which Python salts per process).
What each answers:
- :func:
wilson— a pass rate's 95% interval that behaves at 0, 1 and small n (45/50 → [0.786, 0.957]). - :func:
mean_se/ :func:clustered_se— the standard error of a mean, naive or with cases grouped (turns of one conversation, one scenario's variants), which can be several times the naive one. - :func:
estimate— picks the right one of the three for a metric. - :func:
pass_hat_k— the chance that k trials of a case all pass. - :func:
mcnemar/ :func:newcombe_paired— the exact paired test and the paired interval for a binary check. - :func:
paired_bootstrap— a CI and p for the mean paired difference, resampling cases or whole clusters. - :func:
compare_paired— picks McNemar or the bootstrap for one metric. - :func:
holm/ :func:benjamini_hochberg— adjusted p-values for several metrics at once (gated, and exploratory). - :func:
paired_sample_size/ :func:detectable_drop— how many cases a drop needs, and what drop a number of cases can see.
Functions:¶
wilson
¶
The Wilson score interval for successes / n.
Source code in operonx/app/evals/stats.py
estimate
¶
estimate(
values: Sequence[float],
clusters: Optional[Sequence[Any]] = None,
*,
confidence: float = 0.95,
bounds: Optional[Tuple[float, float]] = None,
) -> Estimate
The mean of per-case values with a confidence interval.
Grouped cases (some cluster holds more than one) → the clustered SE; 0/1 values otherwise → Wilson; anything else → the CLT. bounds clips a normal interval to the values' range (a pass rate's [0, 1]).
Source code in operonx/app/evals/stats.py
clustered_se
¶
The cluster-robust standard error of the mean:
sqrt(Σ_c (Σ_{i∈c} (s_i − s̄))²) / n.
Cases in one cluster move together, so they carry less information than as many independent cases; this is the SE that says so.
Source code in operonx/app/evals/stats.py
pass_hat_k
¶
pass^k: the chance that k trials drawn from a case's trials
all pass, given passes of them did — C(c, k) / C(n, k), the
unbiased estimate (τ-bench). 4 passes of 5 → pass^3 = 0.4.
Source code in operonx/app/evals/stats.py
mcnemar
¶
The exact two-sided McNemar test on the discordant pairs: b
cases went pass → fail, c fail → pass.
p = min(1, 2·P(X ≤ min(b, c))) with X ~ Bin(b + c, ½).
Source code in operonx/app/evals/stats.py
newcombe_paired
¶
newcombe_paired(
both: int,
went_bad: int,
went_good: int,
neither: int,
confidence: float = 0.95,
) -> Tuple[float, float]
The CI for B's pass rate minus A's on the same cases (Newcombe 1998, method 10: Wilson intervals for each rate, joined by the correlation of the two runs). both passed in A and B, went_bad passed only in A, went_good only in B, neither in neither.
Unlike a bootstrap it stays honest with few discordant cases: three cases that agree in both runs give about ±56 points, not [0, 0]. A positive correlation is shrunk by n/2 first (Newcombe's correction): without it, when most cases always pass or always fail, the interval covered a 1.5-point drop only 81% of the time at n = 40 (measured); with it, 99.9%.
Source code in operonx/app/evals/stats.py
paired_bootstrap
¶
paired_bootstrap(
diffs: Sequence[float],
clusters: Optional[Sequence[Any]] = None,
*,
replicates: int = 2000,
seed: int = 0,
confidence: float = 0.95,
) -> Tuple[float, float, float]
(ci_lo, ci_hi, p) for the mean of per-case differences.
Resamples cases, or whole clusters when clusters groups them. The
CI is the percentile interval; p is two-sided against "no change",
read off the same replicates (so p < 1 − confidence exactly when
the CI leaves out 0, up to the +1 that keeps p above zero).
Source code in operonx/app/evals/stats.py
compare_paired
¶
compare_paired(
a: Sequence[float],
b: Sequence[float],
clusters: Optional[Sequence[Any]] = None,
*,
replicates: int = 2000,
seed: int = 0,
confidence: float = 0.95,
) -> Comparison
Compare per-case values of the same cases under A and B.
0/1 values in independent cases → the exact McNemar test and the
Newcombe interval (:func:newcombe_paired), both from the 2×2 table.
Otherwise (shares over repeats, or grouped cases) → the paired
bootstrap's p and percentile interval, resampling cases or clusters.
Source code in operonx/app/evals/stats.py
holm
¶
Holm–Bonferroni adjusted p-values (step-down, family-wise error), in the input's order.
Source code in operonx/app/evals/stats.py
benjamini_hochberg
¶
Benjamini–Hochberg adjusted p-values (step-up, false discovery rate), in the input's order.
Source code in operonx/app/evals/stats.py
The playground bridge¶
play
¶
The playground bridge — a service's doors, driven from outside.
The studio's playground puts a toy on a service's doors: a form, a chat box, later a microphone. The run itself must happen in the project's own interpreter (the studio never imports a project), so this module is a small process the studio starts there and talks to in JSON lines::
python -m operonx.app.play [--root DIR] # stdin: requests, stdout: events
Nothing here is a second way to run a service. A playground session is a
:class:~operonx.app.serve.protocol.BoundedSession driven through the
same :class:~operonx.app.serve.runner.ServeRunner gate (the service's own
on_session hook, its variants, its input contract), the same
:func:~operonx.app.serve.runner.serve_session, the same door ops and the
same trace consumers as production — only the origin differs:
origin=playground, so the run is filed apart and kept 7 days.
Codecs. Toys speak one small protocol — messages {"kind": "text",
"text": …}, {"kind": "json", "value": …}, {"kind": "bytes", "size":
…} — and a door speaks whatever it speaks. A codec translates between
them: :meth:Codec.to_door turns a toy message into an ingress item,
:meth:Codec.from_door an egress item into a toy message. The built-ins
cover plain transports (http JSON, websocket text); a service whose
protocol is its own declares Service(..., playground=MyCodec) (or
playground = "module:attr" in [[serve]]). A door with no codec
offers no toy. Session hooks can tell a playground session apart by
session.meta["playground"].
Audio. A codec that sets audio = {"rate": 8000, …} offers the
Voice toy: the browser sends {"kind": "audio", "b64": <pcm16 mono at that
rate>} in short batches and plays what comes back the same way. The
codec cuts a batch into the door's own frames (to_door_items).
Conditions. A session can run in a worse world than the one it was
built in: latency_ms before each inbound item, drop (the share of
inbound items lost), noise_dbfs (white noise mixed into audio),
silence_ms (quiet audio before the first message) and fail (resource
keys that raise for this session only — the resource objects are wrapped
once and fail only where a session asked them to).
A simulated user. simulate opens a session and lets an LLM persona
play the other side for a number of turns: it waits for the service to go
quiet, reads the conversation, answers in character, and ends it when the
persona says so. The run records what the persona said, like any session.
Where it is recorded. A playground run goes to the service's local
consumers only — files and run stores in the project — so a test session
never lands in a production Langfuse; "remote": true on a session
sends it everywhere the service traces to.
Re-running one op. rerun runs a single op of a service's graph
with the inputs it had in a recorded run — a failing op deep inside a
call retried in a second, without making the call. It is recorded as a
run of its own (toy=rerun, rerun_of=<run>).
Requests (one JSON object per line; id is echoed back)::
{"op": "describe"}
{"op": "open", "sid": "s1", "service": "chat", "query": {...}, "variant": null,
"send": [msg, ...], "end": false}
{"op": "send", "sid": "s1", "msg": {"kind": "text", "text": "hi"}}
{"op": "end", "sid": "s1"}
{"op": "simulate", "sid": "s2", "service": "chat", "persona": "a busy parent…", "llm": "llm:x",
"turns": 6, "first": "user", "conditions": {...}}
{"op": "rerun", "service": "chat", "op_name": "reply", "inputs": {...}, "of": "run-id"}
{"op": "rerun", "job": "score_calls", "op_name": "scored", "inputs": {...}}
Events: doors, opened, refused, out (an egress item, as a
toy message), said (what a simulated user sent), ops (the op
executions that finished since the last batch — {"op", "status",
"ms"} each — so a canvas can follow the run live; the last batch comes
before ended), ended (status, error, duration), rerun,
error.
Codec
¶
Toy messages ↔ door items. Subclass for a door with its own protocol.
toys names the toys the codec can drive (form, chat; the
voice toy arrives with audio codecs).
Methods:¶
to_door_items
¶
The ingress items one toy message becomes — one, unless the door takes audio in frames smaller than the toy's batches.
to_toy
¶
What a client sent the door, as a toy message — the inverse of
:meth:to_door, for recording a real session so it can be replayed
(Service(replay=True)). A door whose items carry audio inside
JSON overrides this to say so ({"kind": "audio", "size": …}):
audio is never kept.
Source code in operonx/app/play.py
TextCodec
¶
Bases: Codec
A websocket door: text frames both ways (the Chat toy); JSON frames are sent as objects and come back as events.
PcmCodec
¶
Bases: Codec
A door that takes raw 16-bit mono PCM frames and sends them back —
the Voice toy. Declared, never guessed: Service(...,
playground=PcmCodec(rate=16000, frame_ms=20)). What the door sends
that is not audio (a transcript, an event) still reaches the toy.
Source code in operonx/app/play.py
codec_for
¶
The codec a service's playground uses: its declared one, else the built-in for its transport, else None (no toy).
Source code in operonx/app/play.py
Where a run came from¶
origin
¶
Where a run came from — the tags every run carries.
A run is one graph execution, and every run has exactly one origin:
a service answering a client, a job working through a source, an eval,
the studio's playground, or anything else (a test, a script — "ad hoc").
The origin travels as trace metadata, which every consumer already
reads: the local consumer files the run under it, Langfuse receives it
as tags, a run store indexes it. Nothing here does I/O except
:func:code_version, which asks git once per process.
::
origin_metadata("service", service="call", transport="websocket")
# {"origin": "service", "service": "call", "transport": "websocket",
# "tags": ["origin:service", "service:call", "transport:websocket"]}
A job of steps runs its steps inside :func:in_runbook; a step's runs
then carry runbook and runbook_run (the names run stores keep,
from when that job was a Runbook) beside their own job fields, so
its run → its steps' runs → their items → their traces is one path.
origin_metadata
¶
Trace metadata naming a run's origin: origin, the given fields,
and the same pairs as tags (Langfuse filters on tags). Fields
that are None are left out.
Source code in operonx/app/origin.py
in_runbook
¶
Everything a job of steps starts inside this block — its steps, their runs — knows the run it belongs to. Tasks created inside inherit it (asyncio copies the context), so it reaches every item.
Source code in operonx/app/origin.py
code_version
¶
{"version": <commit>, "version_dirty": bool} for the git
checkout at root, or {} when it is not one (or git is absent).
Read once per process at bootstrap — never per run. "p95 went up" is only actionable as "p95 went up after commit abc123"; a dirty tree says the numbers came from code no commit holds.
Source code in operonx/app/origin.py
Jobs and runbooks are on their own page: operonx.app.jobs. Run stores, retention and alerts: operonx.telemetry.runs.