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.
Plugin Registration vs Activation
Section titled “Plugin Registration vs Activation”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 aToolPolicyHookinto the sameToolGatephase) - 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 value | Behavior |
|---|---|
| Empty (default) | All plugins’ behavioral components are active |
| Non-empty set | Only 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()}Hook Ordering and Conflict Resolution
Section titled “Hook Ordering and Conflict Resolution”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:
- Partition — Walk commands in registration order. Greedily add each command to a “compatible batch” if its
Exclusivekeys do not overlap with keys already in the batch. Otherwise, defer the hook. - Commit the batch — The compatible batch is merged and committed.
- 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.
Phase Convergence Loop
Section titled “Phase Convergence Loop”Each phase runs a GATHER then EXECUTE loop that converges when no new work remains.
GATHER stage
Section titled “GATHER stage”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)
EXECUTE stage
Section titled “EXECUTE stage”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.
Request Transform Hooks
Section titled “Request Transform Hooks”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.
Effect Handlers
Section titled “Effect Handlers”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_commandreturnsStateError::UnknownEffectHandlerimmediately, preventing silent drops.
Use cases: audit logging, metric emission, cross-plugin notification, external system synchronization.
InferenceOverride Merging
Section titled “InferenceOverride Merging”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.
ToolGate Decision Priority
Section titled “ToolGate Decision Priority”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:
| Priority | Variant | Behavior |
|---|---|---|
| 3 (highest) | Block | Terminate tool execution, fail the call |
| 2 | Suspend | Pause execution, wait for external decision |
| 1 (lowest) | SetResult | Short-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]
See Also
Section titled “See Also”- Tool and Plugin Boundary — when to use a tool vs a plugin
- Run Lifecycle and Phases — phase ordering and termination
- State and Snapshot Model — merge strategies, scoping, snapshot isolation
- Scheduled Actions Reference — action handler registration
- HITL and Mailbox — suspension and resume flow