Troubleshooting & Pitfalls
The failure modes below are the ones you will actually hit. Each entry gives the symptom, the root cause, and the fix — solutions come from how AmritaCore actually executes, not from folklore.
1. The Agent Loops Calling the Same Tool
Symptom: the agent calls the same tool with the same arguments over and over; tokens burn fast.
Root cause: a tool result didn't change anything, but the model keeps trying — classic ReAct failure.
Fixes (AmritaCore has them built in):
| Mechanism | Config / trigger | Effect |
|---|---|---|
| Stall detection | builtin.loop_reasoning_trigger = N | After N identical tool signatures, a give-up prompt is injected and the Step ends |
| Pre-execution cancel | same trigger | The N-th identical call is cancelled before running and returns "Cancelled: Reach the max limit of repeatly calling tool." |
| Hard call limit | function_config.agent_tool_call_limit | The loop stops after this many rounds regardless |
Where detection runs matters. Stall detection is a per-iteration hook (after_iteration, called after every STEP_EXEC round) — it must live inside the loop. A previous design checked only in leave_step, which runs after the loop exits: a model stuck calling one tool never reached leave_step, so the stall never fired and tokens burned without limit. The step loop also carries a hard cap in iter_cond (called_count > max_times) so an inner-loop stall can't outlive the budget. leave_step keeps a stall check as an idempotent backstop.
Tuning: if your task legitimately repeats a tool (e.g. polling), raise loop_reasoning_trigger; if the agent still loops, lower agent_tool_call_limit.
2. HTTP 400: reasoning_content must be passed back
Symptom: HTTP 400 "The reasoning_content in the thinking mode must be passed back", not intermittent — happens on every subsequent request once the assistant has produced reasoning.
Root cause: some providers require the assistant reasoning_content to be returned verbatim on later requests. Whether the rule applies is decided by the provider, not the adapter: Anthropic requires it whenever extended thinking is enabled; DeepSeek requires it even in its OpenAI-compatible mode. So "thinking mode" is not the trigger — the provider is. Two historical bugs caused this: the thinking filter mutated live message objects in place, and the tool-result append dropped reasoning_content.
Fix: both are fixed in v0.13 — the filter (thinking_config.content_mode) shallow-copies messages (model_copy(deep=False)) instead of mutating, and every assistant message append carries the response's thinking fields back verbatim (reasoning_content, reasoning_signature and any provider extra — Message allows extra, so nothing is hard-coded). If you write a custom strategy, keep both rules: never strip reasoning in place; always pass it back — safe for every provider, mandatory for the ones above.
3. insufficient tool messages following tool_calls message
Symptom: OpenAI-compatible API rejects the request.
Root cause: every assistant message with tool_calls must be followed by matching ToolResult messages.
Fix: the built-in strategies put all tool_calls of one response into a single assistant message, followed by all their ToolResults (in call order — fully paired). One response is never split into multiple assistant messages: the reasoning text (reasoning_content) appears exactly once, and splitting would repeat it across messages — undefined behavior. Custom strategies: follow the same pairing rule (see Agent Strategy). Any context injection that splits a tool-call/result pair (e.g. plan-status notes) breaks the contract — AmritaCore injects those at Step boundaries only, never mid-pair.
4. Empty Responses (with thinking enabled)
Symptom: the model sometimes returns an empty content; decomposition or summary calls fail.
Root cause: some providers return '' when thinking is engaged.
Fix: built in — empty responses degrade to a fallback instead of crashing (decompose → run directly; summarize → "Completed <phase>"). The warning log includes the original request id so you can find the call in provider logs: Empty decomposition response (request_id=..., thinking_content=True).
Request-id gotcha: DeepSeek puts the trace id in
x-ds-trace-id(sometimeseo-log-uuid), not OpenAI'sx-request-id. The adapter probes all three headers in order before falling back to its internal id. If you grep provider logs forx-request-idon a DeepSeek call, you will find nothing — search by the id in the warning instead.
5. Token Burn Without an Obvious Loop
Symptom: usage is high even though the agent isn't looping.
Checklist:
function_config.agent_tool_call_limit— hard cap per runllm.memory_abstract_threshold— enable summarization for long sessions- Session-level compression triggers —
llm.session_tokens_windows(default 64k) andllm.memory_length_limit(default 200): context is compacted as soon as either one is reached. Raising only the token window without relaxing the message cap makes the message limit fire first, trimming context too often and lowering prompt cache hit rate. Rule of thumb: ~300 tokens per message, so a 256k context pairs with ~800 messages function_config.agent_step_token_budget— per-Step prompt-token budget; when exhausted,iter_condstops the Step (TokenBudget.exhausted)- Between-Step compression — the step loop compresses history when prompt tokens exceed the threshold (
stepmetadata withextra_type="compress"); the fold keeps a tool-call/result pair together so the kept context stays well-formed - Tool signatures — watch the
stallmetadata to confirm detection works
6. Peer Messages Not Reaching the Agent
Symptom: send_to_producer(...) worked but the agent never saw the text.
Root cause: peer messages are consumed at Step boundaries only (intro_step); inside a Step they queue; after the run they are dropped (channel closed).
Fix: push before the run starts (or between Steps) for guaranteed delivery; for mid-run pushes, accept they may be picked up at the next boundary — see Streaming.
7. Global AmritaConfig is not initialized
Symptom: RuntimeError at startup when registering tools.
Root cause: get_config() was called before minimal_init()/set_config().
Fix: call await minimal_init(config) (or set_config) once before creating agents or registering tools that read config (e.g. enable_if lambdas).
8. Undefined protocol adapter: <name>
Symptom: ValueError when creating an agent or testing a preset.
Root cause: ModelPreset.protocol names an adapter that was never registered. Only "openai" / "__main__" (OpenAI-compatible) and "anthropic" / "claude" ship with the framework — see Model Adapters.
Fix: remember the two-layer model — the adapter picks the protocol, the provider is just base_url + model. DeepSeek and Azure are not protocols; they are OpenAI-compatible endpoints reached through the default protocol. Set protocol="anthropic" only when you target the Anthropic wire format, and register your own adapter (via get_adapter_protocol()) before referencing a custom protocol. create_agent() has no protocol argument — it always builds a default-protocol preset.
9. ModelPreset(model_config=...) Silently Drops the Field
Symptom: streaming is off, thinking config is ignored — no error, just wrong behavior.
Root cause: ModelPreset has no model_config field (extra="allow"), so ModelPreset(model_config={...}) swallows the dict as an unknown extra field. Anything you intended to land in ModelConfig never does.
Fix: use create_agent(model_config={...}), which maps the dict onto ModelConfig correctly. Same trap for ThinkingConfig — pass it via the preset's thinking_config field, or through create_agent's kwargs.
10. Test / Async Traps
These bite when you write tests or embed AmritaCore in a runner:
asyncio.wait_for(coro, timeout=0)times out immediately — the coroutine never gets to run. Use a small positive timeout (e.g.0.001) when probing a stream non-blockingly.- AnyIO streams are bound to the event loop that created them. Two separate
asyncio.run()calls in one test share a stream across loops and hang. Keep push + drain inside a singleasyncio.run(). MagicMockstreams hang on__anext__. Guard stream consumers withisinstance(stream, SuspendObjectStream); a mock "succeeds" atget_producer_input_generator()and then hangs forever on iteration.TypedDictmetadata can't beisinstance-checked.isinstance(m, AgentStepXxxMetadata)raisesTypeError— discriminate withm.get("type") == "step" and m.get("extra_type") == ....patch.object(strategy, method, fake_fn)won't bindself. Usenew=AsyncMock()(or a bound method) so the patched callable receives the instance.
11. Plan Revision (update_step) Seems to Do Nothing
Symptom: the model never calls update_step; the plan never changes.
Root cause: three things must line up, and each has failed historically:
- The tool must be visible.
UPDATE_STEP_TOOLis only exposed when the step loop is active (_ensure_step_toolsonintro_step). Without the step-loop workflow, the model cannot seeupdate_stepat all. - The plan must be in context.
_inject_plan_status()writes a[Plan status]snapshot at Step intro — but only when the snapshot changed, and only at a Step boundary (never splitting a tool-call/result pair). - Prompt guidance is probabilistic. Telling the model "call
update_step" in prose works sometimes. The reliable path is the deterministic framework injection: when a tool result starts withERROR,_maybe_inject_tool_failure_hint()appends a user message — "retry at most once, then callupdate_step(remove_step/replan)" on the first failure, "do not retry, call update_step now" on the second (AgentRunState.tool_error_hintscounts per Step).
Fix: use the step-loop workflow, keep the plan visible, and rely on the ERROR-prefix convention for hard failures — the framework turns a failing tool into an explicit revision instruction deterministically.
Next
Advanced — the internals: workflow engine, suspend and the step loop.
