Skip to content

tool_agent

Level 2 · Tools and Skills — capability-based tool filtering with ToolRegistry.

A tool can declare the dependencies it needs to function via a requires list. The ToolRegistry then exposes only the tools whose requirements are actually satisfied by the current deps. This example registers two tools with different requirements, wires only one of the two backends, and shows the registry filtering the unsatisfiable tool out of the active set.

  • Lines: ~49
  • Prerequisites: none
  • Key concepts: ToolRegistry, capability filtering, the requires declaration

Run it

Installed via pip? Copy this example into your project with the CLI (no checkout needed):

pip install "symfonic-core[cli]"
symfonic examples add tool_agent
python -m tool_agent

Or, from a source checkout (run from the repo root):

python -m examples.tool_agent

Expected output:

Active: ['doc_read']
All: ['doc_read', 'skill_list']
Excluded: ['skill_list']

Full code

"""Tool agent -- ToolRegistry capability-based filtering.

Usage: python -m examples.tool_agent
"""

import asyncio
from typing import Any

from symfonic.core import BaseAgentDeps
from symfonic.core.documents.in_memory import InMemoryDocumentStore
from symfonic.core.protocols import DocumentStore, SkillStore
from symfonic.core.testing import MockModelProvider
from symfonic.core.tool_interface import ToolRegistry


class DocTool:
    """Requires DocumentStore."""
    name = "doc_read"
    description = "Read documents"
    requires: list[type] = [DocumentStore]
    async def __call__(self, state: Any, **kw: Any) -> str:
        return "doc"


class SkillTool:
    """Requires SkillStore."""
    name = "skill_list"
    description = "List skills"
    requires: list[type] = [SkillStore]
    async def __call__(self, state: Any, **kw: Any) -> str:
        return "skills"


async def main() -> None:
    registry = ToolRegistry()
    registry.register(DocTool())
    registry.register(SkillTool())
    # Only DocumentStore registered -- SkillStore absent
    deps = BaseAgentDeps(ModelProvider=MockModelProvider())
    deps.register(DocumentStore, InMemoryDocumentStore())
    active = registry.get_active_tools(deps)
    print(f"Active: {[t.name for t in active]}")
    print(f"All: {[t.name for t in registry.all_tools()]}")
    excluded = [t for t in registry.all_tools() if t not in active]
    print(f"Excluded: {[t.name for t in excluded]}")


if __name__ == "__main__":
    asyncio.run(main())

Step by step

1. Declare tools with their requirements

class DocTool:
    name = "doc_read"
    description = "Read documents"
    requires: list[type] = [DocumentStore]
    async def __call__(self, state: Any, **kw: Any) -> str:
        return "doc"

A tool is any object exposing name, description, an async __call__, and an optional requires list of the protocols it depends on. DocTool declares it needs a DocumentStore; SkillTool declares it needs a SkillStore. The requires list is the contract the registry uses to decide availability.

This example uses the plain class form to make the requires contract explicit. In application code you would more often use the @symfonic_tool decorator, which derives the schema from your type hints and docstring.

2. Register the tools

registry = ToolRegistry()
registry.register(DocTool())
registry.register(SkillTool())

ToolRegistry is the container. Registering a tool makes it known — not necessarily active. Whether it becomes active depends on step 4.

3. Wire only some dependencies

deps = BaseAgentDeps(ModelProvider=MockModelProvider())
deps.register(DocumentStore, InMemoryDocumentStore())
# note: no SkillStore is registered

We deliberately provide a DocumentStore but not a SkillStore. This is the whole point of the demo: the environment can satisfy DocTool but not SkillTool.

4. Let the registry filter

active = registry.get_active_tools(deps)

get_active_tools(deps) returns only the tools whose every requires entry is present in deps. DocTool passes (its DocumentStore is registered); SkillTool is filtered out (no SkillStore). The model therefore only ever sees tools it can actually call — no runtime "capability missing" surprises.

5. Inspect the split

print(f"Active: {[t.name for t in active]}")           # ['doc_read']
print(f"All: {[t.name for t in registry.all_tools()]}") # ['doc_read', 'skill_list']
excluded = [t for t in registry.all_tools() if t not in active]
print(f"Excluded: {[t.name for t in excluded]}")        # ['skill_list']

all_tools() is everything registered; active is the satisfiable subset. The difference is what got filtered and why.

Why this matters

Capability filtering keeps the model's tool surface honest: a tool that cannot run in the current deployment never appears in the prompt, so the model cannot hallucinate a call to it. Register the same tool set everywhere, and each environment (dev, staging, a specific tenant) automatically exposes only what it can back.

What to try next

See also