Operator's test: "Add dark mode." Chat gets stuck at 18 messages. The chat
read the source file in chunks, said "Let me continue reading", and never
called write_file. Loop observed: read → read → read → exhausted.
I added 3 fixes previously:
- read_file pagination (200-line cap + offset)
- maxIterations: 8 (was 4)
- "same tool 3x in a row" stuck detection
None of these worked. The chat still loops on read_file even with 8
iterations and 3-strike detection. Why?
src/vibechat/components/ChatPanel.tsx line 299-518 is the tool loop.
Let me trace what happens with the dark mode prompt:
Iteration 1: model returns tool_call read_file(...)
→ executeTool succeeds → workingMessages += tool_result
Iteration 2: model returns tool_call read_file(...)
→ executeTool succeeds → workingMessages += tool_result
Iteration 3: model returns tool_call read_file(...)
→ executeTool succeeds → workingMessages += tool_result
→ STUCK DETECTION FIRES (3x same tool with no failure)
→ "I'm looping" message → break
But the operator says the chat kept reading past 3 strikes. Why?
Because the same-tool-3x detection only fires if !lastFailure.
read_file always succeeds (returns 200 with content). So the check
should fire. Let me re-read the code:
const lastThreeToolCalls = workingMessages
.filter(m => m.role === 'tool' && m.toolCall)
.slice(-3)
.map(m => m.toolCall.name);
const sameToolLoop = lastThreeToolCalls.length === 3 &&
lastThreeToolCalls.every(n => n === lastThreeToolCalls[0]);
if (sameToolLoop && !lastFailure) { ... break; }
OK so it does check. But here's the bug: the tool execution code
inserts TWO messages per tool call — one for toolCall and one for
toolResult. Both have role: 'tool'. So after one read_file,
workingMessages has 1 entry, but there are 2 tool bubbles in the
visible chat. The "3 tool calls" detection needs 3 calls = 6 tool
messages in workingMessages.
Wait — the filter m => m.role === 'tool' && m.toolCall only matches
the toolCall one, not the toolResult. So 3 read_file calls = 3 entries.
That should fire. So why didn't it?
Let me re-read more carefully...
workingMessages.push({
role: 'tool',
tool_call_id: tc.id,
content: exec.success ? exec.output.slice(0, 8000) : `Error: ${exec.output.slice(0, 2000)}`,
});
This message is the tool RESULT. It has no toolCall field. So
m => m.role === 'tool' && m.toolCall only matches the CALL bubble.
But wait — workingMessages is the array I push to. Let me re-check
the m.toolCall data... yes, only the toolCall bubble has that. So 3
calls = 3 entries = SHOULD fire.
Unless the chat is reading DIFFERENT paths. Reading
VibeChatApp.tsx once and styles.css once doesn't trip the 3x same
tool detection because the names are different... wait no, both are
read_file so the name matches.
Hmm, let me check what the actual observed behavior was. The operator
said the chat said "Let me try to get the remaining part of the
styles.css file." That's a SECOND call to read_file on styles.css. So
it IS calling read_file multiple times on the same file. So same-tool
detection SHOULD fire on call 3. But the test transcript shows 18
messages total, with 5-6 read_file calls and 2-3 result messages. So
the 3-strike should have fired.
WAIT. I see it now. Look at line 463-468:
const lastThreeToolCalls = workingMessages
.filter(m => m.role === 'tool' && m.toolCall)
.slice(-3)
.map(m => m.toolCall.name);
The filter includes BOTH role === 'tool' AND m.toolCall. But only
the toolCall bubble (not toolResult) has toolCall field. So if the
chat does 3 calls, you get 3 toolCall bubbles with names. Slice(-3)
gives all 3. every() checks they're all the same. SAME-TOOL FIRES.
So the 3-strike detection IS working... but the chat is still stuck.
The issue must be: the chat hits the 3-strike and breaks out, but
the break leaves the chat in a "I looped on read_file" state without
ever writing. That's the actual bug.
Let me re-read the loop:
- 3 read_file calls succeed
- 3-strike fires
- break
- The visible message is "I'm looping on read_file — respond with what
I know and a Report"
- Chat ends
So the chat NEVER writes. The 3-strike is catching the loop but not
preventing the dead end.
The system prompt says "be decisive: read once, then act" but that's
an instruction to the model. The model is what it is. We can't
control the model's behavior via prompts alone.
The real fix is: don't let the loop end at "I looped". Force a
write_file before the loop ends.
The chat reads VibeChatApp.tsx in 3 chunks because it's ~600 lines.
Each chunk uses an iteration. After 3 chunks (6 iterations if
including tool results), the chat is out of budget.
Better approach: read the file once, on the SERVER SIDE, in the
bridge, and inject the file content as context when the chat starts
its work. Or: have the chat request file content via a SINGLE call
that returns the full file (no pagination needed for files under
say 1000 lines).
The bridge read_file ALREADY has offset/maxLines. The chat is
calling it with offset=0 maxLines=200 then offset=200 maxLines=200
then offset=400 maxLines=200. That's 3 calls. The chat SHOULD just
call it once with maxLines=2000 and get the whole file.
Why doesn't it? Because the model is told maxLines: max 500 in
the tool definition. So it correctly does 3 paginated calls.
The fix: bump the maxLines to a much higher number, OR
auto-detect "if you're paginating, the bridge will give you the
whole file in one call if you ask".
But the deeper issue is the model is just not smart enough to know
"if I asked for the full file and got 200 lines, the file is bigger
than 200 lines — I need to either ask for more or take action on
what I have."
Add a new tool read_files_for_context(paths: string[]) that reads
multiple files in one bridge call and returns concatenated content
in a single tool_result message. The chat calls ONE tool, gets ALL
the file content it needs, then writes.
Pros: One iteration per task, not 6.
Cons: Could blow up context window for large files.
Before the first model call, look at the user's request. If it
mentions "VibeChat", "the app", or any of the known file paths,
pre-fetch those files and inject them as a system message before
the model starts.
Pros: Zero extra iterations.
Cons: Brittle heuristic; won't generalize.
Add to the system prompt: "You have at most 8 tool calls. After 3
read_file calls, you MUST call write_file or run_command in your
NEXT response, even if you don't have all the content. Partial
changes are better than no changes."
Pros: Forces the model to make progress.
Cons: Models can ignore system prompt instructions.
Instead of letting the model decide what tool to call next, the
bridge could implement a state machine:
1. read_file once (auto-detect file size, return all if < 1000 lines)
2. switch to a "writing" prompt that says "you have the file. write
the change. you have 1 shot."
3. write_file
4. run_command
5. Report
Pros: The model can't get stuck. The loop is finite.
Cons: Loses the "agentic" feel. The model is no longer in charge.
Use a larger/better model (Z.AI glm-4.5-flash with reasoning,
GLM-4.6) that can plan further ahead. Or use a multi-step planner
that splits the task into subtasks before the loop starts.
Pros: Generic. Works for all multi-step tasks.
Cons: Costs more, slower, may not be the actual bottleneck.
Option A + Option C + Option D combined.
read_files_for_context to the bridge. One call returnsread_file: "If the file ismaxLines=2000 to get it all inThe dark mode test should then complete in 4-5 iterations:
- 1: read_file VibeChatApp.tsx (maxLines=2000)
- 2: read_file styles.css (maxLines=2000)
- 3: write_file VibeChatApp.tsx
- 4: write_file styles.css
- 5: run_command npm run build
- 6: Report
That's 6 iterations, 6 tool calls, all under the budget.
read_file pagination — made the chat aware of file lengthmaxIterations: 8 — gave the chat more rope to hang itself.The fundamental issue: I was adding safety nets, not changing
the model's incentive structure. The model wanted to read more (low
risk) instead of writing (high risk of being wrong). My fixes didn't
change that.
read_files_for_context bridge endpoint (1 file change)executeTool in tools.ts to map the new tool (1 file)read_file description tomaxLines=2000 for "small" files; add read_files_for_contextmaxIterations from 8 to 6 with the new nudge (1 file)This should bring the dark mode test from "5+ minutes, 18 messages,
stuck" to "1-2 minutes, 6 messages, done."