Operators
Operators are the core execution unit in skelegent. An operator implements layer0::Operator and encapsulates everything needed to process one agent cycle: context assembly, model calls, tool execution, and output construction.
The Operator trait
#![allow(unused)]
fn main() {
#[async_trait]
pub trait Operator: Send + Sync {
async fn execute(
&self,
input: OperatorInput,
) -> Result<OperatorOutput, OperatorError>;
}
}
skelegent ships a context engine (skg-context-engine) — a set of composable primitives around react_loop — and SingleShotOperator (one model call, no tools). External consumers wrap react_loop in their own impl Operator struct for the object-safe boundary.
Context Engine
Crate: skg-context-engine
The context engine is not a monolithic struct. It is a set of composable primitives centered on react_loop(), which orchestrates the assembly → inference → reaction loop:
- Assemble context – Build the prompt from the system prompt, conversation history, tool definitions, and the new input message.
- Call the model – Send the assembled context to the provider.
- Check for tool use – If the model requested tool calls, execute them.
- Backfill results – Add tool results to the conversation context.
- Repeat – Loop back to step 2 until the model produces a final response or a limit is reached.
Construction
To use the context engine as an Operator, create a wrapper struct that holds a Provider, ToolRegistry, and ReactLoopConfig, then implement Operator by constructing a Context, injecting the user message, and calling react_loop():
#![allow(unused)]
fn main() {
use async_trait::async_trait;
use layer0::operator::{Operator, OperatorInput, OperatorOutput, OperatorError};
use layer0::context::{Message, Role};
use skg_context_engine::{Context, react_loop, ReactLoopConfig};
use skg_turn::provider::Provider;
use skg_tool::{ToolRegistry, ToolCallContext};
struct MyOperator<P: Provider> {
provider: P,
config: ReactLoopConfig,
tools: ToolRegistry,
tool_ctx: ToolCallContext,
}
#[async_trait]
impl<P: Provider> Operator for MyOperator<P> {
async fn execute(
&self,
input: OperatorInput,
) -> Result<OperatorOutput, OperatorError> {
// Context is the conversation store — create one per invocation
let mut ctx = Context::new();
// Inject domain context (shell history, file state, etc.) via assembly ops
// ctx.inject_system("Additional context here").await?;
// Inject the user input
ctx.inject_message(Message::new(Role::User, input.message))
.await
.map_err(|e| OperatorError::NonRetryable(e.to_string()))?;
// react_loop composes Context, CompileConfig, AppendResponse,
// and ExecuteTool internally — you just hand it the primitives
react_loop(&mut ctx, &self.provider, &self.tools, &self.tool_ctx, &self.config)
.await
.map_err(|e| OperatorError::NonRetryable(e.to_string()))
}
}
}
The key integration pattern:
Your domain context (shell history, file state, user prefs)
↓ feeds into
Context via inject_system(), inject_message(), or system_addendum in OperatorConfig
↓ manages
LLM conversation turns (Message with Role + Content)
↓ compiles to
CompiledContext → infer(provider) → InferResult
↓ response goes through
ContextOps (AppendResponse, ExecuteTool) → rules fire automatically
Configuration
ReactLoopConfig sets the static defaults for the loop:
| Field | Default | Description |
|---|---|---|
system_prompt | "" | Base system prompt prepended to every request |
model | None | Model identifier (e.g., Some("claude-haiku-4-5-20251001".into())) |
max_tokens | None | Max tokens per model response |
temperature | None | Sampling temperature |
These defaults can be overridden per-invocation via OperatorConfig in the OperatorInput: |
#![allow(unused)]
fn main() {
use layer0::operator::{OperatorConfig, OperatorInput, TriggerType};
use layer0::content::Content;
use rust_decimal_macros::dec;
let mut input = OperatorInput::new(
Content::text("Refactor this module"),
TriggerType::User,
);
input.config = Some(OperatorConfig {
max_turns: Some(20), // Allow more iterations
max_cost: Some(dec!(0.50)), // Budget: $0.50
model: Some("claude-sonnet-4-20250514".into()), // Use a different model
..Default::default()
});
}
Exit reasons
The context engine loop stops when:
Complete– The model produced a final text response without requesting any tool use.MaxTurns– Themax_turnslimit was reached.BudgetExhausted– Accumulated cost exceededmax_costor tool-call step limit exceeded.Timeout– Wall-clock time exceededmax_duration.InterceptorHalt { reason }– An interceptor (including a Rule that returnsRuleAction::Halt) stopped execution.CircuitBreaker– Too many consecutive failures (provider errors or tool errors).Error– An unrecoverable error occurred.SafetyStop { reason }– Provider safety system stopped generation (content filter or safety mechanism triggered).AwaitingApproval– One or more tool calls require human approval before execution.Custom(String)– Operator-defined exit reason.
Effects
The context engine supports effect-producing tools. If a tool is registered in the operator’s EffectTools configuration, calling it produces an Effect in the OperatorOutput instead of executing the tool directly. This is useful for tools that should be executed by the orchestrator or environment rather than inline (e.g., spawning a sub-agent, signaling a workflow).
SingleShotOperator
Crate: skg-op-single-shot
The single-shot operator makes exactly one model call with no tool use. It is useful for:
- Classification tasks
- Summarization
- Structured data extraction
- Any task where tool use is not needed
#![allow(unused)]
fn main() {
use skg_op_single_shot::{SingleShotConfig, SingleShotOperator};
use skg_provider_anthropic::AnthropicProvider;
let config = SingleShotConfig {
system_prompt: "Classify the following text into one of: positive, negative, neutral.".into(),
default_model: "claude-haiku-4-5-20251001".into(),
default_max_tokens: 100,
};
let provider = AnthropicProvider::new("sk-ant-...");
let operator = SingleShotOperator::new(provider, config);
}
Behavior
- Assemble context from the system prompt and input message.
- Call the model once.
- Return the response immediately.
There is no loop, no tool execution, and no iteration. The exit reason is always Complete on success.
Choosing between operators
| Use case | Operator | Why |
|---|---|---|
| Agent with tools | Context Engine | Needs the reasoning loop to call tools and iterate |
| Classification/extraction | SingleShotOperator | One model call is sufficient |
| Summarization | SingleShotOperator | No tools needed |
| Code generation with testing | Context Engine | May need to run tests, read errors, and iterate |
| Multi-step research | Context Engine | Needs to search, read, and synthesize |
Using operators as trait objects
Both operators implement layer0::Operator, which is object-safe. You can use them interchangeably behind Box<dyn Operator> or Arc<dyn Operator>:
#![allow(unused)]
fn main() {
use layer0::id::OperatorId;
use layer0::operator::Operator;
use std::sync::Arc;
let engine_op: Arc<dyn Operator> = Arc::new(my_operator);
let single_op: Arc<dyn Operator> = Arc::new(single_shot_operator);
// Orchestrator doesn't know or care which operator it's dispatching to
orchestrator.register(OperatorId::new("coder"), engine_op);
orchestrator.register(OperatorId::new("classifier"), single_op);
}
The provider’s generic type parameter is erased at the Operator boundary. Callers never see the concrete provider type.
Custom operators: Rules as extension points
The primary extension mechanism for the context engine loop is Rules. Rules fire during the react loop and can inspect context, modify messages, or halt execution. For detailed guidance on building a custom operator, see:
That guide covers:
- Implementing Rules for loop interception
- Using
ContextOpto compose assembly, inference, and reaction - Wiring domain-specific logic into the react loop
The brief example skeleton below shows the shape of a custom operator that wraps react_loop with additional rule-based behavior:
#![allow(unused)]
fn main() {
use skg_context_engine::{Context, react_loop, ReactLoopConfig};
use skg_tool::ToolRegistry;
// Build your operator struct wrapping Provider + ToolRegistry + ReactLoopConfig
// (see the Construction example above), then add Rules to the Context
// before calling react_loop() to customize loop behavior.
}