VisvoAI Docs
visvoai-core

Defining tools

Hand an agent a plain typed Python function and it becomes a tool — schema from the type hints, description from the docstring. No decorators required.

The cheapest way to give an agent a tool should be writing a normal Python function. Here it is: type hints become the schema the model sees, the docstring becomes the description it routes on, and nothing needs decorating or registering.

build_graph (and anything else in visvoai-core that consumes tools) accepts four shapes in the same list, mixed freely — plain functions, the BaseAgentTool lifecycle class, any LangChain BaseTool, and MCP tools wrapped by your consumer layer. Normalization to the loop's internal currency — a LangChain BaseTool — happens exactly once, at the boundary, in visvoai/core/adapt.py. Tool authors never perform or perceive it.

ToolLike = Union[BaseTool, BaseAgentTool, Type[BaseAgentTool], Callable[..., Any]]

def as_tool(obj: ToolLike) -> BaseTool: ...
def as_tools(objs: Iterable[ToolLike]) -> List[BaseTool]: ...
def as_tools_map(objs: Iterable[ToolLike]) -> Dict[str, BaseTool]: ...

1 · A plain typed function

No framework imports. The schema comes from type hints; the description comes from the docstring. _callable_to_base_tool uses StructuredTool.from_function(..., parse_docstring=True, error_on_invalid_docstring=False), so a Google-style Args: block becomes a per-argument description in the schema the model sees — and its absence is tolerated, not an error.

def word_count(text: str) -> str:
    """Count the words in a piece of text."""
    return f"{len(text.split())} words"

def search_notes(query: str, limit: int = 5) -> str:
    """Search my notes and return the closest matches.

    Args:
        query: What to look for, in plain words.
        limit: How many results to return at most.
    """
    return f"top {limit} notes matching {query!r}"

Async works the same way — _callable_to_base_tool detects inspect.iscoroutinefunction and builds the tool with coroutine= instead of func=; the loop awaits it, no thread pool needed:

async def fetch_status(service: str) -> str:
    """Check if a service is up and return its status."""
    await asyncio.sleep(0)
    return f"{service}: ok"

Two hard requirements enforced at conversion time, both raising TypeError: the function must have a real __name__ (no lambdas), and it must have a non-empty docstring — it becomes the description the model routes on.

2 · The lifecycle class — BaseAgentTool

For tools that want a declared config, auto-registration, and a start→complete/error persistence lifecycle recorded in your datastore. Subclass BaseAgentTool (visvoai/core/tools.py), declare name, description, args_schema, and write only _execute:

from pydantic import BaseModel
from visvoai.core.tools import BaseAgentTool, tool_config
from visvoai.core.results import ToolResult

class EchoArgs(BaseModel):
    text: str

@tool_config(is_core=True, routing_hint="Use to echo text back.")
class EchoTool(BaseAgentTool):
    name = "echo"
    description = "Echo the input back."
    args_schema = EchoArgs

    def _execute(self, tool_call_id: str, **kwargs):
        return ToolResult.success(self.name, kwargs["text"])

Pass the class or an instance straight into core_toolsas_tool instantiates a bare class automatically. _agent_tool_to_base_tool wraps it in a StructuredTool whose args_schema is tool.llm_schema or tool.args_schema (so a tool can expose a different, simpler schema to the model than what _execute actually receives), and whose function body calls tool.execute(**kwargs) — never _execute directly — so the lifecycle always runs.

What .execute() does that ._execute() doesn't

def execute(self, tool_call_id=None, agent_step=0, execution_phase=None, **kwargs):
    # 1. self._persistence.on_start(...)   → returns the canonical tool_call_id
    # 2. result = self._execute(tool_call_id=<that id>, **kwargs)
    # 3. self._persistence.on_complete(...) on success
    #    self._persistence.on_error(...) + re-raise on exception
    return result

_persistence defaults to a no-op ToolPersistence() — every hook fires, records nothing, tools run standalone with no datastore. Inject a concrete subclass (tool._persistence = MyPersistence()) to record every call — see Persistence.

@tool_config — declaring metadata

ToolConfig (visvoai/core/tools.py) is a plain class (not a pydantic model, so its fields stay readable class attributes) declaring the generic metadata every tool can carry:

class ToolConfig:
    is_core: bool = False
    no_cache: bool = False
    cache_key_args: Optional[List[str]] = None
    routing_hint: Optional[str] = None
    anti_patterns: Optional[List[str]] = None
    depends_on: Optional[List[str]] = None
    parallel_with: Optional[List[str]] = None
    skip_context_chunk: bool = False
    persist_context: bool = False
    sequential_only: bool = False
    idempotent: bool = True
    deprecated: bool = False
    disabled: bool = False

BaseAgentTool inherits ToolConfig, so every field is a readable default on any tool class. @tool_config(**kwargs) validates/coerces the kwargs you pass through a pydantic model derived from ToolConfig's type hints (build_config_validator, generated once and cached) and sets only those fields — everything else keeps its inherited default. This means, e.g., @tool_config(is_core="yes") coerces to the bool True.

ToolConfig deliberately only declares generic fields — access roles, approval gating, or other surface-specific axes belong on your own config class that subclasses ToolConfig, not here.

Auto-registration

Every concrete BaseAgentTool subclass (one that isn't itself abstract, and declares name) is appended to BaseAgentTool._registry at class definition time via __init_subclass__ — useful if your product wants to discover all tool classes without a manual registry.

3 · Anything LangChain

Existing @tool-decorated functions, StructuredTools, or any other BaseTool instance pass through as_tool completely untouched (isinstance(obj, BaseTool) short-circuits). Every LangChain integration ever written is usable as-is.

4 · MCP servers

Out-of-process tools in any language, connected at the consumer layer (not inside visvoai-core itself — the CLI, for example, ships visvoai mcp add ...). Once connected, an MCP tool's (name, description) is what feeds tool retrieval via build_catalog_from_servers, and the tool itself is bound the same way as any other ToolLike once your consumer layer wraps it as a callable or BaseTool.

Mixing all four

from visvoai.core import as_tools_map

tools = [word_count, EchoTool, some_langchain_tool]
graph = AgentRuntime().build_graph(
    model=model, core_tools=tools, system_prompt="You are ...",
)

ToolResult (visvoai/core/results.py) is the minimal result envelope lifecycle tools should return — see Persistence for its full shape and status factory methods.

Next: Extension seams.

On this page