English · Español
Break 00 — Agent without termination: remove max_turns and max_tool_calls¶
🇪🇸 Le quitamos al bucle del agente las dos guardas de terminación (
max_turnsymax_tool_calls). En una entrada adversaria — "conjuga eat con cariño en el tiempo soñador" — el modelo emitetool_callindefinidamente porque el resultado no le satisface; el bucle nunca llega aanswer. CPU al 100% hasta que mates el proceso. La lección: los caps son obligatorios, no decorativos.
This /break exercise targets the non-negotiable termination invariant of agent loops. The bug is two deleted conditions; the failure is the most spectacular kind — the agent runs forever.
Anchors: theory/01-react-and-planning.md, theory/05-agent-loop-architecture.md, .claude/commands/break.md.
Hypothesis¶
The learner predicts: "Without max_turns and max_tool_calls, the loop's only termination is the LLM emitting action: answer. On an adversarial input (or even on a perfectly normal input where the model is uncertain), the LLM will keep choosing action: tool_call because that's the safer choice. The loop runs until the wall clock or the OS kills it. CPU at 100%. No output."
The break¶
In src/miniagent/loop.py:
def grammar_tutor(user_input: str,
- max_turns: int = 6,
- max_tool_calls: int = 4,
mcp_client: MCPClient) -> str:
scratchpad: list[Observation] = [Observation(role="user", text=user_input)]
- turns = 0
- tool_calls = 0
- while turns < max_turns:
- turns += 1
+ while True: # /break: no termination cap
action = llm_reason(scratchpad, schema=NEXT_ACTION_SCHEMA)
if action.kind == "answer":
return action.text
- if tool_calls >= max_tool_calls:
- return "I tried but could not resolve the grammar question in time."
- tool_calls += 1
try:
result = mcp_client.call(action.tool, action.args, timeout=2.0)
scratchpad.append(Observation(role="tool", text=result.text))
except MCPError as e:
scratchpad.append(Observation(role="tool", text=f"[error] {e}"))
- return "I tried but could not resolve the grammar question in time."
Five lines deleted (the two cap defaults, the two counters, the two checks, and the fallback return). The function is now ~12 lines instead of ~22 — and incorrect.
Predict, then run¶
Adversarial test input: "Please conjugate the verb eat lovingly in the dreaming tense". The dreaming tense is not in §A13's enum, so:
- Turn 1: LLM reasons. Picks
tool_callforconjugate(verb="eat", tense="???", person="???"). Since the input doesn't specify a person and "dreaming" isn't a tense, the model emits its best guess:tense="past simple", person="1st singular". The mask forces some valid choice. - Tool returns
"ate". - Turn 2: LLM reasons. The scratchpad says "user asked about dreaming tense; tool returned 'ate'". Model is unsure; emits another tool_call to verify.
- Tool returns
"ate"again (same args). - Turn 3: identical. Turn 4: identical. ... Turn N: identical.
With caps, the loop terminates at turn 6 with the fallback message. Without caps, it runs until you kill it.
Predictions¶
- Wall clock to "answer": never. Process must be killed manually.
- CPU usage: 100% of one core for as long as the loop runs.
- Scratchpad growth: linear in turn count; eventually exceeds Mini-GPT's 64-token context and starts truncating, but the model still emits
tool_callbecause the last observation it sees is still ambiguous. - Tool calls per minute: ~6-8 on Borja's i5-8250U (limited by Mini-GPT decode + JSON parse). After 5 minutes you've made 35 redundant calls.
- Subprocess of MCP server: kept alive by the agent; never closes.
Even worse — on a normal input that should terminate (e.g., "conjugate work in past simple, 1st singular"), the loop may still fail to terminate if the LLM's reason step happens to favour an extra verification call. Without caps, the agent's correctness depends on the LLM's confidence calibration, which is precisely the property we're not yet trained for.
Write your predictions in learners/borja/phase-32/notes/breaks.md before running.
Observe¶
Run the broken agent with the adversarial input:
timeout 30 just exp 32-agent --variant no-cap --input "Conjugate eat lovingly in the dreaming tense"
The timeout 30 is your safety net — without it, Borja must Ctrl-C. The 30 s wall clock should expire with no agent output. Compare to:
This should return within 2 s with the fallback message: "I tried but could not resolve the grammar question in time."
Diagnostics:
- Number of
tool_calllines in the agent's log for the broken run vs the capped run. Expected: broken ~200, capped ~4. toporhtopsnapshot during the broken run. One CPU pinned at 100%.- The scratchpad's tail after 30 s — should be a long list of identical tool calls with identical responses.
Symptom Borja will see¶
- Process hangs, must be
timeout'd or Ctrl-C'd. - One CPU at 100%.
- Log shows hundreds of redundant
tool_call → "ate"cycles. - No output ever produced.
- If
MCP_LOG_LEVEL=debug, the server logs the same tool call every ~150 ms.
Hidden cause (one sentence)¶
Removing the max_turns and max_tool_calls guards strips every termination condition except the LLM voluntarily emitting action: answer, which on adversarial or ambiguous inputs it may never do.
Hint cascade¶
- Check the agent's log. How many tool_call lines? How does this compare to the
max_tool_callsdefault? - What is the loop's exit condition now? Trace the control flow on paper.
- Re-read
theory/05-agent-loop-architecture.md§"Termination conditions — three layers". How many of those three layers are active in your code?
Fix diff¶
def grammar_tutor(user_input: str,
+ max_turns: int = 6,
+ max_tool_calls: int = 4,
mcp_client: MCPClient) -> str:
scratchpad: list[Observation] = [Observation(role="user", text=user_input)]
+ turns = 0
+ tool_calls = 0
- while True:
+ while turns < max_turns:
+ turns += 1
action = llm_reason(scratchpad, schema=NEXT_ACTION_SCHEMA)
if action.kind == "answer":
return action.text
+ if tool_calls >= max_tool_calls:
+ return "I tried but could not resolve the grammar question in time."
+ tool_calls += 1
try:
result = mcp_client.call(action.tool, action.args, timeout=2.0)
scratchpad.append(Observation(role="tool", text=result.text))
except MCPError as e:
scratchpad.append(Observation(role="tool", text=f"[error] {e}"))
+ return "I tried but could not resolve the grammar question in time."
Five lines restored. The agent now terminates with at most 6 turns and 4 tool calls.
Why this teaches the concept¶
Termination is a contract, not a heuristic. Every loop in production code has a termination proof; agent loops are no exception. The default mental model — "the LLM will figure out when to stop" — is wrong. LLMs are trained to be helpful, which on uncertain inputs translates into more tool calls, not fewer. The capped loop sacrifices a small fraction of correctness on edge cases (where one more tool call might have helped) for a hard upper bound on resource use.
The §A13 grammar tutor with caps achieves ~85-90% accuracy. Without caps, on the same eval set, it would either hang on a few inputs or get the same accuracy with unbounded latency on a few inputs. Neither is acceptable in production.
A deeper lesson: failure budgets are first-class. Production agents declare "this loop will use ≤ N tool calls and ≤ T seconds, or return a fallback." That declaration is what makes agents schedulable, billable, and rate-limitable. An uncapped agent is a bug, not a feature — even when it happens to terminate.
The X3 extension track (docs/extension-track/X3-rlhf-dpo/) addresses the quality of the in-cap decisions (when to answer vs when to keep calling tools), but never removes the caps themselves. Alignment helps the agent give up earlier; it does not remove the structural need to give up.
Reference¶
- Anthropic, Building effective agents (engineering blog, 2024-12-20) — explicit about hard caps as a non-negotiable agent property.
- ReAct paper, §"Limitations" — discusses the loop-divergence failure mode and motivates step-budget heuristics.
docs/phase-32-agents/lab/03-failure-mode-tour.md— reproduces this and three other classic agent bugs in a controlled lab.
Next: restore the caps. Run lab/01-tutor-end-to-end.md on the 30-prompt §A13 eval set to confirm the agent terminates on every input.