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; elements of the comma-separated form stay strings, so use the JSON array form when the elements are numbers |
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/CancelledErrorpropagate 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; when serialisation fails nothing propagates: the tool result becomes the text [Error] Failed to serialize tool result: ....
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. Paths in return values are usually given as absolute paths (exceptions: glob and grep return paths relative to the search root, list_dir returns bare entry names, and the apply_patch summary is relative to the patch directory).
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, copy overwrite, delete, move |
the check looks at the destination, except for move, which looks at the source (moving equals deleting the source); 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; apply_patch and mkdir only run workspace path validation and do not go through this confirmation |
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 only ;, &&, ||, |, (, ) are automatically allowed, and the rest (& background, >, >>, <, <<, >&, <&) all need confirmation; for redirections only the safe forms > /dev/null, 1>/dev/null, 2>/dev/null and 2>&1 are exempt from confirmation.
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.