Troubleshooting
Find your symptom, read the cause, apply the fix. Every message below is printed by
examples/guide_troubleshooting.py, so the text is what you will see.
flowchart TD
S{"When does it fail?"}
S -- "pip install" --> I["Install"]
S -- "import or start-up" --> D["Definitions"]
S -- "a tool call" --> C["Calls"]
S -- "a pause or resume" --> A["Approvals"]
S -- "loops, threads, notebooks" --> R["Runtime"]
S -- "HTTP status" --> H["Server"]
Install
| Symptom |
Cause |
Fix |
| pip backtracks for minutes, or builds a Rust sdist |
dspy (LiteLLM caps openai<3) installed with the openai extra |
install dspy in its own venv |
pip refuses crewai, strands, semantic-kernel or adk beside [all] |
each pins a dependency the others need newer (mcp, pydantic, opentelemetry) |
one venv each; see the support matrix |
integration 'crewai' needs crewai: pip install "agent-adapter[crewai]" (MissingDependency, also an ImportError) |
the extra is not installed; the message names it |
pip install "agent-adapter[<extra>]" |
IntegrationError: ... is outside the supported range |
the framework release is outside the extra's range |
install a version inside the range in support matrix |
Definitions (start-up)
try:
exec("from agent_adapter import ToolNotFound") # a name that moved in 1.0
except ImportError as exc:
show(exc)
try:
registry.wrap(rate) # the same name twice: a re-run notebook cell, a re-import
except DuplicateTool as exc:
show(exc)
def double(x: int) -> int:
return 2 * x # no docstring: the model would get no description
try:
registry.wrap(double)
except ToolDefinitionError as exc:
show(exc)
def tally(counts: dict[str, int]) -> int:
"""Sum the counts."""
return sum(counts.values())
try:
registry.wrap(tally) # an open map: some providers reject it
except SchemaNotPortable as exc:
show(exc)
| Message |
Cause |
Fix |
ToolNotFound moved: from agent_adapter.errors import ToolNotFound |
most errors left the top level in 1.0 |
import from agent_adapter.errors (Versioning) |
tool 'rate' is already registered |
a re-run notebook cell, a module imported twice, two tools with one name |
one Registry() per module or test; registry.remove(name) before re-adding; name= to rename |
tool 'double' needs a description or a docstring |
the model would get no description |
add a docstring or description= |
$.counts: open map (dict) is not portable |
some providers reject open maps |
a pydantic model with named fields, or portable=False if you only target lenient providers |
Calls
executor = Executor(registry) # no approval port
for call in (
ToolCall(name="clos_account", arguments={"account_id": "a1"}), # misspelt
ToolCall(name="close_account", arguments={"account_id": 7}), # wrong type
ToolCall(name="close_account", arguments={"account_id": "a1"}), # confirm, no port
ToolCall(name="parse_invoice", arguments={"text": "..."}), # the tool crashed
):
result = await executor.run_call(call)
if result.error is not None:
print(result.error.kind, "|", result.error.message)
first = await executor.run_call(ToolCall(name="rate", arguments={"currency": "USD"}))
again = await executor.run_call(ToolCall(name="rate", arguments={"currency": "USD"}))
print("cached:", first.cached, again.cached) # no ctx: each call is a new run
Message (result.error) |
Cause |
Fix |
no tool named 'clos_account'; known: [...] |
the model named a tool you did not register (or not in the registry you passed) |
return the error to the model; it usually corrects itself |
account_id: Input should be a valid string |
the arguments fail validation; values are never echoed |
return it to the model: it says what to fix |
approval denied ...: no approval port configured |
a confirm tool and an executor with no approval= |
pass CallbackApproval, DeferredApproval or AutoDeny (Human in the loop) |
tool 'parse_invoice' failed after 1 attempt |
the tool raised a foreign exception; its text is hidden |
the log line names the class; enable DEBUG on agent_adapter.exec.pipeline for the text; raise ToolError("...") for messages the model should see |
cached: False False |
cache_scope="run" and no ctx: every call is a new run |
pass one ctx for the run, or cache_scope="thread" / "global" (Caching) |
a ToolError is never retried |
ToolError is for the model to fix |
raise a subclass with retryable = True (Retry a flaky tool) |
run-limit-exceeded |
once_per_run or RunLimits(max_calls=) |
expected; a new run gets a new count |
budget-exceeded one call over the cap |
the hook checks before each call |
set max_usd one call below the hard limit |
Approvals
store = MemoryApprovalStore()
executor = Executor(registry, approval=DeferredApproval(store))
ctx = executor.context(run_id="run_1")
call = ToolCall(name="close_account", arguments={"account_id": "a1"}, id="c1")
await executor.run_call(call, ctx) # pending
other = executor.context(run_id="run_2") # resumed under a new run id
again = await executor.run_call(call, other)
print(again.status, len(await store.list(status="pending"))) # a second review
changed = ToolCall(name="close_account", arguments={"account_id": "a2"}, id="c1")
try:
await executor.run(changed.name, changed.arguments, ctx, call_id="c1")
except ToolUsageError as exc:
show(exc)
| Symptom |
Cause |
Fix |
pending 2: the resume opened a second review |
resumed with a new run id (or call id) |
keep run_id and the call id with the approval id |
call id reused with different tool/arguments |
the same call id with other arguments |
a new call id for a new call; edits go in Decision(edited_args=...) |
approval already used by a concurrent request |
two resumes raced for one decision |
expected: the loser is denied; one call runs |
ApprovalAlreadyDecided |
a second decide |
the first decision wins |
| a framework says "approved" but the call is denied |
framework approval never counts alone |
resolve it through the bridge (resolve_*), which issues a one-use token (Security) |
Runtime
try:
Executor(registry, hooks=[print]) # type: ignore[list-item]
except ToolUsageError as exc:
print(exc.kind, "|", str(exc).split(",")[0])
no_ledger = Executor(registry, hooks=[BudgetHook(1.0)]) # no ledger to read
await no_ledger.run("rate", {"currency": "USD"}) # runs; logs a WARNING once
try:
crewai_tool(registry.get("rate")) # the framework is not installed
except MissingDependency as exc:
print(exc.kind, "| ImportError:", isinstance(exc, ImportError))
print(run_sync(no_ledger.run("rate", {"currency": "EUR"}), timeout=5)) # inside a loop
| Symptom |
Cause |
Fix |
hooks must be Hook instances |
a function passed as a hook |
subclass Hook |
BudgetHook has no ledger in the tool context; budget not enforced (WARNING) |
no ledger= |
Executor(..., ledger=Ledger(prices=...)) |
no price for model 'house'; cost recorded as unknown (WARNING, once) |
a model missing from the price table |
Prices.default().merged({...}) (Cost) |
asyncio.run() cannot be called from a running event loop (Jupyter) |
the notebook already runs a loop |
await directly, or run_sync(...): it moves to a worker thread |
... attached to a different loop / Event loop is closed |
an httpx or redis client kept in a module global, used from another run_sync |
keep it in a LoopLocal (Sync code) |
TimeoutError from run_sync |
the work ran past timeout |
raise the timeout, or give tools their own timeout= |
Server (HTTP status)
Status, kind |
Cause |
Fix |
401 unauthorized |
no or bad bearer token |
send Authorization: Bearer ... |
403 forbidden |
another principal owns the run, or you may not decide |
omit run_id or use an unguessable one; reviewers need approvals:decide |
403 non-local-client |
an anonymous app reached from another host |
add authentication; allow_anonymous_public=True only on purpose |
409 request-in-progress |
an Idempotency-Key still running |
retry after it ends |
409 approval-already-decided |
a second decision |
read the record instead |
| 413, 408 |
body over 1 MiB, or slower than 30 s |
raise max_body_bytes / body_timeout_s on the service |
422 tool-args-invalid |
bad arguments |
fix the body; errors lists each field |
Full list: HTTP server.
Providers
| Symptom |
Cause |
Fix |
model-rate-limited |
a 429; .retry_after holds the provider's hint |
it is retryable: FallbackModel or your own backoff |
the model sends null for an optional field |
OpenAI strict mode makes every field required and nullable |
expected: your default is applied (Strict mode and nulls) |
model-auth-failed |
bad or missing API key |
check the client the port was built with |
Output
ImportError | ToolNotFound moved: from agent_adapter.errors import ToolNotFound
duplicate-tool | tool 'rate' is already registered
tool-definition-invalid | tool 'double' needs a description or a docstring
schema-not-portable | schema for tool 'tally' is not portable: $.counts: open map (dict) is not portable
tool-not-found | no tool named 'clos_account'; known: ['close_account', 'parse_invoice', 'rate']
tool-args-invalid | invalid arguments for tool 'close_account': account_id: Input should be a valid string
approval-denied | approval denied for tool 'close_account': no approval port configured
tool-failed | tool 'parse_invoice' failed after 1 attempt
cached: False False
pending 2
tool-usage-error | call id reused with different tool/arguments (tool 'close_account', call 'c1')
tool-usage-error | hooks must be Hook instances
missing-dependency | ImportError: True
0.92
Files
| File |
Does |
examples/guide_troubleshooting.py |
reproduces each failure above and prints its error |
tests/docs/test_guides.py |
runs it offline and checks the output against this page |