Agentix.Hook (Agentix v0.1.0)

Copy Markdown View Source

A hook: a function the agent runs around a model call, plus its metadata.

Two phases:

  • :pre — runs in preparing, before the model call, on the assembled %Agentix.Turn{}. Sequential pre-hooks (turn -> {:cont, turn} | {:halt, reason}) can transform the turn (inject context via inject/2) or halt it; a :halt short-circuits the rest of the pipeline and the turn never reaches the model. Parallel pre-hooks are append-only (turn -> {:cont, [ContentPart]}): they run concurrently and their parts are appended, in declaration order, at the tail of the context (past the prompt-cache breakpoint).
  • :post — runs after the assistant message finalizes (:message_completed), on a turn carrying the finalized assistant_message. (turn -> {:cont, turn} | {:halt, reason}); side-effecting.

Injection budget

Pre-hook injections are bounded by the conversation's injection_reserve. If the injected content exceeds it, the pipeline raises Agentix.Hook.OverflowError naming the offending hook — a loud config error, not a trigger for compaction (the two subsystems are independent; compaction is never re-entered to make room).

durable?

Hook output is transient: pre-hook injections ride only the per-model-call rendered context (so they reach the optional model_calls audit table) and are regenerated each assembly — never written to the canonical event log. The durable? field is part of the contract and is validated, but durable log-persistence of hook output (appending it as a normal event) is deferred to the layer that owns the event-shape/compaction: it needs a data-model decision (a dedicated event type vs. overloading :user_msg) that does not belong in the loop. A durable?: true hook therefore currently behaves identically to a transient one; consumers must not yet rely on persistence.

Hooks (and the stream transformer) are functions and are not JSON-serializable; like tool callbacks they live in the conversation config and are rebuilt from it on revival (a no-op for the ETS adapter, which stores terms verbatim).

Trust boundary

Pre-hook injections are placed adjacent to the user message (role: :user), so the model sees them at the same trust level as the human's own input. A hook that injects untrusted/retrieved content (RAG, fetched documents) is responsible for its own provenance fencing (e.g. wrapping it in a delimited block) — Agentix does not wrap it, because it cannot know which injections are trusted. Treat injected external content as a prompt-injection surface.

Summary

Types

The return of a parallel :pre hook (mode: :parallel): an append-only batch of content parts. Parallel hooks cannot halt.

Any valid hook return; which shape applies depends on phase + mode.

The return of a sequential hook (every :post hook, and every :pre hook with mode: :sequential): transform the turn (inject/2 to add context) or halt it.

t()

Functions

Appends content to the turn's pending injections (placed at the context tail at assembly time). The append-only primitive sequential pre-hooks use to inject.

Builds a hook from attrs. Raises ArgumentError on a missing/invalid :phase, an invalid :mode, a non-1-arity :run, or a :parallel :post hook (parallel append-only batches only make sense before the model call).

Builds a :post hook. opts: :durable?.

Builds a :pre hook. opts: :mode (:sequential/:parallel), :durable?.

Applies the stream-transformer seam to a chunk. The transformer is (chunk -> chunk); nil is the identity default. The single declared call site for the per-chunk transform (driven from the provider stream). A transformer that returns a value the agent's chunk handler doesn't recognize causes that chunk to be dropped — keep it (chunk -> chunk).

Types

mode()

@type mode() :: :sequential | :parallel

parallel_result()

@type parallel_result() :: {:cont, [ReqLLM.Message.ContentPart.t()]}

The return of a parallel :pre hook (mode: :parallel): an append-only batch of content parts. Parallel hooks cannot halt.

phase()

@type phase() :: :pre | :post

run_result()

@type run_result() :: sequential_result() | parallel_result()

Any valid hook return; which shape applies depends on phase + mode.

sequential_result()

@type sequential_result() :: {:cont, Agentix.Turn.t()} | {:halt, term()}

The return of a sequential hook (every :post hook, and every :pre hook with mode: :sequential): transform the turn (inject/2 to add context) or halt it.

t()

@type t() :: %Agentix.Hook{
  durable?: boolean(),
  mode: mode(),
  name: term(),
  phase: phase(),
  run: (Agentix.Turn.t() -> run_result())
}

Functions

inject(turn, parts)

Appends content to the turn's pending injections (placed at the context tail at assembly time). The append-only primitive sequential pre-hooks use to inject.

new(attrs)

@spec new(keyword() | map()) :: t()

Builds a hook from attrs. Raises ArgumentError on a missing/invalid :phase, an invalid :mode, a non-1-arity :run, or a :parallel :post hook (parallel append-only batches only make sense before the model call).

post(name, run, opts \\ [])

@spec post(term(), (Agentix.Turn.t() -> sequential_result()), keyword()) :: t()

Builds a :post hook. opts: :durable?.

pre(name, run, opts \\ [])

@spec pre(term(), (Agentix.Turn.t() -> run_result()), keyword()) :: t()

Builds a :pre hook. opts: :mode (:sequential/:parallel), :durable?.

transform_chunk(chunk, fun)

@spec transform_chunk(chunk, (chunk -> chunk) | nil) :: chunk when chunk: term()

Applies the stream-transformer seam to a chunk. The transformer is (chunk -> chunk); nil is the identity default. The single declared call site for the per-chunk transform (driven from the provider stream). A transformer that returns a value the agent's chunk handler doesn't recognize causes that chunk to be dropped — keep it (chunk -> chunk).