Protocol Traits
Layer 0 defines four protocol traits and two cross-cutting interfaces. Every trait is object-safe (Box<dyn Trait> is Send + Sync), uses #[async_trait], and is designed to be operation-defined rather than mechanism-defined.
“Operation-defined” means the trait says what happens, not how. Operator::execute means “cause this agent to process one cycle” – not “make an API call” or “run a subprocess.” This is what makes implementations swappable.
Protocol 1: Operator
Crate: layer0::operator
The operator is what one agent does per cycle. It receives input, assembles context, reasons (model calls), acts (tool execution), and produces output.
#![allow(unused)]
fn main() {
#[async_trait]
pub trait Operator: Send + Sync {
async fn execute(
&self,
input: OperatorInput,
) -> Result<OperatorOutput, OperatorError>;
}
}
The trait is one method. The operator is atomic from the outside.
OperatorInput
#![allow(unused)]
fn main() {
pub struct OperatorInput {
pub message: Content, // The new message/task/signal
pub trigger: TriggerType, // What caused this invocation (User, Task, Signal, etc.)
pub session: Option<SessionId>, // Session for conversation continuity
pub config: Option<OperatorConfig>,// Per-invocation config overrides
pub metadata: serde_json::Value, // Opaque passthrough (trace IDs, routing, etc.)
}
}
OperatorInput carries only what is new. It does not include conversation history or memory contents. The operator runtime reads those from a StateStore during context assembly. This keeps the protocol boundary clean.
OperatorConfig
#![allow(unused)]
fn main() {
pub struct OperatorConfig {
pub max_turns: Option<u32>, // Max ReAct loop iterations
pub max_cost: Option<Decimal>, // Budget in USD
pub max_duration: Option<DurationMs>, // Wall-clock timeout
pub model: Option<String>, // Model override
pub allowed_operators: Option<Vec<String>>, // Operator restrictions
pub system_addendum: Option<String>, // Additional system prompt
}
}
Every field is optional. None means “use the implementation’s default.”
Tools are operators registered with ToolMetadata. The allowed_operators field restricts which operators can be sub-dispatched during a turn; tool names in this list are operator names.
OperatorOutput
#![allow(unused)]
fn main() {
pub struct OperatorOutput {
pub message: Content, // The operator's response
pub exit_reason: ExitReason, // Why the loop stopped
pub metadata: OperatorMetadata, // Tokens, cost, timing, tool records
pub effects: Vec<Effect>, // Side-effects to execute
}
}
The effects field is a critical design decision. The operator declares effects but does not execute them. The calling layer (orchestrator, environment, lifecycle coordinator) decides when and how to execute them. This is what makes the same operator code work both in-process and in a durable workflow.
ExitReason
#![allow(unused)]
fn main() {
pub enum ExitReason {
Complete, // Natural completion
MaxTurns, // Hit iteration limit
BudgetExhausted, // Hit cost budget
CircuitBreaker, // Consecutive failures
Timeout, // Wall-clock timeout
MiddlewareHalt { reason }, // Middleware halted execution
Error, // Unrecoverable error
Custom(String), // Extension point
}
}
OperatorMetadata
#![allow(unused)]
fn main() {
pub struct OperatorMetadata {
pub tokens_in: u64,
pub tokens_out: u64,
pub cost: Decimal, // USD, precise
pub turns_used: u32,
pub sub_dispatches: Vec<SubDispatchRecord>,
pub duration: DurationMs,
}
}
Every field is concrete (not optional) because every operator produces this data. Implementations that cannot track a field (e.g., cost for a local model) use zero.
SubDispatchRecord
SubDispatchRecord captures the result of a single sub-operator dispatch within a turn:
#![allow(unused)]
fn main() {
pub struct SubDispatchRecord {
pub name: String, // Operator name that was dispatched
pub duration: DurationMs, // Wall-clock time for that dispatch
pub success: bool, // Whether the dispatch completed without error
}
}
Protocol 2: Orchestrator
Crate: layer0::orchestrator
How operators from different agents compose, and how execution survives failures.
#![allow(unused)]
fn main() {
#[async_trait]
pub trait Orchestrator: Send + Sync {
async fn dispatch(
&self,
operator: &OperatorId,
input: OperatorInput,
) -> Result<OperatorOutput, OrchError>;
async fn dispatch_many(
&self,
tasks: Vec<(OperatorId, OperatorInput)>,
) -> Vec<Result<OperatorOutput, OrchError>>;
async fn signal(
&self,
target: &WorkflowId,
signal: SignalPayload,
) -> Result<(), OrchError>;
async fn query(
&self,
target: &WorkflowId,
query: QueryPayload,
) -> Result<serde_json::Value, OrchError>;
}
}
dispatch– Send an operator invocation to a specific agent. May be in-process or remote.dispatch_many– Parallel dispatch. Results returned in input order. Individual tasks may fail independently.signal– Fire-and-forget message to a running workflow. Returns when accepted, not when processed.query– Read-only query of a workflow’s state. Returnsserde_json::Value(schema depends on the workflow).
The key property: calling code does not know which implementation is behind the trait. dispatch() might be a function call or a network hop.
Protocol 3: StateStore / StateReader
Crate: layer0::state
How data persists and is retrieved across turns and sessions.
#![allow(unused)]
fn main() {
#[async_trait]
pub trait StateStore: Send + Sync {
async fn read(&self, scope: &Scope, key: &str)
-> Result<Option<serde_json::Value>, StateError>;
async fn write(&self, scope: &Scope, key: &str, value: serde_json::Value)
-> Result<(), StateError>;
async fn delete(&self, scope: &Scope, key: &str)
-> Result<(), StateError>;
async fn list(&self, scope: &Scope, prefix: &str)
-> Result<Vec<String>, StateError>;
async fn search(&self, scope: &Scope, query: &str, limit: usize)
-> Result<Vec<SearchResult>, StateError>;
}
}
The trait is deliberately minimal: CRUD + list + search. Compaction is not part of this trait because it requires cross-protocol coordination (the lifecycle interface). Versioning is not part of this trait because not all backends support it.
StateReader is a read-only projection:
#![allow(unused)]
fn main() {
#[async_trait]
pub trait StateReader: Send + Sync {
async fn read(&self, scope: &Scope, key: &str)
-> Result<Option<serde_json::Value>, StateError>;
async fn list(&self, scope: &Scope, prefix: &str)
-> Result<Vec<String>, StateError>;
async fn search(&self, scope: &Scope, query: &str, limit: usize)
-> Result<Vec<SearchResult>, StateError>;
}
}
Every StateStore automatically implements StateReader via a blanket impl. Operators receive &dyn StateReader during context assembly – they can read but cannot write directly. Writes go through Effects in the OperatorOutput.
Protocol 4: Environment
Crate: layer0::environment
How an operator executes within an isolated context.
#![allow(unused)]
fn main() {
#[async_trait]
pub trait Environment: Send + Sync {
async fn run(
&self,
input: OperatorInput,
spec: &EnvironmentSpec,
) -> Result<OperatorOutput, EnvError>;
}
}
The Environment owns or has access to whatever it needs to execute an operator. run() takes only data (OperatorInput + EnvironmentSpec), not a function reference. For LocalEnv, the operator is an Arc<dyn Operator> stored at construction time. For a hypothetical DockerEnvironment, the input would be serialized, sent to a container, and the output deserialized.
EnvironmentSpec
#![allow(unused)]
fn main() {
pub struct EnvironmentSpec {
pub isolation: Vec<IsolationBoundary>, // Process, Container, Gvisor, MicroVm, Wasm, etc.
pub credentials: Vec<CredentialRef>, // Secrets to inject
pub resources: Option<ResourceLimits>, // CPU, memory, disk, GPU limits
pub network: Option<NetworkPolicy>, // Allow/deny rules
}
}
Interface 5: Per-Boundary Middleware
Crate: layer0::middleware
Observation and intervention at protocol boundaries. Three traits cover the three boundaries where cross-cutting logic is needed:
#![allow(unused)]
fn main() {
#[async_trait]
pub trait DispatchMiddleware: Send + Sync {
async fn on_dispatch(
&self,
operator: &OperatorId,
input: OperatorInput,
next: DispatchNext<'_>,
) -> Result<OperatorOutput, OrchError>;
}
#[async_trait]
pub trait StoreMiddleware: Send + Sync {
async fn on_read(
&self,
scope: &Scope,
key: &str,
next: StoreNext<'_>,
) -> Result<Option<serde_json::Value>, StateError>;
async fn on_write(
&self,
scope: &Scope,
key: &str,
value: serde_json::Value,
next: StoreNext<'_>,
) -> Result<(), StateError>;
}
#[async_trait]
pub trait ExecMiddleware: Send + Sync {
async fn on_exec(
&self,
input: OperatorInput,
next: ExecNext<'_>,
) -> Result<OperatorOutput, OperatorError>;
}
}
Each middleware wraps the next layer in the stack. The next parameter is a callback that invokes the rest of the middleware chain (and ultimately the real implementation). Middleware can inspect/modify inputs before calling next, inspect/modify outputs after, or short-circuit by returning early without calling next.
Middleware is composed into stacks:
DispatchStack– wraps orchestrator dispatch (budget enforcement, logging, routing)StoreStack– wraps state store access (redaction, audit logging)ExecStack– wraps operator execution (security guardrails, telemetry)
The Rule system provides typed interception within the context engine specifically, for use cases like tool-call filtering that are operator-internal rather than cross-cutting. Rules fire via Trigger enum: Before (pre-inference, pre-tool), After (post-inference, post-tool), or When (exit checks, steering).
Interface 6: Lifecycle Events
Crate: layer0::lifecycle
Cross-layer coordination events:
BudgetEvent– Emitted when cost thresholds are crossed. A hook observes cost, emits a budget event, and the orchestrator can react (cancel the workflow, notify the user, adjust limits).CompactionEvent– Coordinates context compaction between the operator and the state store.ObservableEvent– General-purpose observable events for telemetry and monitoring.
These events are the glue between protocols. They carry information across boundaries that individual protocols cannot see.