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_tools —
as_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 = FalseBaseAgentTool 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.