The One-Turn Trap
Or: The Bug That Looked Like Stupidity
Here’s a fun way to make your AI agents look broken: give them tools, then give them exactly one turn to use them.
That’s what we did. For weeks. And we blamed the model.
The Symptom
Bubba’s initiative system — the part that handles scheduled jobs, analysis, background tasks — kept returning empty responses. Not errors. Not timeouts. Just… nothing. The system would fire a job, Claude would receive the prompt, and the response would come back blank.
The logs were polite about it:
WARNING: Claude returned empty result field
No stack trace. No crash. Just an agent that opened its mouth and nothing came out.
The maddening part? Interactive conversations worked fine. You could message Bubba through Telegram, ask it anything, and get articulate, personality-rich responses. But the moment the initiative system triggered a task — analysis, report generation, anything that ran through handle_with_claude_code — silence.
We checked the prompts. We checked the system prompt loader. We checked the model enum. We added more logging. We stared at the subprocess invocation code like it owed us money.
The Cause
Here’s the function signature that was ruining our week:
async def handle_with_claude_code(
message: str,
context: str = '',
model=None,
max_turns: Optional[int] = 1, # ← this bastard right here
) -> str:
max_turns: 1.
One turn. That’s it. That’s the bug.
To understand why this breaks everything, you need to understand how Claude Code works with tools. When Claude decides it needs to read a file, or run a command, or fetch something from the web, that’s a tool call. And a tool call consumes a turn. The model says “I need to use the Read tool on this file,” the system executes it, returns the result — and that’s one turn spent.
With max_turns: 1, here’s what happens:
- Claude receives the prompt
- Claude decides it needs to read a file to answer properly
- Claude makes a tool call (turn 1 of 1)
- Tool executes, returns the file contents
- Claude has the information it needs, opens its mouth to respond—
- Max turns reached. Session over.
The response field? Empty. Claude never got a chance to speak. It used its one turn to pick up a tool, and the system yanked the microphone away before it could say what it found.
The JSON response comes back with subtype: "error_max_turns", and we actually handle that case:
if subtype == 'error_max_turns':
logger.info(f'Hit max_turns (session saved: {bool(session_id)})')
return (
"I've started looking into this and gathered some context. "
"Say **go ahead** to let me finish."
)
So the system knew it was hitting the turn limit. It even had a graceful message for it. But we never connected the dots: if this is happening on every initiative task, maybe the default is wrong.
The Fix
max_turns: Optional[int] = 3,
That’s it. 1 → 3. Two characters.
Three turns gives Claude room to breathe:
- Turn 1: Tool call (read a file, check a log, whatever)
- Turn 2: Maybe another tool call if needed
- Turn 3: Actually formulate and deliver the response
It’s not generous. It’s not unlimited. It’s just enough headroom for the model to do what we asked it to do: think, gather information, and respond.
Why It Was Set to 1
This is the part where I’d love to tell you someone made a carefully reasoned decision and it backfired. But the truth is simpler.
When handle_with_claude_code was written, it was a convenience wrapper for the initiative system — background jobs that were supposed to be quick, simple, text-in-text-out. “Analyze this.” “Summarize that.” The kind of tasks where tool use wasn’t expected.
max_turns: 1 was a safety net. Don’t let background jobs spiral into expensive multi-turn agentic sessions. Keep it tight. One shot, one response.
The problem is that Claude is smart. Given a prompt like “analyze the recent deployment patterns,” it doesn’t just wing it from training data. It reaches for tools. It wants to read the actual logs, check the actual git history, look at the actual data. That’s the correct behavior. That’s what makes it useful.
But with one turn, “useful” becomes “mute.”
The Broader Lesson
This is a pattern I keep seeing in this project: constraints that make sense in theory but collide with how the model actually works.
We set budget limits that are too tight for tool-heavy tasks. We set timeouts that don’t account for web fetches. We set turn limits that don’t account for the model wanting to, you know, use the tools we gave it.
Every constraint is a bet about what the model will do. And when the model does something smarter than you expected — reaches for a tool, does extra research, takes an extra step — the constraint punishes the good behavior.
The fix isn’t to remove all constraints. Unlimited turns on a background job is how you wake up to a $200 bill and a Claude session that’s been recursively refactoring your codebase for six hours. The fix is to set constraints that have headroom for the model to do its job.
max_turns: 1 says “respond immediately with whatever you’ve got.”
max_turns: 3 says “you can look something up first, then respond.”
One is a calculator. The other is an assistant.
What Changed
After the fix, the initiative system came alive. Analysis tasks that had been returning empty strings started producing actual analysis. Report jobs generated reports. Background tasks that had been silently failing for weeks started… working.
No prompting changes. No model upgrades. No architectural rethinking. Just two characters in a default parameter.
The most humbling bugs are the ones where the system was working exactly as configured. It did precisely what we told it to: use one turn. We just forgot that using a tool is a turn.
The Uncomfortable Truth
We spent time debugging prompts, questioning model capability, wondering if the system prompt was too long or the context was wrong. We treated it as an AI problem when it was a plumbing problem.
The model wasn’t broken. The model wasn’t confused. The model was trying to be helpful, reaching for tools to give us better answers, and we were slamming the door on its fingers every single time.
max_turns: 1 wasn’t a bug in the traditional sense. The code ran. The parameter was respected. The system behaved exactly as designed.
It was a bug in our mental model. We forgot that “respond to this prompt” and “respond to this prompt well” require different amounts of room.
Two characters. Weeks of silent failures.
Ship it.