# Tool Search Pattern: Interrupt + Recreate

Demonstrate dynamic tool discovery using interrupt and agent recreation.

When agents have many tools (30-50+), providing all tools upfront causes:

1. **Context bloat**: Tool definitions consume tokens
2. **Selection errors**: LLM struggles to choose the right tool

**Approach**: Start with a `tool_search` tool, use middleware to intercept results
and interrupt. Then recreate the agent with discovered tools and resume.

**Comparison with filter pattern** (`tool-search-filter.ipynb`):

| Aspect | Interrupt Pattern | Filter Pattern |
|--------|-------------------|----------------|
| Complexity | More complex | Simpler |
| Token saving | Yes | Yes |
| Tool registration | Dynamic | All upfront |
| Use case | Truly dynamic tools | Known tool set |
| State management | Checkpointer required | Middleware state |

**When to use this pattern**:
- Tools loaded from external sources (APIs, plugins)
- Tool set changes at runtime
- Cannot enumerate all tools upfront

> **Known Issue**: When resuming after interrupt, LangGraph restarts the entire tools node
> from the beginning, causing `tool_search` to be executed twice. The second execution
> is harmless (no new discoveries), but tool call logs may appear duplicated.

## Setup



```python
import re
from collections.abc import Callable
from typing import Any, cast

import rich
from dotenv import load_dotenv
from langchain.agents import create_agent
from langchain.agents.middleware.types import AgentMiddleware, AgentState
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import ToolMessage
from langchain_core.runnables import RunnableConfig
from langchain_core.tools import BaseTool, tool
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph.state import CompiledStateGraph
from langgraph.prebuilt.tool_node import ToolCallRequest
from langgraph.types import Command, interrupt

load_dotenv()
```




    True



## Define Tool Registry

Create a registry of all available tools. These are "deferred" - not given to the agent initially.



```python
@tool
def get_weather(city: str) -> str:
    """Get current weather for a city."""
    return f"Weather in {city}: Sunny, 22°C"


@tool
def get_forecast(city: str, days: int = 3) -> str:
    """Get weather forecast for a city."""
    return f"{days}-day forecast for {city}: Sunny → Cloudy → Rain"


@tool
def send_email(to: str, subject: str, body: str) -> str:
    """Send an email to a recipient."""
    return f"Email sent to {to}: {subject}"


@tool
def read_emails(folder: str = "inbox") -> str:
    """Read emails from a folder."""
    return f"3 unread emails in {folder}"


@tool
def create_calendar_event(title: str, date: str) -> str:
    """Create a calendar event."""
    return f"Event '{title}' created on {date}"


@tool
def list_calendar_events(date: str) -> str:
    """List calendar events for a date."""
    return f"Events on {date}: Meeting at 10am, Lunch at 12pm"


# Tool registry: name -> tool
TOOL_REGISTRY: dict[str, BaseTool] = {
    t.name: t
    for t in [
        get_weather,
        get_forecast,
        send_email,
        read_emails,
        create_calendar_event,
        list_calendar_events,
    ]
}

rich.print("Available tools:", list(TOOL_REGISTRY.keys()))
```


    Available tools:
    ['get_weather', 'get_forecast', 'send_email', 'read_emails', 'create_calendar_event', 'list_calendar_events']



## Tool Search Tool

The only tool given to the agent initially. Returns tool names matching a regex pattern.



```python
@tool
def tool_search(pattern: str) -> list[dict[str, str]]:
    """Search for available tools by regex pattern.

    Args:
        pattern: Regex pattern to match against tool names and descriptions.

    Returns:
        List of matching tools with name and description.
    """
    matches = []
    for name, t in TOOL_REGISTRY.items():
        if re.search(pattern, name, re.IGNORECASE) or re.search(pattern, t.description, re.IGNORECASE):
            matches.append({"name": name, "description": t.description})
    return matches
```

## Dynamic Tool Middleware

Intercepts model calls and adds discovered tools to the request.



```python
import json


class DynamicToolMiddleware(AgentMiddleware[AgentState[Any], None]):
    """Middleware that dynamically adds tools based on tool_search results.

    Uses interrupt to pause after tool discovery, allowing agent to be
    recreated with the new tools.
    """

    INTERRUPT_TYPE = "tool_discovery"  # Type identifier for this middleware

    def __init__(self, tool_registry: dict[str, BaseTool]):
        self.tool_registry = tool_registry
        self.discovered_tools: set[str] = set()

    def wrap_tool_call(
        self,
        request: ToolCallRequest,
        handler: Callable[[ToolCallRequest], ToolMessage | Command[Any]],
    ) -> ToolMessage | Command[Any]:
        """Intercept tool_search results to track discovered tools."""
        result = handler(request)

        # If this was a tool_search call, parse the results
        tool_name = request.tool.name if isinstance(request.tool, BaseTool) else ""
        if tool_name == "tool_search" and isinstance(result, ToolMessage):
            # Extract tool names from the JSON result
            try:
                tools = json.loads(str(result.content))
                tool_names = {t.get("name") for t in tools if isinstance(t, dict) and t.get("name")}
            except json.JSONDecodeError:
                tool_names = set()

            new_discoveries = False
            for name in tool_names:
                if name in self.tool_registry and name not in self.discovered_tools:
                    self.discovered_tools.add(name)
                    new_discoveries = True
            rich.print(f"[dim]Discovered tools: {self.discovered_tools}[/dim]")

            # Interrupt to allow tools to be added
            if new_discoveries:
                interrupt(
                    {
                        "type": self.INTERRUPT_TYPE,
                        "discovered_tools": list(self.discovered_tools),
                    }
                )

        return result
```

## Create Agent



```python
from collections.abc import Iterator

middleware = DynamicToolMiddleware(TOOL_REGISTRY)
checkpointer = InMemorySaver()  # Shared checkpointer

model = ChatAnthropic(model="claude-sonnet-4-5-20250929")


def create_agent_with_tools(discovered: set[str]) -> CompiledStateGraph[Any]:
    """Create agent with tool_search + discovered tools."""
    tools_to_use: list[BaseTool] = [tool_search]
    for name in discovered:
        if name in TOOL_REGISTRY:
            tools_to_use.append(TOOL_REGISTRY[name])
    return create_agent(
        model=model,
        tools=tools_to_use,
        system_prompt="You are a helpful assistant. Use tool_search to find tools.",
        middleware=[middleware],
        checkpointer=checkpointer,  # Use shared checkpointer
    )


def stream_with_tool_discovery(
    agent: CompiledStateGraph[Any],
    input_or_command: dict[str, Any] | Command[Any],
    config: RunnableConfig,
) -> Iterator[dict[str, Any]]:
    """Stream agent and handle tool discovery interrupts recursively."""
    yield from agent.stream(input_or_command, config)

    state = agent.get_state(config)

    if not (state.next and state.tasks):
        return

    for task in state.tasks:
        for intr in task.interrupts:
            if intr.value.get("type") == middleware.INTERRUPT_TYPE:
                discovered = intr.value.get("discovered_tools", [])
                rich.print(f"[bold yellow]Discovered: {discovered}[/bold yellow]")

                # Recreate agent with discovered tools and resume
                new_agent = create_agent_with_tools(set(discovered))
                yield from stream_with_tool_discovery(new_agent, Command(resume={}), config)
                return


# Start with only tool_search
agent: CompiledStateGraph[Any] = create_agent_with_tools(set())
```

## Test: Dynamic Tool Discovery

Ask about weather - agent will search for tools, then we resume with discovered tools.



```python
config = cast(RunnableConfig, {"configurable": {"thread_id": "test-1"}})

for chunk in stream_with_tool_discovery(
    agent,
    {"messages": [{"role": "user", "content": "What's the weather in Tokyo?"}]},
    config,
):
    rich.print("chunk =", chunk)
```


    chunk =
    {
        'model': {
            'messages': [
                AIMessage(
                    content=[
                        {
                            'text': "I'll search for weather-related tools to help you get the weather information for 
    Tokyo.",
                            'type': 'text'
                        },
                        {
                            'id': 'toolu_015CwrK7xAGpcb1yaWx4dR4G',
                            'input': {'pattern': 'weather'},
                            'name': 'tool_search',
                            'type': 'tool_use'
                        }
                    ],
                    additional_kwargs={},
                    response_metadata={
                        'id': 'msg_01JELMADuqcGJFukAfuVq7pY',
                        'model': 'claude-sonnet-4-5-20250929',
                        'stop_reason': 'tool_use',
                        'stop_sequence': None,
                        'usage': {
                            'cache_creation': {'ephemeral_1h_input_tokens': 0, 'ephemeral_5m_input_tokens': 0},
                            'cache_creation_input_tokens': 0,
                            'cache_read_input_tokens': 0,
                            'input_tokens': 618,
                            'output_tokens': 72,
                            'server_tool_use': None,
                            'service_tier': 'standard'
                        },
                        'model_name': 'claude-sonnet-4-5-20250929',
                        'model_provider': 'anthropic'
                    },
                    id='lc_run--7fb79f81-1e8f-4b5c-8f59-a31dadca5abb-0',
                    tool_calls=[
                        {
                            'name': 'tool_search',
                            'args': {'pattern': 'weather'},
                            'id': 'toolu_015CwrK7xAGpcb1yaWx4dR4G',
                            'type': 'tool_call'
                        }
                    ],
                    usage_metadata={
                        'input_tokens': 618,
                        'output_tokens': 72,
                        'total_tokens': 690,
                        'input_token_details': {
                            'cache_read': 0,
                            'cache_creation': 0,
                            'ephemeral_5m_input_tokens': 0,
                            'ephemeral_1h_input_tokens': 0
                        }
                    }
                )
            ]
        }
    }




    Discovered tools: {'get_weather', 'get_forecast'}




    chunk =
    {
        '__interrupt__': (
            Interrupt(
                value={'type': 'tool_discovery', 'discovered_tools': ['get_weather', 'get_forecast']},
                id='0e19284d0fd3346a80d1695fa55bacbd'
            ),
        )
    }




    Discovered: ['get_weather', 'get_forecast']




    Discovered tools: {'get_weather', 'get_forecast'}




    chunk =
    {
        'tools': {
            'messages': [
                ToolMessage(
                    content='[{"name": "get_weather", "description": "Get current weather for a city."}, {"name": 
    "get_forecast", "description": "Get weather forecast for a city."}]',
                    name='tool_search',
                    id='c5f3f1b1-b6b4-47af-a745-232f2f9fdc26',
                    tool_call_id='toolu_015CwrK7xAGpcb1yaWx4dR4G'
                )
            ]
        }
    }




    chunk =
    {
        'model': {
            'messages': [
                AIMessage(
                    content=[
                        {
                            'text': "Now I'll get the current weather for Tokyo using the get_weather tool.",
                            'type': 'text'
                        },
                        {
                            'id': 'toolu_01RSLep68QqLF8adG3Pnnjj2',
                            'input': {'city': 'Tokyo'},
                            'name': 'get_weather',
                            'type': 'tool_use'
                        }
                    ],
                    additional_kwargs={},
                    response_metadata={
                        'id': 'msg_01XzjmdiRFbcGmDaF1vosKqT',
                        'model': 'claude-sonnet-4-5-20250929',
                        'stop_reason': 'tool_use',
                        'stop_sequence': None,
                        'usage': {
                            'cache_creation': {'ephemeral_1h_input_tokens': 0, 'ephemeral_5m_input_tokens': 0},
                            'cache_creation_input_tokens': 0,
                            'cache_read_input_tokens': 0,
                            'input_tokens': 867,
                            'output_tokens': 70,
                            'server_tool_use': None,
                            'service_tier': 'standard'
                        },
                        'model_name': 'claude-sonnet-4-5-20250929',
                        'model_provider': 'anthropic'
                    },
                    id='lc_run--baadbfbf-7e77-4cf3-9cf4-4776b081c0c3-0',
                    tool_calls=[
                        {
                            'name': 'get_weather',
                            'args': {'city': 'Tokyo'},
                            'id': 'toolu_01RSLep68QqLF8adG3Pnnjj2',
                            'type': 'tool_call'
                        }
                    ],
                    usage_metadata={
                        'input_tokens': 867,
                        'output_tokens': 70,
                        'total_tokens': 937,
                        'input_token_details': {
                            'cache_read': 0,
                            'cache_creation': 0,
                            'ephemeral_5m_input_tokens': 0,
                            'ephemeral_1h_input_tokens': 0
                        }
                    }
                )
            ]
        }
    }




    chunk =
    {
        'tools': {
            'messages': [
                ToolMessage(
                    content='Weather in Tokyo: Sunny, 22°C',
                    name='get_weather',
                    id='7e067ea7-20a1-4e5b-98ba-06a82a93e941',
                    tool_call_id='toolu_01RSLep68QqLF8adG3Pnnjj2'
                )
            ]
        }
    }




    chunk =
    {
        'model': {
            'messages': [
                AIMessage(
                    content='The current weather in Tokyo is **Sunny** with a temperature of **22°C** (approximately 
    72°F).',
                    additional_kwargs={},
                    response_metadata={
                        'id': 'msg_01Bn9pgNkewp1H4926kpEDSp',
                        'model': 'claude-sonnet-4-5-20250929',
                        'stop_reason': 'end_turn',
                        'stop_sequence': None,
                        'usage': {
                            'cache_creation': {'ephemeral_1h_input_tokens': 0, 'ephemeral_5m_input_tokens': 0},
                            'cache_creation_input_tokens': 0,
                            'cache_read_input_tokens': 0,
                            'input_tokens': 960,
                            'output_tokens': 29,
                            'server_tool_use': None,
                            'service_tier': 'standard'
                        },
                        'model_name': 'claude-sonnet-4-5-20250929',
                        'model_provider': 'anthropic'
                    },
                    id='lc_run--2f238f90-292c-4427-9223-bd969f1a9cc5-0',
                    usage_metadata={
                        'input_tokens': 960,
                        'output_tokens': 29,
                        'total_tokens': 989,
                        'input_token_details': {
                            'cache_read': 0,
                            'cache_creation': 0,
                            'ephemeral_5m_input_tokens': 0,
                            'ephemeral_1h_input_tokens': 0
                        }
                    }
                )
            ]
        }
    }


