Tool Call Self-Repair

Runtime auto-fix of LLM-emitted tool names, corrective errors with nearest-tool hints, and parameter-hint feedback

When the model calls WebSearch but your tool is registered as web_search, PraisonAI fixes the name automatically — and when it can’t, it tells the model exactly which tools exist and how to call them.

python
from praisonaiagents import Agent

def web_search(query: str) -> str:
    """Search the web."""
    return f"results for {query}"

agent = Agent(name="Search", instructions="Search the web", tools=[web_search])
agent.start("Search for 'planet orbits'")
# The model may emit the tool call 'WebSearch' — it dispatches to web_search automatically.
Diagram
graph LR
    subgraph "Tool Call Self-Repair"
        Call[🤖 LLM tool call] --> Match{🔍 Name match?}
        Match -->|Hit| Run[✅ Dispatch tool]
        Match -->|Miss| Norm{🔧 Repairable?}
        Norm -->|Unique match| Run
        Norm -->|No match| Fix[📋 Corrective error + hint]
        Run --> Boom{❌ Raised?}
        Boom -->|Bind error| Hint[📋 Schema hint]
        Boom -->|Value error| Raw[📩 Original message]
        Fix --> Retry[🔁 Model retries]
        Hint --> Retry
        Raw --> Retry
    end

    classDef call fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef check fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef run fill:#10B981,stroke:#7C90A0,color:#fff
    classDef fix fill:#189AB4,stroke:#7C90A0,color:#fff

    class Call,Retry call
    class Match,Norm,Boom check
    class Run run
    class Fix,Hint,Raw fix

Quick Start

  1. Case and separator drift auto-fixes
    python
    agent.execute_tool("WebSearch", {"query": "hello"})   # -> "results for hello"
    agent.execute_tool("web-search", {"query": "world"})  # -> "results for world"

    Both dispatch to web_search. No configuration required.

  2. Unknown names return a corrective message
    python
    from praisonaiagents.errors import ToolExecutionError
    
    try:
        agent.execute_tool("totally_made_up_tool", {})
    except ToolExecutionError as e:
        print(str(e))
        # Tool 'totally_made_up_tool' not found. Did you mean 'web_search'? Available tools: ['web_search']

    The message is what the model sees, so on the next turn it retries with a valid name.

  3. Bind failures echo the parameters
    python
    try:
        agent.execute_tool("web_search", {})   # missing 'query'
    except ToolExecutionError as e:
        print(str(e))
        # ... Expected parameters for 'web_search' — required: ['query'], optional: ['limit'].

    The model learns which arguments to send next. This hint fires only when the kwargs cannot bind — a genuine argument-binding error.

  4. Value errors pass through unchanged
    python
    from praisonaiagents import Agent
    from praisonaiagents.errors import ToolExecutionError
    
    def web_search(query: str) -> str:
        """Search the web."""
        if not query:
            raise ValueError("query must not be empty")
        return f"results for {query}"
    
    agent = Agent(name="Search", instructions="Search the web", tools=[web_search])
    
    try:
        agent.execute_tool("web_search", {"query": ""})   # bound OK, tool raises
    except ToolExecutionError as e:
        print(str(e))
        # query must not be empty

    The kwargs bound cleanly, so the model isn’t told to change the parameter names — it’s told to fix the value.

Async parity

max_tool_repairs and force_tool_usage apply identically whether the agent is called synchronously (agent.start(...)) or awaited (agent.chat(...) / agent.astart(...)). Before PraisonAI PR #5359, both settings — set automatically by OllamaAdapter and passable via the llm dict — were honoured on the sync path but silently ignored the moment the agent was awaited. They now take effect on both paths.


How It Works

Every tool call runs through Agent.execute_tool; a dispatch miss triggers normalisation, repair, or a corrective error before the result returns to the model.

Diagram
Tool RegistryAgentLLMTool RegistryAgentLLMalt[Bind-time error][Runtime error inside tool][Success]alt[Unique match][No match]execute_tool(“WebSearch”, {…})lookup “WebSearch”missnormalise + re-matchdispatch web_searchresultToolExecutionError (… Expected parameters for ‘web_search’ — …)ToolExecutionError (original message, no hint)tool resultToolExecutionError (Did you mean…? Available tools: […])execute_tool(“web_search”, {…})

Normalisation compares str(name).lower().replace('_', '').replace('-', '').replace(' ', ''). Repair fires only when exactly one active tool normalises to the same key — ambiguous matches skip repair and fall through to the corrective error, so there is never an ambiguous dispatch.


Four failure modes, four responses

Case / separator drift

Silent auto-fix. The tool runs and returns its normal result. A debug log records the repair:

text
Self-repaired tool name 'WebSearch' -> 'web_search'

Unknown name

The impl returns a corrective dict; the public path raises ToolExecutionError carrying the same text:

python
{
    "error": "Tool 'totally_made_up_tool' not found. Did you mean 'web_search'? Available tools: ['calculator', 'web_search']",
    "available_tools": ["calculator", "web_search"],
}

The nearest name uses difflib.get_close_matches(name, available, n=1, cutoff=0.5).

Bind failure

A genuine argument-binding error — inspect.signature(...).bind(**kwargs) raises TypeError before the tool body runs (missing, extra, or wrong-named kwargs). The runtime introspects the signature and folds the parameters into the message:

python
{
    "error": "missing a required argument: 'query' Expected parameters for 'web_search' — required: ['query'], optional: ['limit'].",
    "expected_parameters": {"required": ["query"], "optional": ["limit"]},
}

Introspection skips self, *args, and **kwargs. BaseTool subclasses are inspected via .run (classes fall back to ._run).

Runtime error inside the tool

The kwargs bind cleanly and the tool body raises its own TypeError / ValueError. The runtime surfaces the original error and adds no parameter hint — the model needs to change the value, not the argument names:

python
def web_search(query: str) -> str:
    if not query:
        raise ValueError("query must not be empty")
    ...

agent.execute_tool("web_search", {"query": ""})
# {"error": "query must not be empty"}

No expected_parameters field and no Expected parameters for '<tool>' — ... suffix in the message.


Text-based tool-call recovery (local models)

Small local models often emit a tool call as text inside content instead of using the native tool_calls[] field. The adapters for Ollama and OpenAI-compatible local servers (LM Studio, vLLM, llama.cpp) parse that text and dispatch the call anyway — with no extra LLM round trip.

python
from praisonaiagents import Agent

def get_weather(city: str) -> str:
    return f"{city}: 21°C"

agent = Agent(
    llm="ollama/llama3.2",
    instructions="Answer weather questions using get_weather.",
    tools=[get_weather],
)

agent.start("What's the weather in Paris and Berlin?")
# A small local model may write the calls as text in any of 6 dialects, e.g.:
#   Action: get_weather
#   Action Input: {"city": "Paris"}
#   [TOOL_CALL]{"name": "get_weather", "arguments": {"city": "Berlin"}}[/TOOL_CALL]
# Both run in order — no re-prompt, no extra round trip.

Which dialect does my model use?

Diagram

Qwen 2.x / 3.x

Mistral, generic

Llama 3.x

LM Studio

Older / ReAct prompt

Simple JSON prose

🤖 Local model emits a call as text

Which model family?

tool_call tag + JSON

function=name tag + JSON

python_tag + JSON

TOOL_CALL / TOOL_REQUEST

Action / Action Input

Whole-content JSON

✅ Salvaged in-process

Six dialects, one code path

Whole-content JSON

text
{"name": "get_weather", "arguments": {"city": "Paris"}}

Both {...} and [{...}, {...}] shapes work.

text
I'll check that for you.
<tool_call>{"name": "get_weather", "arguments": {"city": "Paris"}}</tool_call>
Multiple `` blocks are recovered in the order they appear. The payload’s `name` or `tool` key is accepted; arguments come from `arguments` or `parameters`.

ReAct

text
Thought: I should look this up.
Action: web_search
Action Input: {"query": "praisonai gateway"}

The same-line form also works:

text
Action: web_search Action Input: {"query": "x", "opts": {"n": 3}}

The JSON input is read to its end, so nested argument objects stay whole. ReAct input that is not JSON (a bare search string) stays prose — no call runs.

[TOOL_CALL] / [TOOL_REQUEST]

text
[TOOL_CALL]{"name": "web_search", "arguments": {"query": "a"}}[/TOOL_CALL]

LM Studio’s variant also works:

text
[TOOL_REQUEST]{"name": "get_weather", "arguments": {"city": "Paris"}}[END_TOOL_REQUEST]

Matching is case-insensitive. Multiple blocks are recovered in the order they appear.

Each payload is {"name": "...", "arguments": {...}}. The tool name key can be name or tool, and the arguments key can be arguments or parameters.

Safety guarantees

Advertised-name allowlist

A name recovered from any of the five embedded dialects must match a tool the model was actually offered. A hallucinated name is silently discarded.

python
# The agent was created with only [get_weather, get_stock_price].
# The model wrote:
#   <tool_call>{"name": "wire_transfer", "arguments": {...}}</tool_call>
# Salvage returns None -- no tool executes.

The whole-content JSON path is looser: since PraisonAI PR #5359 it recovers any dict with a name field without an allowlist check, so a legitimate call the model emits as JSON in prose is picked up even before name-repair runs. Argument filtering and downstream tool-name repair still apply to whatever it recovers.

Fenced code-block masking

A call inside a Markdown fence ( ` or ~~~, length ≥ 3) is treated as documentation and never run — including calls that name a real tool.

text
Example format:
```
<tool_call>{"name": "delete_user", "arguments": {"id": "42"}}</tool_call>
```

An unclosed fence is treated as running to end-of-content, so a truncated example that opens a fence and names a real tool cannot slip through.

Case-insensitive tag matching

<TOOL_CALL>, <Tool_Call>, `

, [tool_call], ACTION: and <|PYTHON_TAG|>` all match — a model that emits the markers in any case still recovers just like the lowercase form.

Prompt-injection resistance

A rejected call (unadvertised name or bad JSON) still reserves its text span, so a marker inside another call’s arguments is data, never a second call.

text
<tool_call>{"name": "web_search",
            "arguments": {"query": "Action: get_weather Action Input: {}"}}
</tool_call>

Only web_search runs — the quoted Action: text is passed as query. The same holds when the outer call is rejected: a marker inside a rejected <|python_tag|>, [TOOL_CALL] or ReAct payload never runs.

Zero cost when nothing to salvage

Clean provider-native responses return None after a single lowercase marker check — no regex runs on prose that never mentions a call. There is no per-turn cost to fear.

Adapter coverage

AdapterRecoverySync pathAsync pathStreaming
OllamaAdapter (ollama/…, ollama_chat/…)✅ all six dialects✅✅✅ end-of-stream
LocalOpenAIAdapter (LM Studio, vLLM, hosted_vllm/, llama.cpp)✅ all six dialects✅✅✅ end-of-stream
DefaultAdapter (hosted OpenAI-compatible via base_url)❌———
AnthropicAdapter❌———
GeminiAdapter❌———

Hosted models deliberately do not get salvage — a hosted model returning JSON prose must never have it silently interpreted as a tool call. Streaming: the sync and async loops build the full response text, then run recovery on the assembled buffer at end-of-stream, so a salvaged call still dispatches. Raw call text is still visible while the reply streams.

If the response also carries a native tool_calls[] entry, that is dispatched — text-based recovery only runs when tool_calls[] is empty. So a well-formed native call is never overridden by prose the model happened to also include.

Recovery participates in the same repair budget as name-and-parameter repair — controlled by max_tool_repairs (default 2 for local adapters, 0 for hosted). Since PraisonAI PR #5359, recovering a tool call from JSON content inside a prose reply no longer requires the tool’s name to be in an internal allowlist — any dict carrying a name field is a candidate for recovery, so a legitimate call the model buries in prose is picked up more reliably.

Diagram

Yes

No

No

Yes

No

Yes

🤖 Model response

🔍 Native tool_calls?

✅ Dispatch native

🔎 Any marker in content?

💬 Treat as prose

🧯 Mask fenced code

🧩 Scan 6 dialects

🛡 In allowlist?

🚫 Discard

🔢 Sort by text position

✅ Dispatch salvaged


Tool return values that can’t be JSON-serialised

If your tool returns a set, a datetime, a Path, or any other value that json.dumps() can’t handle, the tool-result message falls back to str(tool_result) — the turn survives and the model gets a readable string. No configuration required.

python
from datetime import datetime
from praisonaiagents import Agent

def now() -> datetime:
    return datetime.now()   # not JSON-serialisable

agent = Agent(name="Clock", instructions="Tell the time.", tools=[now])
agent.start("What time is it?")
# The datetime is str()-ified; the model reads it and answers naturally.

When your tool returns {"error": "…"} (or a list starting with one), the message the model sees is a standardised apology-oriented instruction: “Error: &lt;your error&gt;. Please inform the user that the operation could not be completed.” — so the model explains the failure naturally instead of echoing the raw error.

When your tool returns None, the model sees “Function returned an empty output” as the result.

For Ollama specifically, the same information is delivered as a role: "user" natural-language turn instead of role: "tool" — see Ollama → How Ollama is handled differently.


Why the hints reach the model

ToolExecutionError preserves only its message through conversion, so the corrective payload is folded into the message string itself.

Corrective inventory in the message

The Did you mean '<nearest>'? hint and the Available tools: [...] list live inside the error string, so the follow-up model turn sees them via the raised ToolExecutionError.message.

Parameter hint in the message

The bind-failure branch appends Expected parameters for '<tool>' — required: [...], optional: [...]. to the message string. The same names are also returned as a structured expected_parameters dict on the impl-level result.

Only true bind failures get the hint

A TypeError or ValueError raised inside a successfully-bound tool is a value problem, not a parameter problem. The runtime gates the schema hint on inspect.signature(target).bind(**kwargs) — the required/optional names only appear in the error message when the kwargs actually failed to bind. Otherwise the tool’s own error string reaches the model unchanged, so the model fixes the value instead of renaming the parameters.

Backward compatible

No new public API, no new Agent parameters, and no new environment variables. The prior behavior on unknown tools was a bare Tool 'X' is not callable — not a stable contract, so no valid callers depended on it. When signature introspection itself is impossible (e.g. a C-implemented callable), the gate fails closed — it omits the hint rather than echo a misleading one.


MCP tools participate too

MCP-container tools are expanded into their contained tools, so their names appear in the corrective inventory and can be name-repaired — not just the opaque MCP container.

python
from praisonaiagents import Agent
from praisonaiagents.mcp import MCP

mcp = MCP("uvx mcp-server-fetch")     # contains e.g. 'read_file', 'write_file'
agent = Agent(name="Files", instructions="Handle files", tools=[mcp])

# Case/separator drift on an MCP tool name still auto-fixes:
agent.execute_tool("Read-File", {"path": "x"})   # dispatches to read_file

If none of the MCP tools match, the corrective error’s Available tools: list contains the MCP-provided tool names instead of the container. Only MCP instances that iterate into per-tool callables exposing __name__ or name benefit — the standard praisonaiagents.mcp.MCP satisfies this.


Init-time vs runtime

Two resolvers cover two different moments.

ScopeTriggerPage
Init-timeYou pass a tool name to Agent(tools=[...]) at construction, e.g. "web_serch"Tool Resolution
RuntimeThe LLM emits a tool call like WebSearch during a runThis page

Self-repair complements — it does not replace — init-time Tool Resolution.


Related