# Dynamic Tool Injection via Middleware (Failed Attempt)

This notebook demonstrates an **unsuccessful** attempt to dynamically inject tools
using `wrap_model_call` middleware.

## The Idea

[This LangChain forum post](https://forum.langchain.com/t/are-dynamic-tool-lists-allowed-when-using-create-agent/1920/4)
suggests modifying `request.tools` in `wrap_model_call` to dynamically add tools.
The post's **intent** (filtering tools based on user context) is valid, but the
**code example** doesn't work as written because it creates an agent with `tools=[]`
and tries to inject tools via middleware.

## Why It Doesn't Work

The `create_agent` function creates a graph where tool execution is handled by a
separate `tools` node. Even if we inject tool definitions into the model call,
the tools node only knows about the tools registered at agent creation time.

When the LLM tries to call a dynamically injected tool, the tools node raises
`ToolException: Tool not found` because the tool was never registered with the executor.

## Working Alternative

The correct approach is to register all tools upfront and **filter** them in middleware.
See the "Appendix" section below and `tool-search-filter.ipynb` for working examples.

## Setup



```python
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from typing import Any

import rich
from dotenv import load_dotenv
from langchain.agents import create_agent
from langchain.agents.middleware.types import (
    AgentMiddleware,
    AgentState,
    ModelRequest,
    ModelResponse,
)
from langchain_anthropic import ChatAnthropic
from langchain_core.tools import BaseTool, tool
from langgraph.graph.state import CompiledStateGraph

load_dotenv()
```




    True



## Define Tools



```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_capital(country: str) -> str:
    """Get the capital city of a country."""
    capitals = {"japan": "Tokyo", "france": "Paris", "germany": "Berlin"}
    return capitals.get(country.lower(), f"Unknown capital for {country}")


# Tool registry
TOOL_REGISTRY: dict[str, BaseTool] = {
    "get_weather": get_weather,
    "get_capital": get_capital,
}

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


    Available tools:
    ['get_weather', 'get_capital']



## Middleware That Injects Tools

This middleware attempts to add extra tools to `request.tools` based on context.
The forum post suggests this approach for "dynamic tool discovery".



```python
@dataclass
class UserContext:
    """Context schema for user-level based tool access."""

    user_level: str = "beginner"


def get_tool_names(tools: Sequence[BaseTool | dict[str, Any]]) -> list[str]:
    """Extract tool names from a list of tools (BaseTool or dict)."""
    return [t.name if isinstance(t, BaseTool) else t.get("name", "?") for t in tools]


class DynamicToolInjectionMiddleware(AgentMiddleware[AgentState[Any], UserContext]):
    """Middleware that injects tools based on user level in context.

    Expert users get all tools, beginners only get get_capital.
    """

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

    def wrap_model_call(
        self,
        request: ModelRequest,
        handler: Callable[[ModelRequest], ModelResponse],
    ) -> ModelResponse:
        """Intercept model call and inject tools based on user level."""
        # Get user level from runtime context
        user_level = getattr(request.runtime.context, "user_level", "beginner")

        rich.print(f"[dim]User level: {user_level}[/dim]")
        rich.print(f"[dim]Original tools: {get_tool_names(request.tools)}[/dim]")

        # Select tools based on user level
        tools_to_inject: list[BaseTool | dict[str, Any]]
        if user_level == "expert":
            # Experts get all tools
            tools_to_inject = list(self.tool_registry.values())
        else:
            # Beginners only get get_capital
            tools_to_inject = [self.tool_registry["get_capital"]]

        # Inject tools into request using override()
        request = request.override(tools=tools_to_inject)
        rich.print(f"[yellow]Injected tools: {get_tool_names(request.tools)}[/yellow]")

        return handler(request)
```

## Create Agent with NO Tools Initially

We create an agent with an empty tool list, expecting middleware to inject them.



```python
middleware = DynamicToolInjectionMiddleware(TOOL_REGISTRY)
model = ChatAnthropic(model="claude-sonnet-4-5-20250929")

# Create agent with NO tools - middleware will inject them
agent: CompiledStateGraph[Any] = create_agent(
    model=model,
    tools=[],  # Empty! Middleware should inject
    system_prompt="You are a helpful assistant. Use tools when needed.",
    middleware=[middleware],  # type: ignore[list-item]
    context_schema=UserContext,  # type: ignore[arg-type]
)

rich.print("Agent created with 0 tools")
```


    Agent created with 0 tools



## Test: Expert User Tries to Use get_weather

Expected: Middleware injects tools, LLM sees them, but tools node fails.



```python
# Pass user_level via context
try:
    for chunk in agent.stream(
        {"messages": [{"role": "user", "content": "What's the weather in Tokyo?"}]},
        context=UserContext(user_level="expert"),  # type: ignore[arg-type]
    ):
        rich.print("chunk =", chunk)
except Exception as e:
    rich.print(f"[bold red]Error: {type(e).__name__}: {e}[/bold red]")
```


    User level: expert




    Original tools: []




    Injected tools: ['get_weather', 'get_capital']




    Error: ValueError: Middleware returned unknown tool names: ['get_weather', 'get_capital']
    
    Available client-side tools: []
    
    To fix this issue:
    1. Ensure the tools are passed to create_agent() via the 'tools' parameter
    2. If using custom middleware with tools, ensure they're registered via middleware.tools attribute
    3. Verify that tool names in ModelRequest.tools match the actual tool.name values
    Note: Built-in provider tools (dict format) can be added dynamically.



## Analysis

The error occurs because:

1. **Middleware injection works partially**: The LLM sees the injected tools and
   generates a tool call for `get_weather`.

2. **Tools node doesn't know about injected tools**: The graph's `tools` node was
   created with an empty tool list. When it receives the `get_weather` tool call,
   it can't find the corresponding tool executor.

3. **No dynamic executor registration**: LangGraph doesn't support adding tool
   executors at runtime. The tool registry is fixed when `create_agent` is called.

### Note: "Built-in provider tools (dict format) can be added dynamically"

The error message mentions this because LangChain supports two tool formats:

| Format | Example | Dynamic? |
|--------|---------|----------|
| `BaseTool` | `@tool` decorated functions | No - needs local executor |
| `dict` | `{"type": "web_search", ...}` | Yes - runs server-side |

`dict` format tools are Anthropic's server-side tools like `computer_use`, `web_search`.
These execute on Anthropic's servers, so no local executor is needed.

## Working Alternatives

- **`tool-search-filter.ipynb`** - Middleware filtering (simpler, all tools registered upfront)
- **`tool-search-interrupt.ipynb`** - Interrupt + agent recreation (truly dynamic tools)

## Appendix: What If We Register Tools But Hide Them?

Let's try registering all tools but hiding some via middleware. This should work
because the tools node has all executors.



```python
class ToolFilterMiddleware(AgentMiddleware[AgentState[Any], UserContext]):
    """Middleware that filters tools shown to LLM based on user level.

    Unlike injection, filtering works because all tools are registered.
    """

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

    def wrap_model_call(
        self,
        request: ModelRequest,
        handler: Callable[[ModelRequest], ModelResponse],
    ) -> ModelResponse:
        """Filter tools based on user level."""
        user_level = getattr(request.runtime.context, "user_level", "beginner")

        rich.print(f"[dim]User level: {user_level}[/dim]")
        rich.print(f"[dim]Original tools: {get_tool_names(request.tools)}[/dim]")

        if user_level == "expert":
            # Show all tools - no filtering needed
            pass
        else:
            # Filter to only show get_capital
            filtered: list[BaseTool | dict[str, Any]] = [
                t
                for t in request.tools
                if (isinstance(t, BaseTool) and t.name == "get_capital")
                or (isinstance(t, dict) and t.get("name") == "get_capital")
            ]
            request = request.override(tools=filtered)

        rich.print(f"[green]Filtered tools: {get_tool_names(request.tools)}[/green]")
        return handler(request)
```


```python
filter_middleware = ToolFilterMiddleware(TOOL_REGISTRY)

# Create agent with ALL tools registered
agent_with_filter: CompiledStateGraph[Any] = create_agent(
    model=model,
    tools=list(TOOL_REGISTRY.values()),  # All tools registered
    system_prompt="You are a helpful assistant. Use tools when needed.",
    middleware=[filter_middleware],  # type: ignore[list-item]
    context_schema=UserContext,  # type: ignore[arg-type]
)

rich.print("Agent created with all tools, filter middleware will hide some")
```


    Agent created with all tools, filter middleware will hide some




```python
# Beginner user - should only see get_capital
for chunk in agent_with_filter.stream(
    {"messages": [{"role": "user", "content": "What's the capital of Japan?"}]},
    context=UserContext(user_level="beginner"),  # type: ignore[arg-type]
):
    rich.print("chunk =", chunk)
```


    User level: beginner




    Original tools: ['get_weather', 'get_capital']




    Filtered tools: ['get_capital']




    chunk =
    {
        'model': {
            'messages': [
                AIMessage(
                    content=[
                        {
                            'id': 'toolu_01UJmKShEihBohAqVAJqB9fn',
                            'input': {'country': 'Japan'},
                            'name': 'get_capital',
                            'type': 'tool_use'
                        }
                    ],
                    additional_kwargs={},
                    response_metadata={
                        'id': 'msg_0115DfuMwaqvd4N3eZp4tkgY',
                        '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': 577,
                            'output_tokens': 53,
                            'server_tool_use': None,
                            'service_tier': 'standard'
                        },
                        'model_name': 'claude-sonnet-4-5-20250929',
                        'model_provider': 'anthropic'
                    },
                    id='lc_run--f6beedef-f46f-4fd4-891c-85a3cf9d6b0b-0',
                    tool_calls=[
                        {
                            'name': 'get_capital',
                            'args': {'country': 'Japan'},
                            'id': 'toolu_01UJmKShEihBohAqVAJqB9fn',
                            'type': 'tool_call'
                        }
                    ],
                    usage_metadata={
                        'input_tokens': 577,
                        'output_tokens': 53,
                        'total_tokens': 630,
                        'input_token_details': {
                            'cache_read': 0,
                            'cache_creation': 0,
                            'ephemeral_5m_input_tokens': 0,
                            'ephemeral_1h_input_tokens': 0
                        }
                    }
                )
            ]
        }
    }




    chunk =
    {
        'tools': {
            'messages': [
                ToolMessage(
                    content='Tokyo',
                    name='get_capital',
                    id='f693ea95-f48a-40e8-bcbd-a067088fe894',
                    tool_call_id='toolu_01UJmKShEihBohAqVAJqB9fn'
                )
            ]
        }
    }




    User level: beginner




    Original tools: ['get_weather', 'get_capital']




    Filtered tools: ['get_capital']




    chunk =
    {
        'model': {
            'messages': [
                AIMessage(
                    content='The capital of Japan is **Tokyo**.',
                    additional_kwargs={},
                    response_metadata={
                        'id': 'msg_01P9BqMzmSD3Kerk4LgsTTgC',
                        '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': 644,
                            'output_tokens': 11,
                            'server_tool_use': None,
                            'service_tier': 'standard'
                        },
                        'model_name': 'claude-sonnet-4-5-20250929',
                        'model_provider': 'anthropic'
                    },
                    id='lc_run--485f261d-c0b3-4429-ab87-d15d297334f3-0',
                    usage_metadata={
                        'input_tokens': 644,
                        'output_tokens': 11,
                        'total_tokens': 655,
                        'input_token_details': {
                            'cache_read': 0,
                            'cache_creation': 0,
                            'ephemeral_5m_input_tokens': 0,
                            'ephemeral_1h_input_tokens': 0
                        }
                    }
                )
            ]
        }
    }




```python
# Expert user - should see all tools
for chunk in agent_with_filter.stream(
    {"messages": [{"role": "user", "content": "What's the weather in Paris?"}]},
    context=UserContext(user_level="expert"),  # type: ignore[arg-type]
):
    rich.print("chunk =", chunk)
```


    User level: expert




    Original tools: ['get_weather', 'get_capital']




    Filtered tools: ['get_weather', 'get_capital']




    chunk =
    {
        'model': {
            'messages': [
                AIMessage(
                    content=[
                        {
                            'id': 'toolu_01SABjv2pZqSGCD577aoneZ7',
                            'input': {'city': 'Paris'},
                            'name': 'get_weather',
                            'type': 'tool_use'
                        }
                    ],
                    additional_kwargs={},
                    response_metadata={
                        'id': 'msg_01NrpcLKm54U54kjSEKMQU6y',
                        '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': 631,
                            'output_tokens': 53,
                            'server_tool_use': None,
                            'service_tier': 'standard'
                        },
                        'model_name': 'claude-sonnet-4-5-20250929',
                        'model_provider': 'anthropic'
                    },
                    id='lc_run--ed4b66c1-7fbb-48af-9e94-3b58d1631487-0',
                    tool_calls=[
                        {
                            'name': 'get_weather',
                            'args': {'city': 'Paris'},
                            'id': 'toolu_01SABjv2pZqSGCD577aoneZ7',
                            'type': 'tool_call'
                        }
                    ],
                    usage_metadata={
                        'input_tokens': 631,
                        'output_tokens': 53,
                        'total_tokens': 684,
                        '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 Paris: Sunny, 22°C',
                    name='get_weather',
                    id='cf3ee6a9-82c7-46c4-bca1-064e09e1a5e1',
                    tool_call_id='toolu_01SABjv2pZqSGCD577aoneZ7'
                )
            ]
        }
    }




    User level: expert




    Original tools: ['get_weather', 'get_capital']




    Filtered tools: ['get_weather', 'get_capital']




    chunk =
    {
        'model': {
            'messages': [
                AIMessage(
                    content='The weather in Paris is currently sunny with a temperature of 22°C.',
                    additional_kwargs={},
                    response_metadata={
                        'id': 'msg_014rz8VpWVNGUFkr8MGfcQko',
                        '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': 708,
                            'output_tokens': 19,
                            'server_tool_use': None,
                            'service_tier': 'standard'
                        },
                        'model_name': 'claude-sonnet-4-5-20250929',
                        'model_provider': 'anthropic'
                    },
                    id='lc_run--943452e9-2aa9-41b7-b306-51271f6065f1-0',
                    usage_metadata={
                        'input_tokens': 708,
                        'output_tokens': 19,
                        'total_tokens': 727,
                        'input_token_details': {
                            'cache_read': 0,
                            'cache_creation': 0,
                            'ephemeral_5m_input_tokens': 0,
                            'ephemeral_1h_input_tokens': 0
                        }
                    }
                )
            ]
        }
    }



## Conclusion

| Approach                             | Works? | Why                                    |
| ------------------------------------ | ------ | -------------------------------------- |
| Inject new tools via middleware      | No     | Tools node doesn't have executors      |
| Filter existing tools via middleware | Yes    | All executors registered at creation   |
| Interrupt + recreate agent           | Yes    | New agent has proper tool registration |

**Key insight**: Middleware can only filter/modify what's already registered.
True dynamic tool discovery requires recreating the agent or using a custom graph.

