operonx.core.ops¶
Op base classes, flow primitives, and graph composition.
ops
¶
Core op types and markers for the Operon workflow engine.
BaseOp— base class for all workflow opsDummyOp— placeholder for START/END markersGraphOp— container managing a sub-graph with parallel executionBranchOp— conditional routing with precompiled conditionsFuncOp— wraps a Python function (supports generators for streaming)
Text→structured-output parsing is done inline by LLMOp(fields=..., parser=...,
validators=...) — the standalone ParserOp was removed in 1.0.0. Its
pure functions live in operonx.providers.parsing for callers that need
text parsing without an LLM call.
Markers: START, END, PARENT, PENDING.
Decorators: op, graph, if_.
Attributes¶
OpType
module-attribute
¶
OpType = Literal[
"data",
"llm",
"embedding",
"rerank",
"vector-search",
"doc-fetch",
"branch",
"interrupt",
"emit",
"code",
"lambda",
"prompt",
"doc-processor",
"graph",
"default",
"dummy",
"tool-executor",
"mcp",
]
Các loại node được hỗ trợ trong workflow graph.
Removed in 1.2.0:
for / while / stream — superseded in 1.0.0 by back-edge
loops, generator ops and Ref.parallel().
parser — ParserOp went away in 1.0.0; parsing lives inside
LLMOp(fields=...).
milvus / mongo / s3 — named backends, not semantics, and
never had ops behind them. Storage is reached through
vector-search and doc-fetch.
onnx / triton — assigned by the ops deleted in 1.2.0 (they
were never in this Literal, which is the drift this cleanup ends).
Added in 1.2.0:
interrupt / emit — set by InterruptOp / EmitOp since
1.0.0 but missing here, so the Literal disagreed with the code.
Classes¶
EOF
dataclass
¶
Marker that an op's async generator has exhausted.
Created by Scheduler._pump() after op.run() stops yielding
(i.e. the underlying function returned or its generator was exhausted).
User code never yields EOF — it is emitted implicitly when the op finishes.
Frame
dataclass
¶
One result yielded by an op during execution.
Created by Scheduler._pump() for every (ctx, result) tuple that
op.run() yields. User code never constructs Frame directly — just
return or yield from an @op function.
Interrupt
dataclass
¶
In-band scheduler cancellation event.
Returned/yielded by user op bodies to cancel queued frames + in-flight
tasks at ctx_to_cancel (and its descendants). op and ctx
record the emitter for tracing; ctx_to_cancel is the target —
typically the prior turn's ctx, stored in SCRATCH when long-running
work began.
ctx_to_cancel defaults to :data:Interrupt.SELF, which the
scheduler resolves to the emitter's own context. Cancelling the whole
run is spelled Interrupt.ALL and has to be asked for::
Interrupt(reason="bad input") # this branch
Interrupt(ctx_to_cancel=Interrupt.ALL, ...) # everything
The empty tuple used to be the default, and it is a prefix of every context — so omitting the argument discarded the entire run and returned cleanly, with no error anywhere for the caller to notice.
The scheduler
- Drops Frame/EOF items at ctx_to_cancel from the queue.
- Cancels in-flight
_pumptasks at ctx_to_cancel and descendants (skipping the emitter to avoid self-cancel). - Clears bookkeeping (ready/seq_active/seq_origins/collect_bufs).
- Forwards a synthetic
("__interrupt__", emitter_ctx, {...})tuple tooutput_queuesoExecutionHandleconsumers see it.
Best-effort: data already pushed to consumer-owned queues (e.g. a
user-supplied asyncio.Queue) is NOT drained — consumer must handle
that itself (see plan §4.6a).
BaseOp
¶
BaseOp(
id: str = None,
name: str = None,
description: str = "",
inputs: Dict[str, Any] = None,
outputs: Dict[str, Any] = None,
sources: List[str] = None,
targets: List[str] = None,
stream: bool = False,
start: bool = False,
end: bool = False,
contain_generation: bool = False,
verbose: bool = True,
enabled: bool = True,
bound: Optional[str] = None,
cache: Union[bool, str, None] = None,
delay: float = 0,
exclude=None,
include=None,
observe_max: Optional[int] = None,
)
Bases: ABC
Base class for all ops in a workflow.
An op is the fundamental processing unit. Each op declares typed inputs
and outputs (via Param), and implements a core() method that
contains the execution logic. Ops are wired together inside a GraphOp
using edge operators.
Sections (read top-to-bottom)::
1. INIT __init__, __slots__, param helpers
2. EDGE OPERATORS >>, >>~, >, <, [], ~ — wiring ops in a graph
3. EXECUTE run(), get_inputs/outputs, store_result, _exec_core
4. OBSERVABILITY _log(), _store_metrics()
5. SERIALIZATION serialize(), metadata — for Rust backend & tracing
Example::
from operonx.core import GraphOp, op, START, END, PARENT
@op
def double(x: int):
return {"result": x * 2}
with GraphOp(name="main") as graph:
d = double(x=PARENT["x"])
START >> d >> END
Source code in operonx/core/ops/base.py
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 | |
Attributes¶
full_name
property
¶
Fully-qualified hierarchical path of this op. Cached after build().
specific_metadata
property
¶
Return subclass-specific metadata. Override in subclasses.
Methods:¶
warmup
¶
Called by Operon engine after graph.build() when a ResourceHub is available.
Override in provider ops (LLMOp, EmbeddingOp, etc.) to eagerly initialize backends and eliminate cold-start latency on the first user request.
The default implementation is a no-op — subclasses opt in by overriding.
Source code in operonx/core/ops/base.py
get_inputs
¶
Retrieve input values from state based on connection mappings.
Uses cached cell indices to avoid per-call schema.get_index() lookups. Falls back to standard path on first call to build the cache.
Source code in operonx/core/ops/base.py
get_outputs
¶
Read output values from state.
Reads directly from this op's output variables. Output connections (outputs={...}) are resolved by the schema at build time — they create refs at the destination, not at this op.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
state
|
MemoryState
|
Workflow state. |
required |
context_id
|
str
|
Context of this op. |
required |
Source code in operonx/core/ops/base.py
normalize_trace_io
¶
Produce a trace-time view of this op's I/O.
Called by _extract_trace_io before media extraction. Subclasses
override when their I/O carries media in a non-Media shape (e.g.
LLMOp wraps OpenAI chat-format image_url blocks into Media
instances). The real state value is untouched — this returns copies
used only for trace capture.
Default is identity: most ops never override.
Source code in operonx/core/ops/base.py
store_result
¶
Store result dict into state.
Uses state[op, var, ctx] = value for O(1) index-based storage.
Extracts $tags special key for dynamic tagging. After all writes
commit, calls state.advance_step() so the checkpointer /
tracer see a fresh step_id for the next op's writes.
Source code in operonx/core/ops/base.py
save_all_caches
staticmethod
¶
Save all file-backed caches. Returns total entries saved.
Source code in operonx/core/ops/base.py
run
async
¶
run(
state: MemoryState, context_id: Optional[str] = None
) -> AsyncGenerator[tuple[Optional[str], Dict[str, Any]], None]
Execute this op as a uniform async generator.
Every op — whether it uses return or yield — is driven through
the same three-layer model:
- User function (
@op): plainreturn {"k": v}oryield {"k": v}. No awareness of Frame/EOF. BaseOp.run()(this method): wraps the user function via_exec_core()into a uniform async generator that yields(context_id, result)tuples. Normal op → one yield. Generator op → N yields, each in its own stream context[i].Scheduler._pump(): consumes this generator. Each yield becomes aFrameevent on the queue; when the generator exhausts naturally,_pumpemits oneEOFevent. The user never writes Frame or EOF.
Yields:
| Type | Description |
|---|---|
AsyncGenerator[tuple[Optional[str], Dict[str, Any]], None]
|
|
Source code in operonx/core/ops/base.py
871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 | |
serialize
¶
Serialize this op to a config dict for the Rust backend.
Source code in operonx/core/ops/base.py
DummyOp
¶
Bases: BaseOp
Sentinel op used as START, END, and PARENT markers.
Source code in operonx/core/ops/_edges.py
Methods:¶
declare
¶
Declare shared vars on the current graph, with optional reducers.
Shared vars persist across all stream contexts within the graph. Normal
PARENT vars are copied per stream context. When a reducer is registered
for a var, concurrent/repeated writes to its shared cell go through
reducer(old, new) instead of overwriting. Without a reducer, shared
cell writes use last-write-wins semantics.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
reducers
|
dict | None
|
Optional |
None
|
**vars
|
Any
|
|
{}
|
Raises:
| Type | Description |
|---|---|
TypeError
|
if called on anything other than PARENT. |
RuntimeError
|
if called outside a |
ValueError
|
if a reducer key is not in the declared vars. |
Usage::
from operonx.reducers import add_messages
@graph
def agent():
PARENT.declare(
count=0,
messages=[],
reducers={"messages": add_messages},
)
Source code in operonx/core/ops/_edges.py
ScratchAccessor
¶
Dict-like accessor for per-call scratch space.
- Inside an op body (ContextVar bound): reads/writes the live scratch dict on the current MemoryState.
- At graph-construction time (ContextVar unbound):
__getitem__returns aScratchRefmarker, post-resolved byBaseOp.get_inputs().__setitem__raises — write-outside-run is a programming error.
Methods:¶
get
¶
Read key, or default when it is absent.
SCRATCH[key] already returns None for a missing key, so
this adds nothing but the idiom people reach for first. Without
it, SCRATCH.get("k") raised AttributeError from inside an
op body — where BaseOp.run records it into state rather than
raising, so it surfaced as an op error rather than an obvious typo.
Outside a run this returns default rather than a
:class:ScratchRef: a ref is a wiring marker and get() with
a default reads as a value lookup, so returning one would smuggle
a marker into a place expecting data.
Source code in operonx/core/ops/_edges.py
keys
¶
items
¶
(key, value) pairs — a snapshot, empty outside a run.
SoftEdge
¶
Branch
¶
Fluent builder for creating a BranchOp.
Two usage flavors — both supported, both idiomatic:
Inline form (recommended for common if/else) — pass op instances as
targets. The Branch auto-wires branch >> target edges at build time
so you never write them yourself, and the branch can drop right into a
>> chain::
START >> source >> if_(source["kind"] == "audio", asr).else_(skip_stt)
asr >> denoise >> picker
skip_stt >> picker
Auto-name resolves to the LHS if there is one (stt_route = if_(...))
or falls back to a semantic "branch_<target>_or_<default>" name.
Named form (for forward refs or a shared branch node) — pass op
names as strings. No auto-wiring; you wire branch >> target
yourself as before::
router = (if_(PARENT["score"] >= 90, "excellent")
.if_(PARENT["score"] >= 70, "good")
.else_("fail"))
# ...
router >> excellent >> merge
router >> good >> merge
router >> fail >> merge
Mixed is allowed — string targets skip auto-wiring, op-instance targets get auto-wired.
Initialise the builder.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
Optional[str]
|
Op name. If None, auto-inferred from the variable name. |
None
|
Source code in operonx/core/ops/flow/branch_op.py
Methods:¶
if_
¶
Add a condition–target case.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
condition
|
Ref
|
Ref with comparison (e.g., |
required |
target
|
Union[str, BaseOp]
|
Target op instance (enables auto-wiring) or op name string. |
required |
Returns:
| Type | Description |
|---|---|
Branch
|
self for chaining. |
Source code in operonx/core/ops/flow/branch_op.py
else_
¶
BranchOp
¶
BranchOp(
cases: Optional[List[Tuple[Ref, str]]] = None,
candidates: Optional[List[str]] = None,
default: Optional[str] = None,
inputs: Dict[str, Any] = None,
outputs: Dict[str, Any] = None,
**kwargs,
)
Bases: BaseOp
Op that evaluates conditions and routes execution to different targets.
Conditions are Ref objects with comparison operators. The first matching
condition determines the target. An optional anchor input overrides all
conditions. Use soft edges (>>~) to connect branch targets to a merge op.
Inputs
anchor (str, optional): Hard-coded target name that overrides conditions. (any): Variables referenced in condition Refs (auto-extracted).
Outputs
target (str): Name of the selected target op. matched (str): Description of which condition matched.
Example::
router = if_(PARENT["score"] >= 90, "excellent").else_("fail")
START >> router >> ~excellent >> merge >> END
router >> ~fail >> merge
Source code in operonx/core/ops/flow/branch_op.py
Attributes¶
Methods:¶
get_target
¶
serialize
¶
Serialize branch op with conditions for Rust backend.
Source code in operonx/core/ops/flow/branch_op.py
EmitOp
¶
Bases: BaseOp
Op that emits a CustomEvent to mode="custom" subscribers.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
payload
|
Any
|
Value (typically a |
None
|
channel
|
str
|
Free-form string label used by consumers for filtering.
Defaults to |
'default'
|
Behaviour
- Fires once per invocation (per ctx for streaming/parallel graphs).
- Fire-and-forget: no subscriber → payload dropped, no error.
- Emits
CustomEvent(step_id, op, ctx, channel, payload)viastate._notify_custom— scheduler providesstep_idandctxvia ContextVar plumbing (matchesSCRATCH's pattern).
Source code in operonx/core/ops/flow/emit_op.py
InterruptOp
¶
Bases: BaseOp
Op that suspends until the caller posts a resume value.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
payload
|
Any
|
Value (typically a |
None
|
timeout
|
float
|
Optional wall-clock seconds. Falsey (0/None) = wait forever. |
0
|
Behaviour
- Emits
InterruptEvent(step_id, op, ctx, payload, interrupt_id)when dispatched. - Awaits
state._interrupt_responses[interrupt_id](adict[str, Future]) — the caller resolves this future. - Returns
{"response": <value>}— downstream refs likeapprove["response"]read the answer. - Also exposes
timed_out(bool) andinterrupt_id(str) outputs.
Design note
This op piggybacks on the state's interrupt bus. Full scheduler
integration (Phase 2b3) wires engine.stream() to auto-subscribe
a listener that hands out interrupt_id → asyncio.Future
pairs and lets run.resume(value) resolve them.
Source code in operonx/core/ops/flow/interrupt_op.py
GraphOp
¶
Bases: BaseOp
Container op that holds and executes a directed graph of child ops.
Lifecycle::
1. DEFINE with GraphOp(name="wf") as g:
a = double(x=PARENT["x"])
b = add(a=a["result"], b=PARENT["y"])
START >> a >> b >> END
Ops auto-register via context manager. Edges via >> operator.
Inputs/outputs auto-discovered from PARENT refs.
2. BUILD g.build() (or auto on first run)
_setup_schema scan PARENT refs → graph inputs/outputs
_setup_endpoints find entry/exit ops from topology
_build() adj list + ready counts + stream ready counts
validate branch targets, cycles, reachability, refs
3. EXECUTE g.run(state, context_id) — async generator
→ run_task_scheduler() drives ops via Frame/EOF events
→ yields (ctx, outputs) per batch or per stream frame
→ loop iteration handled inside scheduler EOF handler
4. EXPORT serialize() config dict for Rust backend
validate() graph structure validation
show() debug display
Source code in operonx/core/ops/graph/graph_op.py
Methods:¶
get_current_graph
staticmethod
¶
add_op
¶
Add an op to the graph.
Source code in operonx/core/ops/graph/graph_op.py
add_edge
¶
add_edge(
source: str,
target: str,
type: EdgeType = "normal",
soft: bool = False,
hard: bool = False,
)
Add an edge between two ops.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
source
|
str
|
Source op name. |
required |
target
|
str
|
Target op name. |
required |
type
|
EdgeType
|
Edge type (normal, lookback, condition). |
'normal'
|
soft
|
bool
|
If True, edge does not count toward ready_count. Used for branch outputs when only one branch executes. |
False
|
hard
|
bool
|
If True, opt this edge out of auto-softening at build time. Use for the rare case where a branch-descended pred must still be waited on as a hard dependency. |
False
|
Source code in operonx/core/ops/graph/graph_op.py
build
¶
Build graph: cycle-rewrite → children → schema → endpoints → validation → topology.
The cycle-rewrite pass (Phase 3 Level-2) runs FIRST so that any
synthetic hidden GraphOp.loop children it creates are then built
alongside the user's own children. Ordering matters — validate() and
auto_soft assume a DAG and would produce wrong results on cyclic input.
Source code in operonx/core/ops/graph/graph_op.py
run
async
¶
run(
state: MemoryState, context_id: Optional[tuple] = None
) -> AsyncGenerator[Tuple[tuple, Dict[str, Any]], None]
Execute graph: get inputs → schedule ops → loop if needed → store results.
Source code in operonx/core/ops/graph/graph_op.py
serialize
¶
Serialize full graph to config dict for the Rust backend.
Note: the key "initial_ready_count" is kept as-is for Rust backend
compatibility even though the internal Python attribute was renamed to
_initial_ready during the scheduler rewrite.
Source code in operonx/core/ops/graph/graph_op.py
validate
¶
Run all validations and return result.
Source code in operonx/core/ops/graph/graph_op.py
show
¶
Display graph structure (debug).
Source code in operonx/core/ops/graph/graph_op.py
FuncOp
¶
FuncOp(
code_fn: Optional[Callable] = None,
return_keys: Optional[List[str]] = None,
inputs: Dict[str, Any] = None,
outputs: Dict[str, Any] = None,
_mappings: Dict[str, Any] = None,
**kwargs,
)
Bases: BaseOp
Op that executes a Python function.
Inputs and outputs are auto-extracted from the function's signature and
return-statement AST. Both sync and async functions are supported.
Prefer the @op decorator over instantiating FuncOp directly.
Inputs
Auto-parsed from the function's parameter list.
Outputs
Auto-parsed from return {"key": ...} via AST, or from
explicit return_keys.
Example::
@op
def add(a: int, b: int):
return {"sum": a + b}
with GraphOp(name="main") as graph:
result = add(a=PARENT["x"], b=PARENT["y"])
START >> result >> END
Source code in operonx/core/ops/transform/func_op.py
Attributes¶
Methods:¶
run
async
¶
run(
state: MemoryState, context_id: Optional[str] = None
) -> AsyncGenerator[Tuple[Optional[str], Dict[str, Any]], None]
Execute FuncOp with CodeError wrapping.
Delegates to BaseOp.run() (async generator) and re-raises any
exception as a CodeError with full op context attached.
Source code in operonx/core/ops/transform/func_op.py
Functions:¶
shorthand
¶
Decorator for Op.of() classmethods.
Registers the function for auto-naming frame skip via register_skip()
and wraps as classmethod.
Usage::
class MyOp(BaseOp):
@shorthand
def of(cls, my_param=None, **kwargs):
inputs, init_kwargs = split_shorthand_kwargs(kwargs)
return cls(my_param=my_param, inputs=inputs or None, **init_kwargs)
Source code in operonx/core/ops/_shortcuts.py
split_shorthand_kwargs
¶
Split flat kwargs into (inputs, init_kwargs).
Used by shorthand functions (llm_, for_, op, etc.) to separate op constructor kwargs from input mappings.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
kwargs
|
dict
|
Flat keyword arguments from shorthand function. |
required |
extra_init_keys
|
set
|
Additional op-specific init keys beyond base keys (e.g., {'max_concurrency', 'callback'} for iteration ops). |
None
|
Returns:
| Type | Description |
|---|---|
tuple
|
(inputs, init_kwargs) tuple where: |
tuple
|
|
tuple
|
|
Example
Provider ops - just base keys¶
inputs, init_kwargs = split_shorthand_kwargs(kwargs)
Iteration ops - with extra keys¶
inputs, init_kwargs = split_shorthand_kwargs( kwargs, {'max_concurrency', 'until', 'callback'} )
Source code in operonx/core/ops/_shortcuts.py
if_
¶
Start a branch declaration with the first condition.
Example (inline, auto-wired)::
START >> source >> if_(cond, asr).else_(skip_stt)
Example (named, string targets, wire manually)::
router = if_(PARENT["score"] >= 90, "excellent").else_("fail")
router >> excellent >> merge
router >> fail >> merge
Source code in operonx/core/ops/flow/branch_op.py
op
¶
op(
func: Optional[Callable] = None,
*,
bound: Optional[str] = None,
cache: Optional[Any] = None,
delay: float = 0,
exclude: Optional[Any] = None,
include: Optional[Any] = None,
observe_max: Optional[int] = None,
) -> Any
Decorator that turns a plain function into a FuncOp factory.
Can be used bare or with keyword arguments::
@op
def double(x: int):
return {"result": x * 2}
@op(bound="cpu")
def heavy_compute(data: list):
return {"result": process(data)}
@op(bound="io")
async def call_api(url: str):
return {"data": await fetch(url)}
# Phase 2 observability filter (Checkpointer + Tracer both respect these):
@op(exclude=["tokens"]) # skip these vars in both observers
@op(include=[]) # silence entire op (allowlist of nothing)
@op(exclude={"trace": ["pii"]}) # per-observer split (dict form)
@op(observe_max=10_000) # circuit breaker: raise if op emits > N events
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
bound
|
Optional[str]
|
Execution bound hint for the scheduler.
|
None
|
exclude
|
Optional[Any]
|
Vars to hide from observers. |
None
|
include
|
Optional[Any]
|
Vars to expose to observers (allowlist). Same shapes as |
None
|
observe_max
|
Optional[int]
|
Per-op circuit breaker. If the op emits more than this many
events in a single run, :class: |
None
|
Source code in operonx/core/ops/transform/func_op.py
21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 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 | |