Skip to content

Plugin System Internals

This page covers the internal mechanics of the plugin system: how plugins are registered and activated, how hooks execute and resolve conflicts, how the phase convergence loop works, and how request transforms, effects, inference overrides, and tool intercepts behave at runtime.

For the high-level boundary between tools and plugins, see Tool and Plugin Boundary. For the phase lifecycle, see Run Lifecycle and Phases.

When a plugin is loaded, its register() method is called with a PluginRegistrar. The plugin declares two categories of components:

Structural components are always available regardless of activation state:

  • State keys (register_key::<K>(options))
  • Profile keys (register_profile_key::<K>())
  • Scheduled action handlers (register_scheduled_action::<A, H>())
  • Effect handlers (register_effect::<E, H>())

Behavioral components are only active when the plugin passes the activation filter:

  • Tool gate hooks (register_tool_gate_hook())
  • Typed tool policy hooks (register_tool_policy_hook() — wraps a ToolPolicyHook into the same ToolGate phase)
  • Phase hooks (register_phase_hook())
  • Tools (register_tool())
  • Request transforms (register_request_transform())

register_request_transform() is fire-and-forget (no validation Result); the other registration methods return Result<(), StateError> so duplicates fail at registration time.

Activation is controlled by AgentSpec.active_hook_filter:

active_hook_filter valueBehavior
Empty (default)All plugins’ behavioral components are active
Non-empty setOnly plugins whose ID is in the set contribute behavioral components

This separation enables infrastructure plugins (state management, action handlers, effect handlers) to exist without impacting execution flow. A logging plugin that only registers an effect handler, for example, never needs to appear in active_hook_filter — its handler fires whenever any plugin emits the corresponding effect.

The filtering happens in PhaseRuntime::filter_hooks():

fn filter_hooks<'a>(env: &'a ExecutionEnv, ctx: &PhaseContext) -> Vec<&'a TaggedPhaseHook> {
let hooks = env.hooks_for_phase(ctx.phase);
let active_hook_filter = &ctx.agent_spec.active_hook_filter;
hooks
.iter()
.filter(|tagged| {
active_hook_filter.is_empty()
|| active_hook_filter.contains(&tagged.plugin_id)
})
.collect()
}

Multiple plugins can register hooks for the same Phase. The engine runs them through a two-stage process implemented in gather_and_commit_hooks() (crates/awaken-runtime/src/phase/engine.rs).

Fast path: parallel execution, single commit

Section titled “Fast path: parallel execution, single commit”

All hooks for a phase run in parallel against a frozen snapshot. Each hook receives the same snapshot and produces a StateCommand. If no hook writes to a key with MergeStrategy::Exclusive that another hook also writes to, all commands are merged and committed in a single batch.

flowchart LR
    S[Frozen Snapshot] --> H1[Hook A]
    S --> H2[Hook B]
    S --> H3[Hook C]
    H1 --> M[Merge All]
    H2 --> M
    H3 --> M
    M --> C[Single Commit]

Conflict fallback: partition and serial retry

Section titled “Conflict fallback: partition and serial retry”

If two or more hooks write to the same Exclusive key, the engine detects the conflict and falls back:

  1. Partition — Walk commands in registration order. Greedily add each command to a “compatible batch” if its Exclusive keys do not overlap with keys already in the batch. Otherwise, defer the hook.
  2. Commit the batch — The compatible batch is merged and committed.
  3. Serial re-execution — Deferred hooks are re-run one at a time, each against a fresh snapshot that includes the results of all prior commits.
flowchart TD
    PAR[Run all hooks in parallel] --> CHECK{Exclusive key overlap?}
    CHECK -- No --> FAST[Merge all, commit once]
    CHECK -- Yes --> PART[Partition: batch + deferred]
    PART --> CBATCH[Commit compatible batch]
    CBATCH --> SERIAL[Re-run deferred hooks serially]
    SERIAL --> DONE[All hooks committed]

Because hooks are pure functions (frozen snapshot in, StateCommand out, no side effects), re-execution on conflict is always safe. The deferred hooks see the updated state from the batch commit, so they produce correct results even when the original parallel execution would have conflicted.

See State and Snapshot Model for details on MergeStrategy and snapshot isolation.

Each phase runs a GATHER then EXECUTE loop that converges when no new work remains.

Run all active hooks in parallel (with conflict resolution as described above). Hooks produce StateCommand values that may contain:

  • State mutations (key updates)
  • Scheduled actions (to be processed in the EXECUTE stage)
  • Effects (dispatched immediately after commit)

Process pending scheduled actions whose Phase matches the current phase and whose action key has a registered handler. Each action handler runs against a fresh snapshot and produces its own StateCommand, which may schedule new actions for the same phase.

If new matching actions appear after processing, the loop repeats:

flowchart TD
    START[Phase Entry] --> GATHER[GATHER: run hooks, commit]
    GATHER --> EXEC[EXECUTE: process matching actions]
    EXEC --> CHECK{New actions for this phase?}
    CHECK -- Yes --> EXEC
    CHECK -- No --> DONE[Phase complete]

The loop is bounded by DEFAULT_MAX_PHASE_ROUNDS (16). If the action count does not converge within this limit, the engine returns StateError::PhaseRunLoopExceeded. This prevents infinite reactive chains while allowing legitimate multi-step action cascades.

This convergence design enables reactive patterns: a permission check action can schedule a suspend action in the same BeforeToolExecute phase, and both are processed before the phase completes.

Plugins can register InferenceRequestTransform implementations via registrar.register_request_transform(). Transforms modify the InferenceRequest before it reaches the LLM executor.

Use cases:

  • System prompt injection — append context, instructions, or reminders to the system message
  • Tool list modification — filter, reorder, or augment the tool descriptors sent to the LLM
  • Parameter overrides — adjust temperature, max tokens, or other inference parameters

Transforms run in registration order and are composable: each transform receives the request as modified by the previous transform.

Only active plugins’ transforms are applied. If active_hook_filter is non-empty, transforms from plugins not in the filter are skipped.

Effects are typed events defined via the EffectSpec trait:

pub trait EffectSpec: 'static + Send + Sync {
const KEY: &'static str;
type Payload: Serialize + DeserializeOwned + Send + Sync + 'static;
}

Hooks and action handlers emit effects by calling command.emit::<E>(payload) on their StateCommand. Unlike scheduled actions (which execute within a specific phase’s convergence loop), effects are dispatched after the command is committed to the store.

Plugins register effect handlers via registrar.register_effect::<E, H>(handler). When an effect is dispatched, the engine calls the handler with the effect payload and the current snapshot.

Key properties:

  • Fire-and-forget — handler failures are logged but do not block execution or roll back the commit.
  • Post-commit — effects see the state after the command that emitted them has been applied.
  • Validated at submit time — if a command emits an effect with no registered handler, submit_command returns StateError::UnknownEffectHandler immediately, preventing silent drops.

Use cases: audit logging, metric emission, cross-plugin notification, external system synchronization.

Multiple plugins can influence inference parameters by scheduling SetInferenceOverride actions. The InferenceOverride struct uses last-wins-per-field merge semantics:

pub struct InferenceOverride {
pub upstream_model: Option<String>,
pub temperature: Option<f64>,
pub max_tokens: Option<u32>,
pub top_p: Option<f64>,
pub reasoning_effort: Option<ReasoningEffort>,
}

upstream_model is an upstream model name for the already resolved provider; it does not switch provider executors. Use model pools for model failover. See Provider and Model Configuration.

When two overrides are merged, each field independently takes the last non-None value:

pub fn merge(&mut self, other: InferenceOverride) {
if other.temperature.is_some() {
self.temperature = other.temperature;
}
if other.max_tokens.is_some() {
self.max_tokens = other.max_tokens;
}
// ... same for all fields
}

This allows plugins to override specific parameters without affecting others. A cost-control plugin can set max_tokens while a quality plugin sets temperature, and neither interferes with the other. If both set the same field, the last merge wins.

During ToolGate, plugins implement ToolGateHook and return an optional ToolInterceptPayload to control tool execution flow:

pub enum ToolInterceptPayload {
Block { reason: String },
Suspend(SuspendTicket),
SetResult(ToolResult),
}

When multiple gate hooks return a decision for the same tool call, they are resolved by implicit priority:

PriorityVariantBehavior
3 (highest)BlockTerminate tool execution, fail the call
2SuspendPause execution, wait for external decision
1 (lowest)SetResultShort-circuit with a predefined result

The highest-priority decision wins. If two decisions have the same priority (e.g., two plugins both return Block), the first one processed takes effect and the conflict is logged as an error.

If no hook returns a decision, the tool executes normally. ToolGate is pure and may be re-evaluated after earlier allowed tool calls commit new state in the same step; BeforeToolExecute runs only once a call is finally allowed.

flowchart TD
    TG[ToolGate hooks run] --> INT{Any decision returned?}
    INT -- No --> EXEC[Execute tool normally]
    INT -- Yes --> PRI[Resolve by priority]
    PRI --> B[Block: fail the call]
    PRI --> S[Suspend: wait for decision]
    PRI --> R[SetResult: return predefined result]