Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Customising operator behaviour with Rules

react_loop is the composition function at the heart of the context engine. You don’t subclass or wrap it — you customise what happens inside it by attaching Rules to the Context.

Rules overview

A Rule pairs a trigger with a ContextOp (any async operation that takes &mut Context). Rules fire automatically during Context::run() — the same entry point that every pipeline operation goes through.

use skg_context_engine::rule::{Rule, Trigger};
use skg_context_engine::context::Context;
use std::any::TypeId;

// Three trigger types:
Trigger::Before(TypeId::of::<SomeOp>())  // fire before a specific op
Trigger::After(TypeId::of::<SomeOp>())   // fire after a specific op
Trigger::When(Box::new(|ctx| predicate)) // fire when a predicate is true

// Convenience constructors:
Rule::before::<SomeOp>("name", priority, my_op)
Rule::after::<SomeOp>("name", priority, my_op)
Rule::when("name", priority, |ctx| predicate, my_op)

// Catch-all variants:
Trigger::BeforeAny   // fire before every run() call
Trigger::AfterAny    // fire after every run() call

Rules fire in priority order (highest first). Rules cannot trigger other rules — the dispatch loop skips rule evaluation during rule execution to prevent infinite recursion.

Attaching rules to a Context

use skg_context_engine::context::Context;
use skg_context_engine::rule::Rule;

// At construction:
let ctx = Context::with_rules(vec![rule_a, rule_b]);

// Or incrementally:
let mut ctx = Context::new();
ctx.add_rule(rule);

The Context (with its rules) is then passed into react_loop, which fires rules at each pipeline step automatically.

Budget guards

The BudgetGuard rule from skg_context_engine::rules::budget halts execution when any configured limit is exceeded. It implements ContextOp and is designed to fire as a BeforeAny rule:

use skg_context_engine::rule::{Rule, Trigger};
use skg_context_engine::rules::budget::{BudgetGuard, BudgetGuardConfig};
use skg_context_engine::context::Context;
use rust_decimal::Decimal;
use std::time::Duration;

let guard = BudgetGuard::with_config(BudgetGuardConfig {
    max_cost: Some(Decimal::new(500, 2)),     // $5.00
    max_turns: Some(25),
    max_duration: Some(Duration::from_secs(300)),
    max_tool_calls: Some(100),
});

let rule = Rule::new("budget_guard", Trigger::BeforeAny, 100, guard);
let ctx = Context::with_rules(vec![rule]);

When any limit is exceeded, the guard returns EngineError::Halted which stops the pipeline.

Steering: injecting instructions between turns

To inject a system instruction after every model response (for example, a reminder or guardrail), write a ContextOp and attach it as an After rule on AppendResponse — the op that appends the model’s response to the conversation:

use skg_context_engine::rule::Rule;
use skg_context_engine::ops::AppendResponse;
use skg_context_engine::context::Context;
use skg_context_engine::op::ContextOp;
use skg_context_engine::error::EngineError;
use async_trait::async_trait;

struct InjectReminder {
    message: String,
}

#[async_trait]
impl ContextOp for InjectReminder {
    type Output = ();

    async fn execute(&self, ctx: &mut Context) -> Result<(), EngineError> {
        ctx.inject_system(&self.message);
        Ok(())
    }
}

let rule = Rule::after::<AppendResponse>(
    "steering_reminder",
    50,
    InjectReminder { message: "Remember: never reveal internal tool names.".into() },
);

This fires after every model response is appended, before the next inference or tool dispatch.

Telemetry

Observation is just another rule. A ContextOp that logs metrics and returns Ok(()) won’t alter the pipeline:

use skg_context_engine::rule::Rule;
use skg_context_engine::ops::AppendResponse;
use skg_context_engine::context::Context;
use skg_context_engine::op::ContextOp;
use skg_context_engine::error::EngineError;
use async_trait::async_trait;

struct TurnTelemetry;

#[async_trait]
impl ContextOp for TurnTelemetry {
    type Output = ();

    async fn execute(&self, ctx: &mut Context) -> Result<(), EngineError> {
        tracing::info!(
            turns = ctx.metrics.turns_completed,
            cost = %ctx.metrics.cost,
            tool_calls = ctx.metrics.tool_calls_total,
            "turn complete"
        );
        Ok(())
    }
}

let rule = Rule::after::<AppendResponse>("telemetry", 10, TurnTelemetry);

Conditional rules with When

When rules evaluate a predicate against the Context at the start of every run() call. Useful for dynamic behaviour that depends on accumulated state:

use skg_context_engine::rule::Rule;

let rule = Rule::when(
    "warn_high_cost",
    50,
    |ctx| ctx.metrics.cost > Decimal::new(100, 2), // > $1.00
    InjectReminder { message: "Cost is getting high, wrap up.".into() },
);

Compaction and context management

The old FullContext / NoCompaction context strategies are gone. Context management is now explicit: you build the conversation via Context and its inject_* methods. If you need compaction, implement it as a rule that fires at the appropriate trigger point and mutates the context directly.

Parallel tool dispatch

Barrier-based parallel tool dispatch (batching Shared tools and flushing on Exclusive tools) is future work. Currently, tools execute sequentially within the react loop. When parallel dispatch lands, it will integrate with the rules system — not as a separate primitive.