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:

CallbackPassChangeStop
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/2any value{:inject, [String.t()]}—
on_turn_end/3, on_passivate/2any 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

entry()

@type entry() :: {module(), opts()}

opts()

@type opts() :: keyword()

outcome()

@type outcome() ::
  {:ok, response :: term()}
  | {:error, Exception.t()}
  | {:rejected, Exception.t()}

Callbacks

after_compact(dropped, meta, t, opts)

(optional)
@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.

after_tool(t, t, t, opts)

(optional)

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.

before_llm(map, t, opts)

(optional)
@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.

before_tool(t, t, opts)

(optional)

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.

on_passivate(t, opts)

(optional)
@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.

on_prompt(input, t, opts)

(optional)
@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.

on_session_start(t, opts)

(optional)
@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.

on_turn_end(outcome, t, opts)

(optional)
@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.

validate_opts!(opts)

(optional)
@callback validate_opts!(opts()) :: term()

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

check_entries(entries)

@spec check_entries(term()) :: :ok | {:error, {:invalid_hooks, String.t()}}

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.

events()

@spec events() :: [{atom(), non_neg_integer()}]

The event callbacks, as {name, arity}.

validate_entries!(entries, opts \\ [])

@spec validate_entries!(
  term(),
  keyword()
) :: :ok

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.