Tool registration, validation, and execution
Corresponding source
| Subject | Source |
|---|---|
| Single-Tool contract | nanobot/agent/tools/base.py::Tool |
| Plugin discovery and instantiation | nanobot/agent/tools/loader.py::ToolLoader |
| Registration, schema, and argument validation | nanobot/agent/tools/registry.py::ToolRegistry |
| Tool Call normalization | nanobot/providers/base.py::ToolCallRequest, parse_tool_arguments |
| Execution batches and result reinsertion | nanobot/agent/runner.py::AgentRunner._execute_tools, AgentRunner._run_tool, AgentRunner._run_core |
Learning objectives
After this lesson, you should be able to:
- distinguish Tool, ToolRegistry, and ToolLoader;
- explain how tools are discovered and registered;
- describe how model arguments are processed before execution;
- distinguish concurrency-safe tools from sequential tools;
- locate how a tool error returns to the model.
Prerequisites
- Understand JSON Schema.
- Understand Tool Call IDs.
- Read the AgentRunner main-loop lesson.
1. Tool defines one capability contract
Base class:
nanobot/agent/tools/base.py::Tool
Each Tool primarily provides:
namedescriptionparametersexecute(**kwargs)enabled(config)create(config, context)concurrency_saferead_onlyexclusive- scope-related properties
In particular:
namemust exactly match the model’s Tool Call;descriptionandparametersinfluence whether and how the model calls the tool;executeperforms the real side effect;- concurrency, safety, and scope properties influence runtime policy.
2. ToolLoader discovers and instantiates Tools
ToolLoader has two discovery paths:
flowchart LR PKG["nanobot.agent.tools package"] --> P["pkgutil.iter_modules"] P --> CLASS["Discover Tool subclasses"] EP["entry point: nanobot.tools"] --> EXT["External plugin classes"] CLASS --> LOAD["Read config, enabled, and scope"] EXT --> LOAD LOAD --> REG["ToolRegistry.register"]
For built-in Tools, it:
- scans modules with pkgutil;
- skips internal modules;
- finds Tool subclasses;
- constructs instances according to configuration, scope, and context.
External Tools use the Python entry-point group nanobot.tools.
If names conflict, a built-in Tool takes precedence so a plugin cannot silently replace a core Tool.
3. ToolRegistry stores available instances and schemas
ToolRegistry is responsible for:
- register, unregister, and get;
- producing stably ordered tool definitions;
- resolving exact tool names;
- normalizing argument shapes;
- type casting and schema validation;
- executing a tool.
Tool definitions are cached until register or unregister invalidates the cache. Built-in Tools and names beginning with mcp_ receive separate ordering, which helps keep prompt caching stable.
4. Execution requires an exact tool name
Registry can suggest “Did you mean …?” in an error, but it never executes a fuzzy name directly.
Reasons:
- fuzzy execution can choose the wrong capability;
- security-sensitive Tools must not depend on similarity;
- schema and permissions are attached to the exact Tool.
5. Argument processing order
ToolRegistry.prepare_call() follows:
flowchart TD A["name + raw params"] --> B{"Exact Tool found?"} B -->|No| X["Return not-found error and suggestions"] B -->|Yes| C["Coerce JSON string"] C --> D["Unwrap arguments when needed"] D --> E{"Are params an object?"} E -->|No| Y["Return argument-shape error"] E -->|Yes| F["tool.cast_params"] F --> G["tool.validate_params"] G -->|Errors| Z["Return schema errors"] G -->|Valid| H["Pass to execute"]
Important boundaries:
- empty arguments become an empty object;
- a valid JSON string can be parsed;
- arrays, scalars, or malformed JSON must not be guessed into executable arguments;
- replayed history may receive compatibility repair, but the execution path for a new Tool Call must not rely on guesses.
The successful source path is short:
tool = self._tools.get(name)
if not tool:
suggestion = self._suggest_name(str(name))
# Return a not-found error; suggestion is used only in the error message.
...
params = self._coerce_params(tool, params)
if not isinstance(params, dict):
# Return the “parameters must be a JSON object” error.
...
cast_params = tool.cast_params(params)
errors = tool.validate_params(cast_params)
if errors:
# Return schema-validation errors.
...
return tool, cast_params, NoneThe excerpt comes from ToolRegistry.prepare_call(); ellipses replace only error-message construction. The order is not arbitrary: the Registry must locate the exact Tool before using its schema to unwrap, cast, and validate arguments. Only a final None error permits execution.
6. Runner owns batch execution policy
Although ToolRegistry.execute() can execute one Tool, AgentRunner’s _execute_tools() also:
- groups
concurrency_safeTools; - runs safe Tools concurrently with gather;
- runs non-concurrency-safe Tools sequentially;
- records tool events;
- classifies exceptions;
- handles workspace and SSRF violations;
- decides whether an error is fatal according to
fail_on_tool_error.
ToolRegistry: locate, validate, and invoke one Tool
AgentRunner: organize a batch of Tool Calls and define their error semantics7. Errors usually return to the model as Tool Results
A missing Tool name, invalid arguments, or an ordinary execution exception usually becomes a result beginning with Error, accompanied by guidance that lets the model reconsider its approach.
The model can then:
- correct arguments;
- choose another Tool;
- stop and explain the failure to the user.
Security violations receive additional classification:
- workspace violation;
- SSRF;
- repeated attempts against an external target;
- fatal tool error.
A Tool must not catch every exception and return an empty string; doing so removes diagnostic information from both Runner and the model.
8. Tool Result enters the next iteration
Runner creates one message per call:
{
"role": "tool",
"tool_call_id": "...",
"name": "...",
"content": "..."
}Before inserting it, Runner may:
- convert a complex result to text;
- constrain its length;
- persist a large result to the workspace;
- preserve replay boundaries.
9. Minimal distinction among Tool, Skill, and MCP
| Type | Form presented to the model | Execution location |
|---|---|---|
| Tool | JSON schema + runtime execution | nanobot process |
| Skill | Markdown guidance or on-demand knowledge | context; not directly executable |
| MCP Tool | converted to a nanobot Tool schema | external MCP server |
The extension section compares them in detail. For now, remember:
If the goal is to teach the model how to do something, consider a Skill first. If the model must perform an action, use a Tool or MCP.
Observation task
During one filesystem-read Tool Call, inspect:
ToolCallRequest.name
ToolCallRequest.arguments
ToolRegistry.get_definitions()
ToolRegistry.prepare_call()
tool.concurrency_safe
tool result
role=tool in the next iteration's messagesAsk the model to read a nonexistent path. Observe whether the error returns as a tool result instead of crashing the entire process.
Source and tests
nanobot/agent/tools/base.pynanobot/agent/tools/loader.pynanobot/agent/tools/registry.pynanobot/agent/runner.py::_execute_toolsnanobot/agent/runner.py::_run_tooltests/agent/test_tool_loader_entrypoints.pytests/agent/test_tool_loader_scopes.pytests/agent/test_runner_tool_execution.pytests/agent/test_runner_safety.py
Self-check
- Why should ToolLoader and ToolRegistry remain conceptually distinct?
- Why are name suggestions used only for errors, not real execution?
- Why can replay argument repair not be reused blindly for a new call?
- Who declares
concurrency_safe, and who enforces it? - Why can an ordinary tool error return to the model?