Normandy.Behaviours.Hooks behaviour
(normandy v2.2.0)
View Source
Lifecycle hooks: modules an agent lists on Normandy.Behaviours.Config :hooks as
{module, opts} entries, run in list order.
Every callback is optional; an entry whose module does not export an event's callback is skipped for that event. A hook exists where it can change an outcome (rewrite, halt, inject) or must run application code in-process; to only observe, attach to telemetry. Hooks run inline, with no timeout: a slow hook stalls its turn, so hand slow side effects to a process of your own.
use Normandy.Behaviours.Hooks sets @behaviour and checks at compile time that a
public function named like an event has that event's arity (ArgumentError otherwise),
and warns for a public function whose name is close to an event's (on_promt).
Return values, checked only where the library consumes them:
| Callback | Pass | Change | Stop |
|---|---|---|---|
on_prompt/3 | {:cont, input} | {:cont, new_input} | {:halt, reason} |
before_llm/3 | :cont | — | {:halt, reason} |
before_tool/3 | {:cont, call} | {:cont, new_call} | {:halt, reason} or {:halt, %ToolResult{}} |
after_tool/4 | {:cont, result} | {:cont, new_result} | — |
after_compact/4, on_session_start/2 | any value | {:inject, [String.t()]} | — |
on_turn_end/3, on_passivate/2 | any value (ignored) | — | — |
on_turn_end/3 receives {:ok, response}, {:rejected, exception} (a schema,
input-guardrail or output-guardrail rejection, or an on_prompt halt) or
{:error, exception}. The shells differ only for a failure that is not Normandy's own:
under BaseAgent a raised exception (a crashing client's RuntimeError) arrives bare
and an exit or throw as a :task_down Normandy.TurnError; under Turn.Server each
arrives as the Normandy.TurnError that run/2 replies (:task_down or
:handler_crashed).
opts must be plain data (no function, pid, reference or port) and hold no secrets: the
list is saved in the session's plaintext config template. :on_error (:reraise
default, :open, :closed) is reserved and removed before a callback sees opts. See
docs/guides/hooks.md.
Summary
Callbacks
Runs after a compaction that removed messages, with dropped, the removed
Normandy.Components.AgentMemory.Entry structs (oldest first), and meta, the
compactor's metadata without :dropped.
Runs on every tool result the model will see, once, with the call and its
Normandy.Components.ToolResult.
Runs before each model call, with %{final: boolean, iterations_left: integer}.
Runs before each tool call, with the model's Normandy.Components.ToolCall.
Runs in Turn.Server before an idle session stops.
Runs on a turn's validated input, before input guardrails.
Runs when Turn.Server starts a session whose store holds no history, once per start.
Runs at the end of a turn, with {:ok, response}, {:rejected, exception} or
{:error, exception}.
Optional: checks the entry's opts (without :on_error) when hook entries are validated,
in BaseAgent.init/1 and when Turn.Server loads a session.
Functions
validate_entries!/1 as data, for Turn.Server: an invalid list (a hook module missing
on this node, say) is reported like a store fault instead of crashing the start.
The event callbacks, as {name, arity}.
Raises ArgumentError naming the first malformed entry. With load: false (DSL compile
time and ConfigTemplate.from_config/2, where the module may not be loaded) only the
entry shape, :on_error and the plain-data rule are checked.
Types
@type opts() :: keyword()
@type outcome() :: {:ok, response :: term()} | {:error, Exception.t()} | {:rejected, Exception.t()}
Callbacks
@callback after_compact( dropped :: [Normandy.Components.AgentMemory.Entry.t()], meta :: map(), Normandy.Hooks.Context.t(), opts() ) :: {:inject, [String.t()]} | term()
Runs after a compaction that removed messages, with dropped, the removed
Normandy.Components.AgentMemory.Entry structs (oldest first), and meta, the
compactor's metadata without :dropped.
Return {:inject, [text]} to append each text to the conversation as a user message
before the next model call. {:inject, texts} with anything but a list of strings raises
ArgumentError, whatever :on_error says; any other return value is ignored. Under
Turn.Server the compacted conversation, with the injected texts, is stored, so a restart
does not report the same messages again; if that write failed they are reported again under
the same entry ids, so deduplicate on entry.id.
@callback after_tool( Normandy.Components.ToolCall.t(), Normandy.Components.ToolResult.t(), Normandy.Hooks.Context.t(), opts() ) :: {:cont, Normandy.Components.ToolResult.t()}
Runs on every tool result the model will see, once, with the call and its
Normandy.Components.ToolResult.
Return {:cont, result} with the result to send, changed or not. That includes the error
results of a denied, halted or failed call. Any other return value raises ArgumentError.
@callback before_llm( %{final: boolean(), iterations_left: integer()}, Normandy.Hooks.Context.t(), opts() ) :: :cont | {:halt, reason :: String.t()}
Runs before each model call, with %{final: boolean, iterations_left: integer}.
Return :cont to make the call, or {:halt, reason} to end the turn with a
Normandy.HookHaltError without making it. A halt is not retried. Any other return value
raises ArgumentError.
@callback before_tool( Normandy.Components.ToolCall.t(), Normandy.Hooks.Context.t(), opts() ) :: {:cont, Normandy.Components.ToolCall.t()} | {:halt, String.t() | Normandy.Components.ToolResult.t()}
Runs before each tool call, with the model's Normandy.Components.ToolCall.
Return {:cont, call} to run the tool with the call, changed or not. Return
{:halt, reason} or {:halt, %Normandy.Components.ToolResult{}} to skip the tool: the
result, or an error result carrying reason, answers the call. A halt ends only this
call, not the turn. Any other return value raises ArgumentError.
@callback on_passivate(Normandy.Hooks.Context.t(), opts()) :: term()
Runs in Turn.Server before an idle session stops.
The return value is ignored. A crash is logged, whatever :on_error says.
@callback on_prompt(input :: term(), Normandy.Hooks.Context.t(), opts()) :: {:cont, term()} | {:halt, reason :: String.t()}
Runs on a turn's validated input, before input guardrails.
Return {:cont, input} to pass on the input, changed or not; a changed input is validated
again. Return {:halt, reason} to end the turn with a Normandy.HookHaltError before any
model call. Any other return value raises ArgumentError.
@callback on_session_start(Normandy.Hooks.Context.t(), opts()) :: {:inject, [String.t()]} | term()
Runs when Turn.Server starts a session whose store holds no history, once per start.
Return {:inject, [text]} to add each text to the conversation as a user message; the
texts are stored and survive a restart. {:inject, texts} with anything but a list of
strings raises ArgumentError, whatever :on_error says; any other return value is
ignored. A crash is handled by the entry's :on_error; under :reraise the session waits :unavailable and
retries the start.
@callback on_turn_end(outcome(), Normandy.Hooks.Context.t(), opts()) :: term()
Runs at the end of a turn, with {:ok, response}, {:rejected, exception} or
{:error, exception}.
Turn.Server runs it after it has replied to run/2; BaseAgent.run/3 runs it before it
returns.
The return value is ignored. A crash is logged, whatever :on_error says.
Optional: checks the entry's opts (without :on_error) when hook entries are validated,
in BaseAgent.init/1 and when Turn.Server loads a session.
Raise on a bad option; any exception is re-raised as an ArgumentError that names the
hook module. The return value is ignored.
Functions
validate_entries!/1 as data, for Turn.Server: an invalid list (a hook module missing
on this node, say) is reported like a store fault instead of crashing the start.
@spec events() :: [{atom(), non_neg_integer()}]
The event callbacks, as {name, arity}.
Raises ArgumentError naming the first malformed entry. With load: false (DSL compile
time and ConfigTemplate.from_config/2, where the module may not be loaded) only the
entry shape, :on_error and the plain-data rule are checked.