Skip to content

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:

engine ← common ← browser ← actions ← llm ← core

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 β€” models and actions. These packages must not import conatus_core.runtime, so they type runtime objects with the Protocols.
  • The concrete world β€” agents and runtime. These depend on conatus_core.runtime directly (for example, agents/ai_interfaces/base.py imports Runtime at 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.