Hooks and session commands¶
Hooks¶
Hooks is a group of HookRegistry objects, registered as agent.hooks.<name>:
from xun import Agent, HookArgs
def log_tool_call(args: HookArgs.BeforeToolCallArgs) -> None:
for call in args.tool_calls:
print(call.function.name, call.function.arguments)
agent = Agent()
agent.hooks.before_tool_call.add(log_tool_call)
Registry behaviour:
| Method | Semantics |
|---|---|
add(fn) |
persistent callback, executed in registration order |
add_once(fn) |
automatically removed after the next invoke |
invoke(args) |
calls every callback in turn, then clears the one-shot callbacks |
The return values of callbacks are discarded; to change behaviour, modify the argument object in place. Callbacks are wrapped in except_safe as well: ordinary exceptions are swallowed into a Result.Err (execution is not interrupted), and only KeyboardInterrupt / CancelledError propagate up.
Hook list¶
| Hook | Arguments (main fields) | When / what can be changed |
|---|---|---|
run_start |
agent |
when the agent moves from idle into a run (not re-triggered by nested scopes) |
run_end |
agent |
the outermost scope that actually started the run exits, whether it succeeded, raised an error or was cancelled |
before_execution |
agent, schema, max_iterations, context_value, takeover_result |
before the execution loop starts, every field can be changed; takeover_result takes over the whole loop, see below |
before_execution_step |
agent |
before each model call; the auto-compactor is attached here |
after_execution_step |
agent |
tool results have been written back to the session, before the next model call |
before_tool_call |
agent, tool_calls (a list) |
before the tools run; tool calls can be added, removed or changed |
after_tool_call |
agent, tool_results (a list of (id, Result)) |
when the turn has tool calls, after all tools have run and before the results are written back to the session; editing tool_results only affects what is written into the session, the display events for the results have already been emitted |
model_text_delta |
agent, model_call_id, content |
before a text delta is committed; content can be rewritten |
model_reasoning_delta |
same as above | before a reasoning delta is committed |
before_display_info |
agent, message |
before agent.info() prints; message can be rewritten |
before_display_warning |
same as above | before agent.warning() prints |
before_display_error |
same as above | before agent.error() prints |
after_initialize |
agent |
the last step of initialize() (extensions, model probing and display binding are all done) |
before_finalize |
agent |
at the start of finalize(), a good place to release resources |
before_command |
agent, command, arguments (a list) |
after the command was parsed successfully, before it runs; arguments can be changed in place; unknown commands do not trigger it |
after_command |
same as above | the command finished (including errors that were handled) |
Taking over the execution loop¶
The before_execution argument carries a takeover_result (default None). Once a hook sets it to a string, that execute() call is taken over by the hook:
def canned(args: HookArgs.BeforeExecutionArgs) -> None:
if cached := args.agent.state.get("cached_answer"):
args.takeover_result = cached # becomes the result of execute()
agent.hooks.before_execution.add(canned)
The effect is that the whole loop is skipped: no model call, no hooks or events from inside the loop, and nothing appended to the conversation history. The string becomes the return value of execute(); when execute(schema=...) was given a schema it is still validated against it, and a validation failure fails the run. Useful for result caches, offline replay and test doubles.
Common uses: mirror tool calls into a log, prefix the output, set max_iterations centrally in before_execution or answer from a cache, close external resources in before_finalize (this is how the browser runtime is cleaned up).
Session commands¶
Commands are the entry point for human interaction (the terminal REPL, the / input box of the web UI). Agent.execute_command() is the single entry point: it parses the arguments with shlex.split, triggers before_command, runs the command, and finally triggers after_command.
from xun import Agent, Command, CommandRegistry
def wordcount(agent: Agent, args: list[str]) -> None:
"""Count words in the given text."""
agent.info(f"{len(' '.join(args).split())} words")
agent.command.register(Command("wordcount", wordcount))
agent.command.register(Command.from_function(wordcount)) # the name is taken from the function name
agent.execute_command("wordcount", "one two three")
Registration rules:
| Point | Details |
|---|---|
| Handler arguments | 1 argument → handler(agent); 2 arguments → handler(agent, arguments); otherwise TypeError |
| Description | taken from the first line of the docstring by default; a multi-line docstring becomes the long help for -h |
| Command groups | pass a CommandRegistry as the handler to make it a command group, cmd sub -h then works automatically |
| Help | help is shown when the arguments are exactly -h or --help (cmd sub extra -h passes -h on to the command as an ordinary argument); help is a virtual command and is always available |
| Errors | exceptions inside a command are caught and reported via agent.error(...); KeyboardInterrupt / CancelledError propagate up |
| Escaping | input starting with / is treated as a command; input starting with \/ is an ordinary message |
For the list of built-in commands and their descriptions see xun · Terminal session; the policy subcommand group (policy ...) is registered when default_tools=True.
Hooks do not reach the sub-agent; commands do
Agent.inherit() does not inherit hooks, but it does copy command; a sub-agent replays the extensions during initialize(), which registers the hooks again.
Persisting the session history¶
/save and /load are backed by Store:
from xun.store import Store
store = Store() # the default root directory is get_home_dir()
target = store.next_history_store() # .../conversation/000007.conversation
latest = store.latest_history_store()
one = store.get_history_store(3) # an int is zero-padded: .../conversation/000003.conversation
two = store.get_history_store("000003") # a string is used verbatim and must match the directory name
Zero-pad the index in /load
/load treats its argument as a string, so /load 000003 resolves while /load 3 reports History 3 not found.; use /load latest when you are unsure of the number.
The actual file is {XUN_HOME}/conversation/{NNNNNN}.conversation/conversation.json, whose keys include conversation_id, time, tokens_used, messages, compacted_toolcalls and compaction.