Runtime protocols ¶
This page explains why conatus_common.runtime_protocols exists, how the
structural Protocols defined there are used, and the typing consequences that
ripple out from them. If you have just encountered a Sequence where you
expected a list, a cast that looks redundant, or a no-op function under
if TYPE_CHECKING, this is the page that explains it.
The problem: a models β runtime import cycle ¶
The package layering is meant to be a DAG:
runtime sits high up (in core); models and actions sit below it. The
allowed direction is therefore runtime β models and runtime β actions.
The trouble was that models and actions annotated fields and parameters with
the concrete classes
RuntimeVariable and
RuntimeState, importing them from
conatus_core.runtime. That created edges in the wrong direction β
models β runtime and actions β runtime β turning the dependency graph into a
cycle. Cycles block the eventual split of the monolith into separately
installable packages, because two packages that import each other cannot be
built or versioned independently.
The fix: depend on a thin structural Protocol ¶
The concrete classes are heavy. But the consumers in models and actions only
touch a handful of their members. So instead of importing the concrete class,
those consumers import a structural Protocol that declares exactly the
members they use β and nothing more β from a deliberate leaf module,
conatus_common.runtime_protocols.
That module imports nothing from conatus_core.runtime (only
conatus_common._types, itself a leaf), so it introduces no edge back into
runtime. The result: models and actions now have zero edges into
conatus_core.runtime, while runtime β models (the allowed direction) is
preserved. The cycle is broken.
The Protocol surface is intentionally minimal β it was reverse-engineered from what consumers actually call:
RuntimeVariable:name,value,update.RuntimeState:variables,step_count,termination_sentinel,pending_variable_updates,add_result.
The single best place to see why the surface is exactly this is
Action.force_variable_update,
which exercises nearly every member in one method: it iterates
runtime_state.variables, reads each variable's value, calls update, reads
step_count, appends to pending_variable_updates, reads name, and falls
back to add_result. Anything the concrete class has but no consumer touches
stays out of the Protocol.
The Protocols are not @runtime_checkable: no consumer does an isinstance
check against them, so making them runtime-checkable would buy nothing.
Keeping the concrete classes pinned: conformance assertions ¶
A Protocol is structural: nothing declares "the concrete RuntimeState
implements this Protocol." They conform only by coincidence of having the right
members. If someone renamed step_count or changed add_result's signature,
the concrete class would silently stop conforming, and β because the class and
the Protocol live in different files β nobody would notice until something broke
at a call site far away.
To make drift a build failure, runtime/variable.py and runtime/state.py each
carry a no-op assertion under if TYPE_CHECKING: (so it never runs and costs
nothing):
if TYPE_CHECKING:
from conatus_common.runtime_protocols import (
RuntimeVariable as _RuntimeVariableProto,
)
def _assert_runtime_variable_conformance( # pyright: ignore[reportUnusedFunction]
v: RuntimeVariable, # the concrete class (this file)
) -> _RuntimeVariableProto: # the Protocol (aliased to avoid a clash)
return v # basedpyright flags reportReturnType if the class drifts
Inside runtime/variable.py, the bare name RuntimeVariable is the concrete
class, so the Protocol is imported under the _β¦Proto alias. The function
claims a concrete value is a valid Protocol return; return v type-checks only
while the concrete class still satisfies the Protocol. The day it stops, the
build goes red. The pyright: ignore[reportUnusedFunction] suppresses the
"defined but never called" warning, since being type-checked is the function's
entire job.
This guards one direction β the concrete class must remain a superset of the Protocol. It deliberately does not check that the Protocol stays minimal; that is a human judgment.
The type cascade ¶
Once a declared type flips from the concrete class to the Protocol, every consumer must adjust. There are exactly three shapes of adjustment, chosen by what the consumer actually needs:
| Consumer needs | Fix |
|---|---|
| only Protocol members | drop annotation, infer as Protocol |
| a concrete-only capability | cast Protocol β concrete |
| to hold the value in a list | widen the container for covariance |
Drop the annotation. In runtime/runtime.py, lines like
env_var: RuntimeVariable = tool_call.environment_variable lost their explicit
annotation. tool_call.environment_variable is now Protocol-typed, but in
runtime.py the bare name RuntimeVariable is the concrete class β so the
old annotation asserted more than the value guarantees and would not type-check.
The surrounding code only reads .name and .value (both Protocol members), so
letting env_var infer as the Protocol is sufficient and honest.
Widen the container. ComputerUseConfig.variables_to_hide was widened from
list[RuntimeVariable] to Sequence[RuntimeVariable]. list is invariant,
so a list[<concrete>] (what runtime builds) does not satisfy
list[<Protocol>]. Sequence is covariant, so it does. This widening is
load-bearing: do not "simplify" it back to list. It is also semantically
honest, because variables_to_hide is only ever read, never mutated through the
config.
Cast at the boundary. See the next section.
The boundary rule ¶
The cleanest way to hold the whole picture: there are two type worlds.
- The Protocol world β
modelsandactions. These packages must not importconatus_core.runtime, so they type runtime objects with the Protocols. - The concrete world β
agentsandruntime. These depend onconatus_core.runtimedirectly (for example,agents/ai_interfaces/base.pyimportsRuntimeat module scope) and use the concrete classes.
A value crosses from one world to the other only at an agents-layer seam, and
the crossing is an explicit cast. The canonical example is
ComputerUseAIInterface.make_first_prompt. It reads variables_to_hide out of
ComputerUseConfig β a models type, so Protocol-typed and covariant
Sequence β and must hand it to get_all_variables_xml, which lives in
agents and is typed against the concrete list[RuntimeVariable]. Two
mismatches at once (Sequence β list, Protocol β concrete) are resolved by
list(...) wrapped in a cast.
Why not avoid the cast by typing get_all_variables_xml against the Protocol?
Because agents already depends on runtime wholesale, so a Protocol parameter
would remove no edge β it would be indirection for no decoupling β and the XML
renderer uses concrete-only behavior the minimal Protocol omits.
Why not avoid it by typing ComputerUseConfig.variables_to_hide concrete?
Because ComputerUseConfig lives in models; typing it concrete re-imports
conatus_core.runtime into models and resurrects the very cycle this whole
design removes.
So the impedance mismatch between the two worlds is resolved deliberately in
agents β the one layer permitted to know both the Protocol and the concrete
class β rather than allowed to leak the other way.
The cast is sound because the values provably originate from
Runtime.variables (via
filter_computer_use_environment_vars, which reads the concrete variable map).
The structural typing cannot prove that, which is why the cast is the one spot
that rests on an unstated invariant: if a variables_to_hide ever came from
somewhere other than the runtime, the cast would be a silent lie. That makes it
the place to watch β more so than any consumer merely accepting a
Protocol-typed value, which is harmless because it only uses declared members.
The layering is enforced by machine ¶
The leaf rule ("runtime_protocols must not import conatus_core.runtime") and
the no-edges rule ("models and actions must not import
conatus_core.runtime") are no longer prose: they are import-linter
contracts in pyproject.toml that encode the layering DAG as executable rules.
Run them with make check-imports (or uv run lint-imports); they also run as
a pre-commit hook and inside make check-strict-all. An edit that re-introduces
a cycle now fails the contract instead of slipping past review.
Together with the TYPE_CHECKING conformance assertions β which guard the
Protocol surface β the import graph and the Protocol contract are both
machine-checked.