Lifecycle Callbacks

phi-core provides four tiers of lifecycle callbacks that let you observe and control the agent loop without modifying its internals. Loop-level, turn-level, and tool-level callbacks are set on AgentLoopConfig (or via Agent builder methods). Session-level callbacks (before_task / after_task) are set on SessionRecorderConfig.

0.9.0 — async hook bodies. All loop-level, turn-level, and the non-update tool-level hooks below (plus the two compaction hooks) are now async. The on_* builders on BasicAgent accept closures whose bodies return Pin<Box<dyn Future<Output = T> + Send>> — wrap sync bodies in Box::pin(async move { ... }), or .await LLM and other async work directly. The two tool-update hooks (before_tool_execution_update / after_tool_execution_update) stay sync — see the note next to their sections for the rationale. CHANGELOG [0.9.0] § Migration carries the full mechanical recipe.

Tiers Overview

TierHooksScope
Session-levelbefore_task, after_taskOnce per session (on SessionRecorderConfig)
Loop-levelbefore_loop, after_loopOnce per agent_loop() / agent_loop_continue() call
Turn-levelbefore_turn, after_turn, on_errorOnce per LLM call (every turn)
Tool-levelbefore_tool_execution, after_tool_execution, before_tool_execution_update, after_tool_execution_updateOnce per tool call

Loop-Level Hooks

before_loop

Called once before AgentStart is emitted. Receives the current message history and an initial usage counter of 0. Return false to abort the entire run — AgentEnd is emitted with an empty message list and the loop exits immediately.

#![allow(unused)]
fn main() {
let agent = BasicAgent::new(ModelConfig::anthropic("claude-sonnet-4-20250514", "Claude Sonnet 4", &api_key))
    .on_before_loop(|messages, _usage| {
        println!("Starting run with {} existing messages", messages.len());
        true // return false to abort
    });
}

after_loop

Called once after AgentEnd is emitted. Receives the new messages produced during the run and the accumulated Usage across all turns.

#![allow(unused)]
fn main() {
let agent = BasicAgent::new(ModelConfig::anthropic("claude-sonnet-4-20250514", "Claude Sonnet 4", &api_key))
    .on_after_loop(|new_messages, total_usage| {
        println!(
            "Run complete: {} new messages, {} total tokens",
            new_messages.len(),
            total_usage.total_tokens
        );
    });
}

Turn-Level Hooks

before_turn

Called before each LLM call. Receives the current message history and the turn number (0-indexed). Return false to abort the loop.

#![allow(unused)]
fn main() {
let agent = BasicAgent::new(ModelConfig::anthropic("claude-sonnet-4-20250514", "Claude Sonnet 4", &api_key))
    .on_before_turn(|messages, turn| {
        println!("Turn {} starting with {} messages", turn, messages.len());
        turn < 10 // Stop after 10 turns
    });
}

after_turn

Called after each LLM response and tool execution. Receives the updated message history and the turn's token usage.

#![allow(unused)]
fn main() {
use std::sync::{Arc, Mutex};

let total_cost = Arc::new(Mutex::new(0u64));
let cost_tracker = total_cost.clone();

let agent = BasicAgent::new(ModelConfig::anthropic("claude-sonnet-4-20250514", "Claude Sonnet 4", &api_key))
    .on_after_turn(move |_messages, usage| {
        let mut cost = cost_tracker.lock().unwrap();
        *cost += usage.input + usage.output;
        println!("Cumulative tokens: {}", *cost);
    });
}

on_error

Called when the LLM returns a StopReason::Error. Receives the error message string.

#![allow(unused)]
fn main() {
let agent = BasicAgent::new(ModelConfig::anthropic("claude-sonnet-4-20250514", "Claude Sonnet 4", &api_key))
    .on_error(|err| {
        eprintln!("LLM error: {}", err);
        // Log to monitoring, send alert, etc.
    });
}

Tool-Level Hooks

before_tool_execution

Called before each tool starts, after the ToolExecutionStart event would normally emit. Receives the tool name, call ID, and arguments. Return false to skip the tool — a ToolExecutionEnd with an error result is emitted and the tool's execute() is never called.

#![allow(unused)]
fn main() {
let agent = BasicAgent::new(ModelConfig::anthropic("claude-sonnet-4-20250514", "Claude Sonnet 4", &api_key))
    .on_before_tool_execution(|name, call_id, _args| {
        println!("About to run tool: {}", name);
        // Return false to block specific tools:
        name != "bash" // block bash, allow everything else
    });
}

after_tool_execution

Called after each tool finishes (after ToolExecutionEnd is emitted). Receives the tool name, call ID, and whether the result was an error.

#![allow(unused)]
fn main() {
let agent = BasicAgent::new(ModelConfig::anthropic("claude-sonnet-4-20250514", "Claude Sonnet 4", &api_key))
    .on_after_tool_execution(|name, call_id, is_error| {
        if is_error {
            eprintln!("Tool {} ({}) failed", name, call_id);
        }
    });
}

before_tool_execution_update (sync — see note below)

Called before each ToolExecutionUpdate event (streaming progress from a running tool). Return false to suppress the event — the tool keeps running and the final ToolResult is unaffected; only the intermediate streaming update is dropped.

Pre-existing-behaviour preservation note (phi-core 0.9.0). The two tool-update hooks (before_tool_execution_update / after_tool_execution_update) remain sync after the 0.9.0 async-trait migration. Async-ifying them would cascade into the ToolUpdateFn callback type and every AgentTool::execute body that invokes ctx.on_update(...) — materially wider than the 0.9.0 scope. The veto decision in before_tool_execution_update must be synchronous so the surrounding emit gate works without an .await suspension at every streamed tool-update. Async work at update-time should be dispatched via tokio::spawn(...) inside the sync closure body. Tracked in the CHANGELOG [Unreleased] "Forward markers" for a future release.

#![allow(unused)]
fn main() {
let agent = BasicAgent::new(ModelConfig::anthropic("claude-sonnet-4-20250514", "Claude Sonnet 4", &api_key))
    .on_before_tool_execution_update(|name, call_id, text| {
        // Only forward updates for bash tool
        name == "bash"
    });
}

after_tool_execution_update

Called after each ToolExecutionUpdate event, only if it was not suppressed by before_tool_execution_update.

#![allow(unused)]
fn main() {
let agent = BasicAgent::new(ModelConfig::anthropic("claude-sonnet-4-20250514", "Claude Sonnet 4", &api_key))
    .on_after_tool_execution_update(|name, call_id, text| {
        // e.g., log streaming updates to a file
    });
}

Script Callbacks

In addition to Rust closures, callbacks can be implemented as external shell or Python scripts. This allows non-Rust consumers to hook into the agent lifecycle without compiling Rust code.

Script callbacks are specified as command strings (e.g., "./scripts/on_task_start.sh" or "python3 scripts/after_turn.py"). The agent loop spawns the script as a subprocess, passing relevant context (such as session ID, turn number, or tool name) as environment variables or arguments. The script's exit code determines whether the action proceeds (0 = continue, non-zero = abort, for Before* hooks).

Script callbacks can be configured in the [callbacks] section of the config file or set programmatically via the Agent trait.

All callback tiers are wired in the script callback bridge. Loop-level (before_loop, after_loop), tool-level (before_tool_execution, after_tool_execution), compaction-level (before_compaction_start, after_compaction_end), and turn-level (before_turn, after_turn) hooks are all resolved from the [callbacks] config section and bridged to external scripts. The bridge passes hook context as JSON (message count, turn index, tool name, etc.) via stdin to the subprocess.


Hook Ordering

The hooks fire in strict order relative to their paired events. This ordering is an invariant — it is enforced at runtime:

before_loop
  → AgentStart
    before_turn
      → TurnStart
        [MessageStart / MessageUpdate* / MessageEnd]
        [per tool call:]
          before_tool_execution
            → ToolExecutionStart
              (before_tool_execution_update → ToolExecutionUpdate → after_tool_execution_update)*
            ToolExecutionEnd →
          after_tool_execution
        [if context budget exceeded:]
          before_compaction_start
            → CompactionStarted
            CompactionEnded →
          after_compaction_end
      TurnEnd →
    after_turn
  AgentEnd →
after_loop

Short-Circuit Rules

Hook returns falseEffect
before_loopAborts before AgentStart; emits AgentEnd(messages=[])
before_turnSkips turn; neither TurnStart nor TurnEnd is emitted
before_tool_executionSkips tool; emits error ToolExecutionEnd without calling execute()
before_tool_execution_updateSuppresses ToolExecutionUpdate; tool keeps running; ToolResult unaffected

Steering Checkpoints

Steering messages (injected via the agent's steering queue) are checked at six specific points in the turn cycle. These checkpoints give the caller opportunities to redirect the agent mid-run without waiting for the current loop iteration to complete.

The Six Checkpoints

  1. Before turn -- After before_turn fires, before the LLM call. The steering message is prepended to the message history as a User message before the model sees it.
  2. After turn -- After the LLM response is received and after_turn fires. Steering is appended before the next turn begins.
  3. Between tool executions (Sequential) -- When tool_strategy = "sequential", the steering queue is checked between each individual tool call. This is the finest-grained checkpoint.
  4. Between batches (Batched) -- When tool_strategy = "batched", the steering queue is checked after each batch completes, before the next batch starts.
  5. After all tools (Parallel) -- When tool_strategy = "parallel", steering is checked once after all tool calls complete. No mid-batch interruption.
  6. On loop re-entry -- At the top of each loop iteration, before before_turn fires.

Per-Strategy Behavior

StrategyWhen steering is checkedGranularity
SequentialBetween each tool callPer-tool
BatchedAfter each batch completesPer-batch
ParallelAfter all tools completePost-batch

In all strategies, checkpoints 1, 2, and 6 always apply. The strategy only affects when steering is checked during tool execution (checkpoints 3-5).

Why Mid-Stream and Mid-Tool Steering Is Not Supported

Steering is intentionally not checked:

  • During an LLM streaming response -- The SSE stream is atomic from the agent loop's perspective. Interrupting a partial response would produce an inconsistent message (partial assistant text with no stop reason). The model's response must complete or fail before steering can take effect.
  • During a single tool's execution -- A tool call is an atomic unit. Interrupting a bash command mid-execution or a file write mid-stream would leave the environment in an undefined state. The tool must return its ToolResult before steering is considered.

These boundaries are not limitations but invariants that keep the message history and environment consistent.

Hard Abort with CancellationToken

For cases where waiting for the next steering checkpoint is unacceptable (e.g., runaway tool, user-initiated cancel), CancellationToken provides a hard abort:

#![allow(unused)]
fn main() {
use tokio_util::sync::CancellationToken;

let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();

// In another task:
cancel_clone.cancel(); // triggers immediate abort
}

When the token is cancelled:

  • The current LLM stream is dropped (partial response discarded)
  • Running tools are cancelled via their async cancellation
  • The loop emits AgentEnd with StopReason::Aborted
  • No further turns or tool calls are attempted

CancellationToken is a last resort. Prefer steering for graceful redirection; use cancellation only when the agent must stop immediately.


Combining Callbacks

All callbacks are optional and independent:

#![allow(unused)]
fn main() {
let agent = BasicAgent::new(ModelConfig::anthropic("claude-sonnet-4-20250514", "Claude Sonnet 4", &api_key))
    .on_before_loop(|_msgs, _| true)
    .on_after_loop(|msgs, usage| {
        println!("Done: {} messages, {} tokens", msgs.len(), usage.total_tokens);
    })
    .on_before_turn(|_msgs, turn| turn < 20)
    .on_after_turn(|msgs, usage| {
        println!("Messages: {}, Tokens: {}/{}", msgs.len(), usage.input, usage.output);
    })
    .on_error(|err| eprintln!("Error: {}", err))
    .on_before_tool_execution(|name, _id, _args| {
        println!("Running: {}", name);
        true
    })
    .on_after_tool_execution(|name, _id, is_error| {
        println!("Tool {} finished (error={})", name, is_error);
    });
}

Using with AgentLoopConfig

For direct loop usage without the Agent wrapper:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use phi_core::agent_loop::AgentLoopConfig;
use phi_core::provider::ModelConfig;

let config = AgentLoopConfig {
    model_config: ModelConfig::anthropic("claude-sonnet-4-20250514", "Claude Sonnet 4", &api_key),
    // Loop-level
    before_loop: Some(Arc::new(|_msgs, _| true)),
    after_loop: Some(Arc::new(|msgs, usage| { /* log */ })),
    // Turn-level
    before_turn: Some(Arc::new(|_msgs, turn| turn < 5)),
    after_turn: Some(Arc::new(|_msgs, _usage| { /* log */ })),
    on_error: Some(Arc::new(|err| eprintln!("{}", err))),
    // Tool-level
    before_tool_execution: Some(Arc::new(|name, id, args| true)),
    after_tool_execution: Some(Arc::new(|name, id, is_error| {})),
    before_tool_execution_update: Some(Arc::new(|name, id, text| true)),
    after_tool_execution_update: Some(Arc::new(|name, id, text| {})),
    ..Default::default()
};
}