Tool registration, validation, and execution

Corresponding source

SubjectSource
Single-Tool contractnanobot/agent/tools/base.py::Tool
Plugin discovery and instantiationnanobot/agent/tools/loader.py::ToolLoader
Registration, schema, and argument validationnanobot/agent/tools/registry.py::ToolRegistry
Tool Call normalizationnanobot/providers/base.py::ToolCallRequest, parse_tool_arguments
Execution batches and result reinsertionnanobot/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:

  • name
  • description
  • parameters
  • execute(**kwargs)
  • enabled(config)
  • create(config, context)
  • concurrency_safe
  • read_only
  • exclusive
  • scope-related properties

In particular:

  • name must exactly match the model’s Tool Call;
  • description and parameters influence whether and how the model calls the tool;
  • execute performs 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, None

The 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_safe Tools;
  • 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 semantics

7. 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

TypeForm presented to the modelExecution location
ToolJSON schema + runtime executionnanobot process
SkillMarkdown guidance or on-demand knowledgecontext; not directly executable
MCP Toolconverted to a nanobot Tool schemaexternal 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 messages

Ask 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.py
  • nanobot/agent/tools/loader.py
  • nanobot/agent/tools/registry.py
  • nanobot/agent/runner.py::_execute_tools
  • nanobot/agent/runner.py::_run_tool
  • tests/agent/test_tool_loader_entrypoints.py
  • tests/agent/test_tool_loader_scopes.py
  • tests/agent/test_runner_tool_execution.py
  • tests/agent/test_runner_safety.py

Self-check

  1. Why should ToolLoader and ToolRegistry remain conceptually distinct?
  2. Why are name suggestions used only for errors, not real execution?
  3. Why can replay argument repair not be reused blindly for a new call?
  4. Who declares concurrency_safe, and who enforces it?
  5. Why can an ordinary tool error return to the model?

Next step

Read Streaming, errors, and stop.