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.
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.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 fixQuick Start
- 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. - 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.
- 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.
- 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 emptyThe 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.
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:
Self-repaired tool name 'WebSearch' -> 'web_search'Unknown name
The impl returns a corrective dict; the public path raises ToolExecutionError carrying the same text:
{
"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:
{
"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:
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.
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?
Six dialects, one code path
Whole-content JSON
{"name": "get_weather", "arguments": {"city": "Paris"}}Both {...} and [{...}, {...}] shapes work.
I'll check that for you.
<tool_call>{"name": "get_weather", "arguments": {"city": "Paris"}}</tool_call>
Multiple `ReAct
Thought: I should look this up.
Action: web_search
Action Input: {"query": "praisonai gateway"}The same-line form also works:
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]
[TOOL_CALL]{"name": "web_search", "arguments": {"query": "a"}}[/TOOL_CALL]LM Studio’s variant also works:
[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.
# 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.
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.
<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
| Adapter | Recovery | Sync path | Async path | Streaming |
|---|---|---|---|---|
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.
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.
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: <your error>. 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.
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_fileIf 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.
| Scope | Trigger | Page |
|---|---|---|
| Init-time | You pass a tool name to Agent(tools=[...]) at construction, e.g. "web_serch" | Tool Resolution |
| Runtime | The LLM emits a tool call like WebSearch during a run | This page |
Self-repair complements — it does not replace — init-time Tool Resolution.