Skip to content

Tool system

Functions as tools

Hand an ordinary function to ToolBox and the framework derives its name, description and parameter JSON Schema automatically:

from xun import ToolBox

def list_dir(path: str, details: bool = False) -> dict:
    """List the contents of a directory.
    Returns names of files and directories; with details also sizes."""
    ...

toolbox = ToolBox().register(list_dir)

Derivation rules:

Item Rule
Tool name @tool_attr(name=...) takes priority, otherwise the function name is used
Description the whole docstring verbatim (no Google/reST parsing), so the meaning of parameters has to be written clearly in the docstring
Parameter model pydantic.create_model(), extra="forbid": passing extra fields to the model fails validation outright
Parameter types taken from the type annotations; with no annotation but a non-None default value, type(default) is used; otherwise Any
Required detection no default value means required; Optional[int] = None is nullable and not required; Literal[...] becomes an enum
Validation model_validate(..., strict=True) on invocation — no implicit conversion, passing "3" to an int is rejected. The exception is list / tuple parameters: when the model writes an array as a string ('["a", "b"]' or "a, b") it is parsed automatically, Optional[list[...]] included, while unions that contain a plain str member are left untouched

self/cls and ToolCallContext parameters are automatically excluded from the schema.

ToolBox API

Method Description
register(*funcs) register several functions (except_safe is applied automatically); on a name clash override decides the winner, otherwise ValueError: Conflict tool name
tool(func) decorator form of register
with_defaults(*groups) register built-in tool groups; calling it with no arguments registers all 8 groups
with_subagent_provider(agent_getter=None) register agent_run / agent_run_parallel; agent_getter takes an AgentGetterParam and returns an uninitialised sub-agent
disable(*names) / enable(*names) supports fnmatch wildcards (browser_*); disabling is not deleting, list_tools() filters the tools out
disable_subagent() / enable_subagent() turns off only the two sub-agent tools
list_tools(model_capabilities=None) filters out disabled tools and tools whose required model capabilities are not met
list_tools_json(model_capabilities=None) the tools array handed to the model
call_tool(agent, tool_name, arguments, context) the unified call entry point
clone() copies the tool table and the set of disabled names; the Function objects themselves are still shared with the source toolbox (not a deep copy)

Built-in tool group names: system, framework, fs, patch, cmd, search, browser, diagnostic. For the item-by-item list see Built-in tool reference.

tool_attr

from xun import tool_attr

@tool_attr(name="MultiplyTool")
def multiply(a: int, b: int) -> int:
    """Multiply two numbers."""
    return a * b
Attribute Type Default Purpose
name str \| None None overrides the tool name exposed to the model (used heavily by built-in tools, e.g. system_time → datetime)
required_capabilities Sequence[ModelCapabilityType] [] currently only "vision"; when the model's capabilities do not include it, the tool does not appear in the request
override bool False allows silently replacing a tool with the same name (used when an extension overrides a built-in tool)

tool_attr only attaches metadata, it does not register anything; registration still goes through ToolBox.register(). When debugging name clashes, set _XUN_INFO_TOOL_OVERRIDE=1 to print the override information.

Context injection

A tool that needs the agent itself or the data passed in by the caller only has to declare a ToolCallContext parameter:

from xun import ToolCallContext as Context
import datetime

def stamp(context: Context[dict]) -> str:
    """Record the call time into the shared context."""
    context.value["tool_call_time"] = datetime.datetime.now().isoformat()
    return context.value["tool_call_time"]

agent.instruct("Call your only tool.").execute(context={"tool_call_time": "?"})
Accessor Contents
ctx.agent the agent making the call (Agent[Agent.T.Init])
ctx.tool_name the registered name (may differ from the Python function name)
ctx.value the arbitrary value passed by Agent.execute(context=...), passed through every layer

ToolCallContext[T] is generic, and T is the type of value; the fields are private and accessed through read-only properties.

Return values and exceptions

ToolBox.register() wraps every tool in except_safe:

  • returning a plain value → automatically wrapped as Result.Ok(value)
  • raising an exception → Result.Err(ErrorInfo(error=str(exc), details=repr(exc)))
  • KeyboardInterrupt / CancelledError propagate unchanged

So inside a tool it is enough to return or raise; you do not need to build a Result by hand. Serialisation goes through model_dump / to_json / value_json; a type that cannot be serialised reports TypeError.

Images

Images cannot enter the context directly as a tool return value. defer_tool_image(ctx, image, msg="") registers a one-shot after_execution_step hook that appends a user message carrying the image (and optionally one line of explanation) after this round's tool results (request_image and browser_screenshot work exactly this way). Constraints: PNG/JPEG/WebP/GIF, at most 10 MB per image; request_image additionally caps the long side at 1000 px after cropping and reports the final and original sizes in that line.

Paths

Every path parameter is validated by Workspace.resolve(): relative paths are resolved against workdir, and only workdir and the lazy temporary directory are allowed (the temporary directory only counts as workspace once it has actually been created); anything outside reports Path ... is not within the agent's workspace. Everything path-related in return values is given as an absolute path (glob and grep are the exceptions, returning paths relative to the search root).

Permission and confirmation policy

Calls that need human approval all go through agent.get_choice() / agent.get_confirm(), which return ChoiceOutcome[T] (choice plus source, which is "user" or "auto").

ChoiceOutcome has no truth value

Writing if agent.get_confirm(...) directly raises TypeError. Use .choice: if agent.get_confirm("Proceed?").choice: ...

Scenario Behaviour
writing an existing file, move, copy overwrite, delete writes inside the lazy temporary directory go through without asking; anything else pops the three-option "write permission" prompt: allow and grant / allow this time only / deny; only choosing "grant" writes to the write allowlist
bash commands the command allowlist is checked first; when the command is not in the list or contains dangerous operators, a "command risk assessment" sub-agent (read-only tools only) decides allow/unsure/reject, which then determines whether a confirmation prompt is shown
config.auto_confirm is true returns the default option directly and marks source="auto"; it does not write to any allowlist
ask_preference tool explicitly skips automatic confirmation, always asks

The built-in command allowlist covers common diagnostic and text-processing commands (ls, cat, git status, git diff, python -m pytest and so on — note that curl, wget, sed and journalctl in that list are not read-only) and matches by token prefix, so git diff --cached matches git diff while git statusx does not. bash splits the command into segments and checks: whether the executable is in the allowlist, whether an executable at an unusual path is used, backquote substitution, newline concatenation, $(...) sub-substitution and the like; among the shell operators ;, &&, ||, |, (, ) are automatically allowed, and redirections are only allowed in the safe forms pointing at /dev/null.

The allowlists are stored per agent instance in state["__builtin_tool_policy"], and the /policy family of commands inspects and adds or removes entries, see xun · Terminal session.

Windows

The cmd group does not register the bash tool when os.name == "nt", and prints a skip notice once during the registration phase.