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 |
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(getter=None) |
register agent_run / agent_run_parallel |
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(capabilities=None) |
filters out disabled tools and tools whose required model capabilities are not met |
list_tools_json(capabilities=None) |
the tools array handed to the model |
call_tool(agent, tool_name, arguments, context) |
the unified call entry point |
clone() |
deep-copies the tool table and the set of disabled names |
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; a type that cannot be serialised reports TypeError.
Images¶
Images cannot enter the context directly as a tool return value. defer_tool_image() registers a one-shot after_execution_step hook that appends a user message carrying the image 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.
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; 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 |
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 read-only commands (ls, cat, git status, git diff, python -m pytest and so on) 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.