Skip to content

Tool family

Wrap any callable as a Tool for an Agent. The Tool.wrap() classmethod is the canonical multi-input factory (callable / Agent / existing Tool); Tool(...) is the explicit constructor used when you want to set every field by hand. ToolProvider is the protocol for expandable tool catalogues (MCP servers etc.). NativeTool enumerates provider-hosted server-side tools.

The module-level lazybridge.tool (lowercase) is a thin backwards-compat alias for Tool.wrap — existing imports keep working, new code should prefer the classmethod.

For narrative usage see Guides → Basic → Tool and Guides → Basic → Native tools.

Timeouts

Tool(timeout=N) bounds one tool; Agent(tool_timeout=N) supplies a default to every tool that sets none of its own; LLMEngine(tool_timeout=N) does the same at engine level. The most specific one set wins, and a tool that exceeds its bound raises ToolTimeoutError, which the engine reports to the model as a failed tool result rather than aborting the run.

Bound the tool, not just the run. Agent(timeout=N) can only fire at an await, and a synchronous tool never yields one — a blocking time.sleep/requests.get inside a tool will run past the agent deadline indefinitely. Tool(timeout=N) instead runs the call on a daemon thread and abandons it when the time is out: the caller is freed immediately, the work itself keeps running until it returns on its own, and its result is discarded. Anything with a side effect may therefore still complete after the timeout — for work that must actually stop, give the underlying library its own deadline (requests.get(..., timeout=)) or run it in a subprocess.

An async tool is cancelled rather than abandoned, but cancelling is a request and not a guarantee: a coroutine may catch CancelledError and carry on, or spend a long time in cleanup. It gets Tool.cancel_grace_seconds (1.0) to unwind, after which it too is abandoned.

One case no deadline can reach: a coroutine that blocks the event loop — CPU-bound work or a synchronous call inside async def, whether in the body or in cancellation cleanup. Nothing else runs while it does, including the clock that would end it. That is a property of asyncio, not of this bound; the fix is to keep blocking work out of async def (declare the tool def and let Tool(timeout=) put it on its own thread, or use run_in_executor).

The bound is on the call, not on process exit. An abandoned task still belongs to its event loop, and asyncio.run cancels and gathers every pending task on the way out — so an async tool that swallows CancelledError outright can delay shutdown even though the call itself returned on time. run_sync() is unaffected: LazyBridge owns that loop and skips draining what it has already abandoned.

lazybridge.Tool

Tool(func: Callable, *, name: str | None = None, description: str | None = None, mode: Literal['signature', 'llm', 'hybrid'] = 'signature', schema_llm: Any | None = None, strict: bool = False, returns_envelope: bool = False, agent_memory: Any | None = None, agent_store: Any | None = None, timeout: float | None = None)

Wraps any Python callable as an LLM-accessible tool.

Pass raw functions directly; Tool auto-wraps them on the agent level. Use Tool(fn, ...) only when you need explicit configuration.

Source code in lazybridge/tools.py
def __init__(
    self,
    func: Callable,
    *,
    name: str | None = None,
    description: str | None = None,
    mode: Literal["signature", "llm", "hybrid"] = "signature",
    schema_llm: Any | None = None,
    strict: bool = False,
    returns_envelope: bool = False,
    agent_memory: Any | None = None,
    agent_store: Any | None = None,
    timeout: float | None = None,
) -> None:
    if mode not in ("signature", "llm", "hybrid"):
        # ``"auto"`` was the 0.7-era default — removed in 0.7.9.
        # Reject it eagerly so the failure surfaces at construction
        # time, not lazily at the first ``definition()`` call.
        raise ValueError(
            f"Tool(mode={mode!r}) is invalid.  Accepted values: "
            f"'signature' (default), 'hybrid', 'llm'.  "
            f"The legacy 'auto' value was removed in 0.7.9; pass "
            f"'hybrid' or 'llm' explicitly to opt into LLM-driven "
            f"schema generation."
        )
    self.func = func
    self.name = name or func.__name__
    self.description = description
    self.mode = mode
    #: Seconds this tool may take before the caller gives up on it, or
    #: ``None`` for no bound.  ``Agent(tool_timeout=...)`` supplies a
    #: default to tools that do not set their own.
    self.timeout = check_timeout(timeout, "Tool(timeout=)")
    self.schema_llm = schema_llm
    self.strict = strict
    #: When ``True``, ``func`` returns an ``Envelope`` instead of a
    #: plain Python value.  Engines aware of this hint will preserve
    #: the inner envelope's metadata (tokens / cost / error) when
    #: aggregating results from a turn's tool calls.  The flag is
    #: set automatically by ``_wrap_tool`` for Agents wrapped via
    #: ``agent.as_tool()``.
    self.returns_envelope = returns_envelope
    #: Live reference to the source agent's Memory, set by ``agent.as_tool()``.
    #: Resolved lazily at step execution time via ``from_memory("name")``.
    #: None for plain function tools.
    self.agent_memory = agent_memory
    #: Live reference to the source agent's Store, set by ``agent.as_tool()``.
    #: Used by ``from_agent("name")`` to read the agent's last output.
    #: None for plain function tools.
    self.agent_store = agent_store
    self._definition: ToolDefinition | None = None
    self._lock = threading.Lock()

from_schema classmethod

from_schema(name: str, description: str, parameters: dict[str, Any], func: Callable[..., Any], *, strict: bool = False, returns_envelope: bool = False, timeout: float | None = None) -> Tool

Create a Tool with a pre-built JSON Schema for parameters.

Use this when the schema is already known (from MCP, OpenAPI, a third-party tool registry, ...) and signature introspection would either be unavailable or produce the wrong shape.

parameters must be a JSON Schema object (the same shape that ToolDefinition.parameters carries).

Source code in lazybridge/tools.py
@classmethod
def from_schema(
    cls,
    name: str,
    description: str,
    parameters: dict[str, Any],
    func: Callable[..., Any],
    *,
    strict: bool = False,
    returns_envelope: bool = False,
    timeout: float | None = None,
) -> Tool:
    """Create a Tool with a pre-built JSON Schema for parameters.

    Use this when the schema is already known (from MCP, OpenAPI, a
    third-party tool registry, ...) and signature introspection would
    either be unavailable or produce the wrong shape.

    ``parameters`` must be a JSON Schema object (the same shape that
    ``ToolDefinition.parameters`` carries).
    """
    tool = cls.__new__(cls)
    tool.func = func
    tool.name = name
    tool.description = description
    tool.mode = "signature"  # unused — we set ``_definition`` directly
    tool.schema_llm = None
    tool.strict = strict
    tool.timeout = check_timeout(timeout, "Tool.from_schema(timeout=)")
    tool.returns_envelope = returns_envelope
    tool.agent_memory = None
    tool.agent_store = None
    tool._definition = ToolDefinition(
        name=name,
        description=description,
        parameters=parameters,
        strict=strict,
    )
    tool._lock = threading.Lock()
    return tool

run_sync

run_sync(**kwargs: Any) -> Any

Blocking tool invocation.

Handles two cases so that callers never see a stray coroutine:

  • plain sync function → called directly.
  • async function → driven to completion through the shared sync↔async bridge (:func:lazybridge._asyncbridge.run_coroutine_blocking), which handles nest_asyncio, contextvars propagation, and loop-closed cleanup identically to Agent.__call__. Needed because :meth:Agent.as_tool wraps the agent's .run() coroutine into Tool.funcSupervisorEngine / REPL callers were previously getting "<coroutine object _run at 0x...>" instead of the result.
Source code in lazybridge/tools.py
def run_sync(self, **kwargs: Any) -> Any:
    """Blocking tool invocation.

    Handles two cases so that callers never see a stray coroutine:

    * plain sync function → called directly.
    * async function → driven to completion through the shared
      sync↔async bridge (:func:`lazybridge._asyncbridge.run_coroutine_blocking`),
      which handles nest_asyncio, contextvars propagation, and
      loop-closed cleanup identically to ``Agent.__call__``.  Needed
      because :meth:`Agent.as_tool` wraps the agent's ``.run()``
      coroutine into ``Tool.func`` — ``SupervisorEngine`` / REPL
      callers were previously getting ``"<coroutine object _run at
      0x...>"`` instead of the result.
    """
    if self.timeout is not None:
        # Through the async path so the bound is enforced by the same
        # machinery, thread included: a blocking call must not be able
        # to outlast its deadline just because the caller was sync.
        return run_coroutine_blocking(lambda: self._dispatch(kwargs, self.timeout))
    kwargs = self._coerce_arguments(kwargs)
    if not inspect.iscoroutinefunction(self.func):
        return self.func(**kwargs)
    return run_coroutine_blocking(lambda: self.func(**kwargs))

wrap classmethod

wrap(obj: Any, *, name: str | None = None, description: str | None = None, mode: Literal['signature', 'hybrid', 'llm'] = 'signature', schema_llm: Any | None = None, strict: bool = _UNSET_BOOL, timeout: float | None = None) -> Tool

Canonical multi-input factory — accepts a callable, an Agent, or an existing :class:Tool, and returns a properly wrapped Tool.

For Python functionsname is required so Plan steps, tool maps, and LLM calls all share the same stable identifier::

search = Tool.wrap(search_web, name="search", description="Search the web.")
researcher = Agent(name="research", engine=LLMEngine(...), tools=[search])

For Agents — the canonical path is tools=[agent] directly; Tool.wrap is useful when you need a local alias::

Tool.wrap(researcher, name="deep_research")

For existing Tools — returns the object unchanged (no overrides) or clones it with the specified overrides (non-mutating)::

search_v2 = Tool.wrap(search, name="web_search")
Parameters

obj: A callable, :class:Agent, or existing :class:Tool to wrap. name: Required for callables. Optional alias for agents and Tools. description: Human-readable description forwarded to the LLM. mode: Schema generation mode. "signature" (default) introspects the function signature and docstring deterministically. Pass "hybrid" (signature + LLM-enriched descriptions) or "llm" (full LLM-inferred schema) explicitly when the signature alone is insufficient — both require schema_llm= to be set. schema_llm: Engine used when mode="hybrid" or mode="llm". strict: Enable JSON Schema strict mode. timeout: Seconds this tool may take before the caller gives up on it. None leaves the tool unbounded, or keeps the bound a wrapped Tool already carries.

Notes

Module-level :func:tool is a thin alias for backwards compatibility and is kept indefinitely; new code should prefer Tool.wrap.

Source code in lazybridge/tools.py
@classmethod
def wrap(
    cls,
    obj: Any,
    *,
    name: str | None = None,
    description: str | None = None,
    mode: Literal["signature", "hybrid", "llm"] = "signature",
    schema_llm: Any | None = None,
    strict: bool = _UNSET_BOOL,  # type: ignore[assignment]
    timeout: float | None = None,
) -> Tool:
    """Canonical multi-input factory — accepts a callable, an Agent, or an
    existing :class:`Tool`, and returns a properly wrapped ``Tool``.

    **For Python functions** — ``name`` is required so Plan steps, tool
    maps, and LLM calls all share the same stable identifier::

        search = Tool.wrap(search_web, name="search", description="Search the web.")
        researcher = Agent(name="research", engine=LLMEngine(...), tools=[search])

    **For Agents** — the canonical path is ``tools=[agent]`` directly;
    ``Tool.wrap`` is useful when you need a local alias::

        Tool.wrap(researcher, name="deep_research")

    **For existing Tools** — returns the object unchanged (no overrides) or
    clones it with the specified overrides (non-mutating)::

        search_v2 = Tool.wrap(search, name="web_search")

    Parameters
    ----------
    obj:
        A callable, :class:`Agent`, or existing :class:`Tool` to wrap.
    name:
        Required for callables.  Optional alias for agents and Tools.
    description:
        Human-readable description forwarded to the LLM.
    mode:
        Schema generation mode.  ``"signature"`` (default) introspects the
        function signature and docstring deterministically.  Pass
        ``"hybrid"`` (signature + LLM-enriched descriptions) or ``"llm"``
        (full LLM-inferred schema) explicitly when the signature alone
        is insufficient — both require ``schema_llm=`` to be set.
    schema_llm:
        Engine used when ``mode="hybrid"`` or ``mode="llm"``.
    strict:
        Enable JSON Schema strict mode.
    timeout:
        Seconds this tool may take before the caller gives up on it.
        ``None`` leaves the tool unbounded, or keeps the bound a wrapped
        ``Tool`` already carries.

    Notes
    -----
    Module-level :func:`tool` is a thin alias for backwards compatibility
    and is kept indefinitely; new code should prefer ``Tool.wrap``.
    """
    # ── Case 1: already a Tool ──────────────────────────────────────────
    if isinstance(obj, Tool):
        reshapes_schema = (
            name is not None
            or description is not None
            or mode != "signature"
            or schema_llm is not None
            or strict is not _UNSET_BOOL
        )
        if not reshapes_schema:
            if timeout is None:
                return obj
            # A deadline says nothing about the tool's shape, so copy
            # rather than rebuild: reconstruction would regenerate the
            # schema from the signature and throw away an explicit one
            # set by ``from_schema`` — for an imported tool whose callable
            # is ``lambda **kwargs`` that means showing the model a tool
            # with no parameters.
            clone = copy.copy(obj)
            clone.timeout = check_timeout(timeout, "Tool.wrap(timeout=)")
            return clone
        return cls(
            obj.func,
            name=name if name is not None else obj.name,
            description=description if description is not None else obj.description,
            mode=mode if mode != "signature" else obj.mode,
            schema_llm=schema_llm if schema_llm is not None else obj.schema_llm,
            strict=obj.strict if strict is _UNSET_BOOL else bool(strict),
            returns_envelope=obj.returns_envelope,
            agent_memory=obj.agent_memory,
            agent_store=obj.agent_store,
            timeout=timeout if timeout is not None else obj.timeout,
        )

    # ── Case 2: Agent-like ──────────────────────────────────────────────
    if getattr(obj, "_is_lazy_agent", False):
        # An explicit alias passed here is always accepted.
        # Without an alias, the agent must have _name_explicit=True.
        if name is None and getattr(obj, "_name_explicit", True) is False:
            # Only reject real Agent instances that set _name_explicit=False.
            # Duck-typed agents (MockAgent, custom subclasses) default to True.
            agent_name = getattr(obj, "name", repr(obj))
            raise ValueError(
                f"Agent used as a tool must have an explicit name=...\n"
                f"The agent currently has name={agent_name!r} "
                f"(derived from the model or left as the default).\n\n"
                f"Set an explicit name:\n"
                f'    Agent(name="research", engine=LLMEngine(...))\n\n'
                f"Or pass an alias to the factory:\n"
                f'    Tool.wrap(agent, name="research")'
            )
        effective_name = name or getattr(obj, "name", None)
        if not effective_name or not str(effective_name).strip():
            raise ValueError(
                "Agent used as a tool must have an explicit name=...\n"
                "Example:\n"
                '    Agent(name="research", engine=LLMEngine(...))'
            )
        if hasattr(obj, "as_tool"):
            agent_tool = obj.as_tool(effective_name, description=description)
        else:
            agent_tool = _agent_as_tool_named(obj, effective_name, description)
        if timeout is not None:
            # ``as_tool`` builds a fresh Tool per call, so this bounds
            # this alias only and not the agent everywhere it is used.
            agent_tool.timeout = check_timeout(timeout, "Tool.wrap(timeout=)")
        return agent_tool

    # ── Case 3: plain callable ──────────────────────────────────────────
    if callable(obj):
        if name is None:
            fn_name = getattr(obj, "__name__", repr(obj))
            raise ValueError(
                f"Tool.wrap() requires an explicit name=... for callables.\n"
                f'Example: Tool.wrap({fn_name!r}, name="{fn_name}")'
            )
        strict_val = False if strict is _UNSET_BOOL else bool(strict)  # type: ignore[arg-type]
        return cls(
            obj,
            name=name,
            description=description,
            mode=mode,
            schema_llm=schema_llm,
            strict=strict_val,
            timeout=timeout,
        )

    raise TypeError(f"Tool.wrap() cannot wrap {type(obj).__name__!r}")

lazybridge.tool

tool(obj: Any, *, name: str | None = None, description: str | None = None, mode: Literal['signature', 'hybrid', 'llm'] = 'signature', schema_llm: Any | None = None, strict: bool = _UNSET_BOOL, timeout: float | None = None) -> Tool

Backwards-compatibility alias for :meth:Tool.wrap.

New code should call Tool.wrap(obj, name=...) — it lives on the class alongside the explicit constructor, mirroring Python stdlib factories like :meth:dict.fromkeys and :meth:datetime.datetime.fromisoformat. The lowercase :func:tool is kept indefinitely so existing imports (from lazybridge import tool) continue to work; no deprecation timer is set.

Source code in lazybridge/tools.py
def tool(
    obj: Any,
    *,
    name: str | None = None,
    description: str | None = None,
    mode: Literal["signature", "hybrid", "llm"] = "signature",
    schema_llm: Any | None = None,
    strict: bool = _UNSET_BOOL,  # type: ignore[assignment]
    timeout: float | None = None,
) -> Tool:
    """Backwards-compatibility alias for :meth:`Tool.wrap`.

    New code should call ``Tool.wrap(obj, name=...)`` — it lives on the class
    alongside the explicit constructor, mirroring Python stdlib factories
    like :meth:`dict.fromkeys` and :meth:`datetime.datetime.fromisoformat`.
    The lowercase :func:`tool` is kept indefinitely so existing imports
    (``from lazybridge import tool``) continue to work; no deprecation
    timer is set.
    """
    return Tool.wrap(
        obj,
        name=name,
        description=description,
        mode=mode,
        schema_llm=schema_llm,
        strict=strict,
        timeout=timeout,
    )

lazybridge.ToolTimeoutError

ToolTimeoutError(message: str, *, tool_name: str | None = None, timeout: float | None = None)

Bases: Exception

A tool ran past the time it was given and the caller stopped waiting.

Reported to the model loop as a failed tool result, not raised out of the run: the model sees the timeout and carries on with what it does have. Raised by Tool.timeout and, for the outer per-call bound, by LLMEngine.tool_timeout. For a synchronous tool the work is abandoned rather than stopped -- see :meth:Tool._run_bounded.

Source code in lazybridge/tools.py
def __init__(self, message: str, *, tool_name: str | None = None, timeout: float | None = None) -> None:
    super().__init__(message)
    self.tool_name = tool_name
    self.timeout = timeout

lazybridge.ToolProvider

Bases: Protocol

A tools=[...] entry that expands itself into one or more Tools.

Implementors set _is_lazy_tool_provider = True and define as_tools() -> list[Tool]. MCPServer and ExternalToolProvider both satisfy this protocol structurally; custom providers (OpenAPI imports, internal tool registries, etc.) can do the same — drop the instance into Agent(tools=[provider]) and build_tool_map will expand it on construction.

lazybridge.NativeTool

Bases: StrEnum

Provider-native server-side tools (run on provider infrastructure).