← Back ← Back

VibeChat Tool-Loop Logic Audit (2026-07-20 15:20 UK)

What we know

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?

Tracing the actual loop

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.

Root cause: there's no "act now" pressure

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 fix I should have made: pre-warm the chat with file content

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."

What I should do

Option A: Pre-warm via a context tool

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.

Option B: Pre-fetch at the start of the tool loop

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.

Option C: Strict "act now" pressure

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.

Option D: Make the loop server-side

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.

Option E: Increase the model's planning horizon

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.

My recommendation

Option A + Option C + Option D combined.

  1. Add read_files_for_context to the bridge. One call returns
    multiple files (each capped at 1000 lines). One iteration per task.
  2. Change tool description for read_file: "If the file is
    likely under 1000 lines, pass maxLines=2000 to get it all in
    one call. Pagination wastes iterations."
  3. Add hard deadline: after iteration 3, if no write_file or
    run_command has been called, FORCE the model to switch. Add a
    "you've been reading, now act" nudge to the conversation.
  4. Cap iterations at 6 (was 8) and end with a forced Report
    block that says "I read X, attempted Y, here's what I have."

The 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.

What went wrong with my prior fixes

  1. read_file pagination — made the chat aware of file length
    but didn't reduce the number of calls. The chat still paginated
    3x.
  2. maxIterations: 8 — gave the chat more rope to hang itself.
  3. 3-strike detection — caught the loop but didn't prevent the
    dead end. The chat ended with "I looped, here's a Report" but
    no actual change.

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.

Concrete plan

  1. Add read_files_for_context bridge endpoint (1 file change)
  2. Update executeTool in tools.ts to map the new tool (1 file)
  3. Update prompts.ts: change read_file description to
    recommend maxLines=2000 for "small" files; add read_files_for_context
    to the tool list (1 file)
  4. Add "act now" pressure at iteration 4: inject a system
    message "you have 2 iterations left. write what you have. partial
    changes are better than no changes." (1 file)
  5. Lower maxIterations from 8 to 6 with the new nudge (1 file)
  6. Test with the dark mode prompt via the dev-inject queue
  7. If still stuck: try Option E (better model)

This should bring the dark mode test from "5+ minutes, 18 messages,
stuck" to "1-2 minutes, 6 messages, done."