Skip to content

Type-state pattern

Agent encodes its "lifecycle state" into a type parameter, so calls made out of order are reported by the static checker rather than only at runtime.

The three states

from xun import Agent

T = Agent.T          # namespace, equivalent to xun.agent_state.T
State Meaning Typical source
T.Uninit constructed, not initialized Agent() (it is also the default parameter of the bare type)
T.Init initialized, ready to execute agent.initialize(), entering a with block, setup_agent()
T.Final finalized, no longer usable agent.finalize(), exiting a with block
T.Alive union of Uninit | Init the constraint domain of system() / instruct()
T.Any any state use it when acting purely as a container or a parameter

StateT is covariant: Agent[T.Init] may appear where Agent[T.Any] is required, but not the other way round.

Rebinding is required

A state transition rewrites the internal _lifecycle in place and casts the same object back, so the return value is the very same object as the argument — only its type differs. Rebind the variable to keep the types precise:

agent = Agent()
agent = agent.initialize()      # Agent[Agent.T.Init]
...
agent = agent.finalize()        # Agent[Agent.T.Final]

A with statement does this for you:

with Agent() as agent:          # agent: Agent[Agent.T.Init]
    result = agent.instruct("Hi").execute()

Method availability

API Uninit Init Final
initialize() allowed rejected statically (returns itself idempotently at runtime) RuntimeError at runtime
execute() / execute_command() not allowed allowed not allowed
system() / instruct() allowed allowed not allowed
info() / warning() / error() / get_choice() / get_confirm() not allowed allowed not allowed
display_event() allowed allowed allowed
finalize() allowed allowed allowed
cancel() / check_cancel() / is_running allowed allowed allowed
Agent.inherit() / is_initialized() / is_finalized() allowed (the argument is Agent[T.Any]) allowed allowed

Typical static errors:

agent_u = Agent()
# agent_u.execute()          # error: Cannot access attribute `execute` for class `Agent[Agent.T.Uninit]`

agent_f = Agent().initialize().finalize()
# agent_f.system("hi")       # error: Cannot access attribute `system` for class `Agent[Agent.T.Final]`

In a notebook you can inspect what is inferred with reveal_type():

from typing import reveal_type, TYPE_CHECKING

agent_u = Agent()
if TYPE_CHECKING: reveal_type(agent_u)     # Agent[Agent.T.Uninit]

agent_i = agent_u.initialize()
if TYPE_CHECKING: reveal_type(agent_i)     # Agent[Agent.T.Init]

Runtime versus static checking

The constraints exist only in the type layer: nothing enforces them at runtime, so an out-of-order call does not crash immediately. The framework therefore adds a runtime check at the most critical spot — execute() verifies Agent.is_initialized(self) on entry and, when the agent is not initialized, raises:

RuntimeError: Agent 'xxx' is not initialized. Call agent.initialize() or use 'with agent:'.

The checks are expressed as TypeGuard helper functions, so types can be narrowed inside a function signature:

from xun import Agent

def run(agent: Agent[Agent.T.Any]) -> str | None:
    if Agent.is_initialized(agent):        # narrowed to Agent[Agent.T.Init]
        return agent.instruct("ping").execute().unwrap()
    return None

Implementation notes

  • Type layer: the base class _AgentState (v=0, i.e. T.Any) and the three subclasses _Uninit / _Init / _Final (v=1/2/3), told apart by their distinct class attribute v, exposed through the T namespace.
  • Runtime layer: the instance field _lifecycle holds one of those instances; comparison only looks at _lifecycle.v.
  • The conversion function _cast_self(state) rewrites _lifecycle and casts self back — the single place in the whole design where state is written.
  • Agent.T must be a plain class attribute rather than a type T = T alias, otherwise Agent.T.Init cannot be resolved in some IDEs.
  • Display-related helpers are defined on AgentDisplayMixin[StateT], whose self annotations refer to the mixin rather than to Agent; this preserves "only the Init state may output" without breaking the subtyping relationship.