Agent Hooks Reference¶
Reference for lionagi.hooks.HookPoint — the closed vocabulary of session lifecycle hook points — and the built-in handlers registered via lionagi.hooks.loader.DEFAULT_HOOKS.
These Session-level HookBus hooks are separate from the per-iModel service lifecycle. See HookRegistry service hooks for model invocation callbacks.
HookPoint catalog¶
Dispatched (active emit callsites)¶
| Point | Value | Callsite |
|---|---|---|
MESSAGE_ADD | message.add | branch.py _persist_via_bus — every inbound message |
USER_PROMPT_SUBMIT | prompt.submit | operations/chat/chat.py and operations/run/run.py, immediately before provider invocation / streaming begins — fires only when the operation context carries a turn-origin token (blocking, via blocking_emit) |
BRANCH_END | branch.end | cli/_runs.py teardown_persist — once per branch the teardown owns, only when the run reached a genuine terminal outcome (never for the "running" reconciliation-suppression case) |
API_PRE_CALL | api.pre_call | operations/_api_hooks.py emit_api_pre_call, called from operations/chat/chat.py (before the request is compiled from the active view, so a handler that curates the view shapes this request) and operations/run/run.py (after the request is compiled, before the CLI stream starts) — only when the calling Branch is session-bound (branch._hooks is not None); a standalone iModel never reaches this callsite |
API_POST_CALL | api.post_call | operations/_api_hooks.py emit_api_post_call, called from the same two sites once the call has settled — success, a provider-reported failure (api_call.status), or a raised exception (error=..., status="error") |
API_STREAM_CHUNK | api.stream_chunk | operations/_api_hooks.py emit_api_stream_chunk, called from operations/run/run.py for every chunk of a session-bound stream; payload carries a redacted chunk_type only, not the raw chunk |
TOOL_PRE | tool.pre | operations/act/act.py, before tool invocation (blocking via blocking_emit) |
TOOL_POST | tool.post | operations/act/act.py, after successful tool invocation |
TOOL_ERROR | tool.error | operations/act/act.py, on tool invocation failure |
Registered in DEFAULT_HOOKS (handlers wired; emit callsite deferred to ADR-0023b)¶
| Point | Value | Default handler |
|---|---|---|
SESSION_START | session.start | persist_session_start |
SESSION_END | session.end | persist_session_end |
BRANCH_CREATE | branch.create | persist_branch_provenance |
Dormant (vocabulary only; no handler and no emit callsite)¶
| Point | Value | Status |
|---|---|---|
ARTIFACT_CREATED | artifact.created | Deprecated compatibility vocabulary: no emit site or payload contract exists |
ARTIFACT_CREATED is retained only for enum compatibility. Do not register new handlers against it until an artifact owner defines a typed payload and a production emit site. HookBus.on() emits a UserWarning when a handler is registered against any point in lionagi.hooks.DORMANT_POINTS (today, just ARTIFACT_CREATED) so that mistake surfaces at registration time instead of being discovered later as a handler that silently never ran.
Bus dispatch semantics¶
HookBus.emit fires handlers sequentially and logs exceptions without propagating them (isolation invariant). Exceptions: TOOL_PRE and USER_PROMPT_SUBMIT route through blocking_emit, which propagates exceptions so guards can raise PermissionError to abort the tool call or prompt submission. Before a blocking exception is re-raised, the bus records a denial HookSignal whose payload includes denied: true and an exception summary.
A store refusal is never suppressed by suppress_errors. A StateCompatibilityRefused raised by a tool call's own path, or by a TOOL_POST handler _act() fires under its default suppress_errors=True, is re-raised as itself: a refused write never becomes a swallowed tool error result, so a run cannot report a success the store refused to persist. A signal whose own persistence is refused is not raised by the emit: the session signal sink keeps the first such refusal, logs every later one, and counts none of them as dropped, and the run's teardown then fails the run with the kept refusal as its reason, also for a refusal recorded while the lifecycle emits run, since the terminal status is written after them. Every other failed signal write is still counted as dropped, as before. A lifecycle emit a run makes hands the run's own open handle to the default handlers, so each writes through that handle and meets whatever the store requires of the run (the SESSION_START and BRANCH_CREATE handlers, handed the agent setup's handle, write nothing: the rows the setup created already carry what they would write); an emit that carries no handle falls back to the store looked up by its URL, where a registered compatibility handle is returned ahead of a native one. A refusal a handler raises is raised by the emit as any handler's refusal is; a refusal the signal sink keeps (the store declined a signal write without raising) is not raised by the emit, and the run's teardown fails the run with it as its reason.
USER_PROMPT_SUBMIT fires at most once per genuine user-originated turn, regardless of how many internal calls that turn drives underneath it — see lionagi/operations/_turn_origin.py for the tri-state disposition (unset / forwarded / no-origin) that makes this exactly-once property hold across chat(), chat_and_record(), communicate(), operate(), run(), and ReAct().
StopHook may be raised by any handler to skip remaining handlers on the same point without propagating as an error.
Override semantics (build_session_bus)¶
build_session_bus(agent_hooks=...) is a low-level construction utility for callers that explicitly own a Session bus. It is not consumed automatically by Session or AgentSpec/profile construction. A point present in the mapping replaces its default handler list; an empty list disables a default. Points not mentioned keep their defaults.
bus = build_session_bus(
agent_hooks={"api.post_call": ["log_api_metrics"]},
observer=session.observer,
)
Assigning a separately built bus to Session internals is unsupported. Callers that need the standard Session-owned bus should use session.hooks and register runtime handlers with on().
Tool-event hooks at the invoke chokepoint¶
lionagi.protocols.action.tool_hooks defines a second, mutation-capable tool-event layer, separate from the HookBus points above. It attaches to ActionManager (manager.add_tool_pre_hook(hook) / manager.add_tool_post_hook(hook)) and runs inside ActionManager.invoke, outermost around every tool call the manager mediates — plain function tools, Tool objects, and MCP-discovered tools alike. Constructing a FunctionCalling directly (bypassing the manager) skips this layer entirely; that is a documented, tested limit, not an oversight.
Call order on a single tool invocation:
tool-pre hooks (registration order)
-> Tool.preprocessor (spec-level security/user chain, security_pre last)
-> [rewritten arguments revalidated against Tool.request_options]
-> the tool callable
-> Tool.postprocessor (spec-level chain)
-> tool-post hooks (registration order)
A tool-pre hook receives (tool_name, arguments) and returns None (allow, unchanged), a dict (allow, replace the arguments), or a ToolPreDecision (decision ∈ allow | deny | ask, plus optional reason / updated_input). deny, ask (no interactive-approval surface exists in this runtime), and any unrecognized decision value all fail closed, raising ToolHookDeniedError (a PermissionError) directly out of ActionManager.invoke before the tool ever runs. security_pre always stays the last pre-stage validator: tool-pre hooks run entirely outside the spec-level chain, so any rewrite they make is visible to security_pre, never the other way around.
Whichever layer rewrites the arguments — tool-pre hooks or the spec-level chain — the final dict is revalidated against the tool's request_options (when declared) immediately before the callable executes; a validation failure is captured as a FAILED event (matching the existing spec-level-preprocessor-error convention) rather than raised out of invoke. A tool with no request_options never had schema enforcement, and this step does not invent one.
A tool-post hook receives (tool_name, arguments, result, error) — exactly one of result/error is set — after invocation completes, success or failure. Post hooks are advisory only: a raised exception from one is logged and skipped, and nothing a post hook returns can change the already- completed outcome.
Built-in handlers¶
| Handler | Registered for |
|---|---|
persist_session_start | SESSION_START |
persist_session_end | SESSION_END |
persist_branch_provenance | BRANCH_CREATE |
persist_branch_end | BRANCH_END |
persist_message | name-addressable; routed explicitly by hooks/persist.py, not in DEFAULT_HOOKS |
log_api_metrics | (name-addressable; not in DEFAULT_HOOKS) |
log_tool_call | (name-addressable; not in DEFAULT_HOOKS) |
log_tool_use | (name-addressable; not in DEFAULT_HOOKS; deprecated — use log_tool_call) |
All handlers are name-addressable via the loader registry and can be referenced as strings in the explicit agent_hooks mapping passed to build_session_bus.
AgentSpec coding() guards¶
AgentSpec.coding(secure=True) (the default) wires two security guards via _wire_secure_guards:
guard_destructiveas a pre-hook onbash— blocks destructive shell commands (rm -rf,git push --force,git reset --hard,git clean -fd,drop table,drop database,truncate table,mkfs,dd if=,> /dev/sd*).guard_paths(allowed_paths=[workspace_root])as a pre-hook onreaderandeditor— restricts file access to the workspace root (cwdif provided, elsePath.cwd()at call time). Relative paths are resolved against the workspace root, not the process cwd.
Set secure=False to disable these defaults and manage guards manually.