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

Scheduled Actions

Scheduled actions are the primary mechanism for plugins and tools to request side-effects during a phase execution cycle. Any hook, tool, or external module can schedule an action via StateCommand::schedule_action::<A>(payload). The runtime dispatches the action to its registered handler during the EXECUTE stage of the target phase.

How it works

Hook / Tool                    Runtime
    |                            |
    |-- StateCommand ----------->|  (contains scheduled_actions)
    |   schedule_action::<A>(p)  |
    |                            |-- commit state updates
    |                            |-- dispatch to handler(A, p)
    |                            |      |
    |                            |      |-- handler returns StateCommand
    |                            |      |   (may schedule more actions)
    |                            |<-----'
    |                            |-- commit handler results

Scheduling from a hook

use awaken_runtime::agent::state::ExcludeTool;

async fn run(&self, ctx: &PhaseContext) -> Result<StateCommand, StateError> {
    let mut cmd = StateCommand::new();
    cmd.schedule_action::<ExcludeTool>("dangerous_tool".into())?;
    Ok(cmd)
}

Scheduling from a tool

use awaken_runtime::agent::state::AddContextMessage;
use awaken_contract::contract::context_message::ContextMessage;

async fn execute(&self, args: Value, ctx: &ToolCallContext) -> Result<ToolOutput, ToolError> {
    let mut cmd = StateCommand::new();
    cmd.schedule_action::<AddContextMessage>(
        ContextMessage::system("my_tool.hint", "Remember to check the docs."),
    )?;
    Ok(ToolOutput::with_command(
        ToolResult::success("my_tool", json!({"ok": true})),
        cmd,
    ))
}

Core Actions (awaken-runtime)

These are always available. Registered by the internal LoopActionHandlersPlugin.

AddContextMessage

Keyruntime.add_context_message
PhaseBeforeInference
PayloadContextMessage
Importawaken_runtime::agent::state::AddContextMessage

Injects a context message into the LLM conversation for the current step. Messages can be persistent (survive across steps), ephemeral (one-shot), or throttled (cooldown-based).

Used by: skills plugin (skill catalog), reminder plugin (rule-based hints), deferred-tools plugin (deferred tool list), custom hooks.

cmd.schedule_action::<AddContextMessage>(
    ContextMessage::system_persistent("my_plugin.info", "Always verify inputs."),
)?;

SetInferenceOverride

Keyruntime.set_inference_override
PhaseBeforeInference
PayloadInferenceOverride
Importawaken_runtime::agent::state::SetInferenceOverride

Overrides inference parameters (model, temperature, max_tokens, top_p) for the current step only. Multiple overrides are merged; last-writer-wins per field.

cmd.schedule_action::<SetInferenceOverride>(InferenceOverride {
    temperature: Some(0.0),  // force deterministic
    ..Default::default()
})?;

ExcludeTool

Keyruntime.exclude_tool
PhaseBeforeInference
PayloadString (tool ID)
Importawaken_runtime::agent::state::ExcludeTool

Removes a tool from the set offered to the LLM for the current step. Multiple exclusions are additive.

Used by: permission plugin (unconditionally denied tools), deferred-tools plugin (deferred tools replaced by ToolSearch).

cmd.schedule_action::<ExcludeTool>("rm".into())?;

IncludeOnlyTools

Keyruntime.include_only_tools
PhaseBeforeInference
PayloadVec<String> (tool IDs)
Importawaken_runtime::agent::state::IncludeOnlyTools

Restricts the tool set to only the listed IDs. Multiple IncludeOnlyTools actions are unioned. Combined with ExcludeTool, exclusions are applied after the include-only filter.

cmd.schedule_action::<IncludeOnlyTools>(vec!["search".into(), "calculator".into()])?;

Tool interception is not modeled as a scheduled action anymore. Implement ToolGateHook and register it with PluginRegistrar::register_tool_gate_hook() when you need to block, suspend, or short-circuit tool calls before execution.


Deferred-Tools Actions (awaken-ext-deferred-tools)

Available when the deferred-tools plugin is active.

DeferToolAction

Keydeferred_tools.defer
PhaseBeforeInference
PayloadVec<String> (tool IDs)
Importawaken_ext_deferred_tools::state::DeferToolAction

Moves tools from eager to deferred mode. Deferred tools are excluded from the LLM tool set and made available via ToolSearch instead, reducing prompt token usage.

The handler updates DeferralState, setting each tool’s mode to Deferred.

cmd.schedule_action::<DeferToolAction>(vec!["rarely_used_tool".into()])?;

PromoteToolAction

Keydeferred_tools.promote
PhaseBeforeInference
PayloadVec<String> (tool IDs)
Importawaken_ext_deferred_tools::state::PromoteToolAction

Moves tools from deferred to eager mode. Promoted tools are included in the LLM tool set for subsequent steps.

The handler updates DeferralState, setting each tool’s mode to Eager.

Typically triggered automatically when ToolSearch returns results, but can be scheduled manually by any plugin or tool.

cmd.schedule_action::<PromoteToolAction>(vec!["needed_tool".into()])?;

Plugin Action Usage Matrix

Which plugins schedule which actions:

PluginAddContextSetOverrideExcludeIncludeOnlyDeferPromote
permissionX
skillsX
reminderX
deferred-toolsXXXX
observability
mcp
generative-ui

Defining Custom Actions

Plugins can define their own actions by implementing ScheduledActionSpec and registering a handler via PluginRegistrar::register_scheduled_action.

use awaken_contract::model::{Phase, ScheduledActionSpec};

pub struct MyCustomAction;

impl ScheduledActionSpec for MyCustomAction {
    const KEY: &'static str = "my_plugin.custom_action";
    const PHASE: Phase = Phase::BeforeInference;
    type Payload = MyPayload;
}

Then in your plugin’s register():

fn register(&self, r: &mut PluginRegistrar) -> Result<(), StateError> {
    r.register_scheduled_action::<MyCustomAction, _>(MyHandler)?;
    Ok(())
}

Other plugins and tools can then schedule your action:

cmd.schedule_action::<MyCustomAction>(my_payload)?;

Convergence and cascading

Scheduled actions execute within the phase convergence loop. After each round of action dispatch, the runtime checks whether new actions were produced. If so, the loop repeats to process them.

How the loop works

Phase EXECUTE stage:
  round 1: dispatch queued actions -> handlers return StateCommands
           commit state, collect newly scheduled actions
  round 2: dispatch new actions    -> handlers may schedule more
           ...
  round N: no new actions          -> phase converges, loop exits

An action handler can schedule new actions for the same phase, which causes another round. This enables cascading behaviors (e.g., a handler adds a context message, which triggers a filter action from another plugin).

Limits

The loop is bounded by DEFAULT_MAX_PHASE_ROUNDS (16). If actions are still being produced after 16 rounds, the runtime returns a StateError::PhaseRunLoopExceeded error with the phase name and round count. This prevents infinite loops from misconfigured or recursive handlers.

Failed actions

When an action handler returns an error, the action is not retried. Instead, it is recorded in the FailedScheduledActions state key, which holds a list of FailedScheduledAction entries (action key, payload, and error message). Plugins or tests can inspect this key to detect handler failures.

let failed = store.read::<FailedScheduledActions>().unwrap_or_default();
assert!(failed.is_empty(), "expected no failed actions");

See Plugin Internals for the full convergence loop description and phase execution model.