# Duplicate Tool Calls — Root Cause Analysis

## The Symptom

The agent repeatedly calls the same tool with the same arguments (e.g., `file_read` on the same path, `grep_search` with the same pattern, `shell` with the same command). This wastes turns, burns context, and creates visible "looping" behavior.

## The Core Problem: Reactive Dedup, Not Preventive

The entire dedup infrastructure is **post-hoc interception** — the model has *already decided* to make the duplicate call before any dedup logic runs. The architecture looks like:

```
Model emits tool_call → Critic inspects → serve_cached / reject → (maybe) proactivePrune later
```

The model never sees the Critic's decision *before* it chooses to call. It only sees the *result* of a prior call in context, which doesn't prevent it from re-requesting the same information.

### Why the Model Re-calls

1. **Context bloat buries prior results.** By turn 15+, the model's context contains thousands of tokens of tool results. A `file_read` result from turn 3 is buried under 12 turns of other content. The model can't find it, so it calls again.

2. **No "what I already know" summary.** The context is a raw conversation transcript. There's no structured "known facts" section that the model can reference instead of re-reading files. The model treats the context as a chat log, not a knowledge base.

3. **proactivePrune makes it worse.** When `proactivePrune` replaces old results with `"[deduped — see turn N]"` or `"[file_read aged out, summary: ...]"`, it *removes the information the model needed* while telling it the info exists somewhere it can't access. The model's response: call the tool again to get the actual content.

4. **Critic's serve_cached is invisible to the model.** When the Critic serves a cached result, the model sees a tool result — it doesn't learn "I already asked this." Next turn, same context, same decision.

## The Structural Issues in Context Engineering

### 1. Conversation-as-Context is the Wrong Primitive

The context is a linear `ChatMessage[]` — an exact transcript of every turn. This is the fundamental design error. Research (AgentFold, RECOMP, MemGPT) shows that **progressive summarization** or **working memory** architectures outperform raw transcript for long-horizon tasks.

The agent should maintain a **working memory** (structured facts about the current task state) separate from the **episodic transcript** (raw conversation). The model should see the working memory as its primary context, with the transcript available but compressed.

### 2. No Semantic Index of Prior Tool Results

The `recentToolResults` map in the Critic is keyed by fingerprint (tool name + args hash). This is syntactic dedup — it catches exact re-calls but misses semantic duplicates (e.g., `file_read("foo.ts")` vs `file_read("foo.ts", offset=1, limit=50)` — same file, different args, same information need).

A semantic index would map **information goals** to **prior results**: "I need the contents of foo.ts" → "you already read it at turn 5, here's a summary." The current system can't do this.

### 3. The Dedup Sets Reset Per-Turn (Not Per-Task)

All the `_injectedThisTurn` Sets (`_errorGuidanceInjected`, `_reflectionsInjectedThisTurn`, `_verifyHintInjectedThisTurn`, etc.) reset every turn. This means:
- Turn N: model calls `file_read("foo.ts")` → result injected
- Turn N+1: model calls `file_read("foo.ts")` again → no dedup fires (the per-turn set was cleared)

The `dedupHitCount` in the Critic persists across turns, but it only triggers `serve_cached` — it doesn't *prevent* the model from deciding to call. The model still emits the tool call, wasting an LLM inference.

### 4. The Model Sees Its Own Redundancy Too Late

The Critic runs *after* the model has emitted a tool call. By then, the LLM inference cost is already spent. The model should see a **pre-call hint** like "you already read foo.ts at turn 5" *before* it generates the tool call. This would require injecting context about prior calls into the system prompt or last assistant message *before* the next LLM call.

## What Would Fix It

### Short-term (minimal changes)

1. **Inject a "recently read files" summary into the system prompt each turn.** Before each LLM call, scan the conversation for `file_read` results and inject a compact list: "Files already read: foo.ts (turn 5), bar.ts (turn 8)." This gives the model the information it needs to avoid re-reading.

2. **Don't age-out file_read results that are still relevant.** `proactivePrune` should check if the file was modified since last read (via git status or mtime) before replacing the result. If unchanged, keep the full result — the model needs it.

3. **Make `serve_cached` results visually distinct.** When the Critic serves a cached result, prefix it with `[CACHED — you already called this at turn N. Do not call again.]` so the model learns not to re-request.

### Medium-term (architectural)

4. **Working memory layer.** Maintain a structured "known facts" document that the model sees as part of its system context. Updated after each tool call. This is the MemGPT / Generative Agents approach.

5. **Pre-call context injection.** Before each LLM call, analyze the last few tool calls and inject warnings about likely duplicates: "You called file_read on foo.ts 2 turns ago. If you need the same content, reference that result instead of calling again."

6. **Semantic dedup.** Instead of fingerprint-based dedup, use embedding similarity to detect when a new tool call is semantically equivalent to a prior one (different args, same information goal).

### Long-term (fundamental)

7. **Replace conversation-as-context with state-as-context.** The model should see a task state object (current files, known issues, pending todos, recent errors) rather than a raw transcript. The transcript should be available for reference but not the primary context. This is the architecture shift from "chat agent" to "state machine agent."

## Evidence from the Codebase

The sheer number of REG-* patches (16, 17, 18, 26, 28, 31, 37, 38, 44, 45, 49b, 50) addressing symptoms of the same problem confirms this is a structural issue, not a surface bug. Each REG patch adds another per-turn Set or cooldown to suppress a specific duplicate/repetition pattern, but none address the root cause: **the model doesn't know what it already knows.**

The `proactivePrune` function (lines 2960-3040) is the clearest evidence — it exists solely to clean up the symptom (bloated context from duplicate calls) without preventing the cause (the model making duplicate calls in the first place).

## Summary

| Layer | Current | Needed |
|-------|---------|--------|
| Dedup timing | Post-hoc (Critic after call) | Pre-hoc (inject known-info before call) |
| Context model | Raw transcript | Working memory + compressed transcript |
| Dedup scope | Syntactic (exact fingerprint) | Semantic (information goal) |
| Prune strategy | Age-based removal | Relevance-based retention |
| Model awareness | Sees tool results, not patterns | Sees "what I already know" summary |

The duplicate calls aren't a bug — they're an emergent property of a context architecture that treats the LLM as a chat participant rather than a stateful agent.
