# Duplicate Tool Calls — Deep Root Cause Analysis

## The Problem

The agent repeatedly calls the same tool with the same arguments right after having already called it. This is visible even in this agent's own conversation — `file_read` and `grep_search` calls get duplicated within 1-2 turns.

## Root Cause: The Model Decides to Re-call BEFORE Any Dedup Can Stop It

The fundamental issue is **temporal**: the model emits a tool call *before* any dedup logic runs. The architecture is:

```
LLM generates tool_call → Critic intercepts → serve_cached / reject → proactivePrune later
```

The model never sees "you already know this" *before* it decides to call. It only sees prior tool *results* buried in context, which it may not locate or trust.

## The Critic: Post-Hoc Interception That Can't Prevent Re-calls

**Code**: `packages/orchestrator/src/critic.ts` (lines 1-180)

The Critic (`evaluate()` function, line 137) is the central dedup decision module. It runs **after** the model has already emitted a tool call. Its decision priority:

1. **adversary guidance**: If the adversary flagged this fingerprint as redundant, attach a non-blocking critique and cached prior evidence; the requested tool call still executes.
2. **serve_cached** (line 166): If the tool is "read-like" AND the fingerprint exists in `recentToolResults` AND hit count < threshold → return cached result with a soft warning
3. **force_progress_block** (line 158): If hit count ≥ threshold (2 for shell, 3 for filesystem) → block the call entirely with a forced message
4. **pass** (line 179): Otherwise, let the call through

**Key insight**: Steps 2 and 3 both happen *after* the model already decided to call. The LLM inference cost is already spent. The model emitted `file_read("foo.ts")` — the Critic then says "you already have this" but the model never learns from this because:

- `serve_cached` returns the cached result as a tool result — the model sees it as a normal response, not a "don't call again" signal
- `force_progress_block` blocks the call but the model just sees an error message — it doesn't understand *why* it was blocked in context of its reasoning

The `recentToolResults` Map (line 63) and `dedupHitCount` Map (line 65) persist across turns, but they only affect the Critic's decision — they don't inject any information into the model's context *before* the next LLM call.

## Trigger 1: proactivePrune Removes Information the Model Still Needs

**Code**: `packages/orchestrator/src/agenticRunner.ts:2973-3049`

`proactivePrune` walks the message history and replaces old tool results with placeholders:

- `"[deduped — same call as turn N]"` (line 2981) — for exact duplicate calls
- `"[file_read aged out, summary: ...]"` (line 2982) — for file_read results older than 10 turns (line 2976: `AGED_FILE_READ_TURNS = 10`)
- `"[shell succeeded, output pruned — ...]"` (line 2983) — for shell results older than 5 turns (line 2977: `AGED_SHELL_TURNS = 5`)

**Why this causes re-calls**: When the model sees `"[file_read aged out, summary: ...]"`, it knows the file was read but can't see the actual content. The summary is typically truncated and insufficient for the model to reason about. The model's rational response: call `file_read` again to get the full content.

This is a **self-reinforcing loop**:
1. Model reads `foo.ts` → full content in context
2. 10 turns later, proactivePrune replaces it with `"[file_read aged out, summary: ...]"`
3. Model needs `foo.ts` content again → calls `file_read("foo.ts")` again
4. proactivePrune will eventually prune this too → loop repeats

## Trigger 2: No Pre-Call "What You Already Know" Injection

**Code**: The LLM call path in `agenticRunner.ts` composes the `ChatMessage[]` array and sends it directly to the model. There is no step that injects a summary of prior tool results *before* the model generates its next response.

The model sees:
- System prompt (static)
- Conversation transcript (raw `ChatMessage[]`)
- No structured "known facts" or "recently read files" section

Without a pre-call hint like "You already read foo.ts at turn 5 — content available in context", the model has no way to know it already has the information. It must scan the entire transcript to find prior results, which is unreliable for small models with long contexts.

## Trigger 3: Per-Turn Dedup Sets Reset, Allowing Cross-Turn Duplicates

**Code**: `agenticRunner.ts:1130-1171`

The dedup system uses per-turn Sets that reset each turn:

- `REG-37` (line 1130): per-turn dedup for verification-required hint
- `REG-38` (line 1134): per-turn dedup for artifact-inspection critique injection
- `REG-49b` (line 1171): per-loop-episode dedup flag for SSMA invocation

This means:
- Turn N: model calls `file_read("foo.ts")` → result injected, dedup Set records it
- Turn N+1: dedup Set is cleared → model calls `file_read("foo.ts")` again → no dedup fires

The `dedupHitCount` Map (line 5645) persists across turns, but it only triggers `serve_cached` in the Critic — it doesn't *prevent* the model from emitting the tool call. The LLM inference cost is already spent.

## Trigger 4: Fingerprint-Only Dedup Misses Semantic Duplicates

**Code**: `agenticRunner.ts:4489` (`_buildToolFingerprint`)

The fingerprint is built from tool name + canonical args. This catches exact re-calls but misses:

- `file_read("foo.ts")` vs `file_read("foo.ts", offset=1, limit=50)` — same file, different args, same information need
- `grep_search("pattern", path="src")` vs `grep_search("pattern", path="src", include="*.ts")` — same search, different filter
- `shell("cat foo.ts")` vs `file_read("foo.ts")` — different tool, same information goal

The `recentToolResults` Map (line 5635) is keyed by fingerprint, so semantically equivalent calls with different fingerprints are treated as distinct — no dedup fires.

## Why the 12+ REG-* Patches Don't Fix It

Each REG patch adds another per-turn Set, cooldown, or fingerprint check. These are **symptom suppressors** — they intercept duplicates *after* the model decides to call, but they don't address why the model decides to call in the first place.

| REG Patch | Location | What It Does | Why It Doesn't Fix Root Cause |
|-----------|----------|-------------|-------------------------------|
| REG-16 | line 5639 | Per-fingerprint dedup-hit counter, escalates to force_progress_block at ≥3 hits | Post-hoc: model already emitted the call |
| REG-17 | critic.ts:102 | Shell threshold = 2 hits before block | Post-hoc: only blocks after 2 wasted inferences |
| REG-18 | line 5691 | Stagnation window for variant-fatigue loops | Detects loops, doesn't prevent them |
| REG-26 | line 5820 | Per-turn reflection-injection dedup | Resets per-turn, misses cross-turn patterns |
| REG-31 | line 5874 | Positive completion signal injection | Doesn't address duplicate reads |
| REG-37 | line 1130 | Per-turn dedup for verification hint | Resets per-turn |
| REG-38 | line 1134 | Per-turn dedup for artifact-inspection | Resets per-turn |
| REG-49b | line 1171 | Per-loop-episode dedup for SSMA | Episode-scoped, not task-scoped |

## The Fix: Pre-Hoc Knowledge Injection

### 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: "Files already in context: foo.ts (turn 5), bar.ts (turn 8). Do not re-read these files."

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.

3. **Make 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.]`

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

## Evidence

| Symptom | Code Location | Mechanism |
|---------|--------------|-----------|
| Critic runs post-hoc | `critic.ts:137` (`evaluate()`) | Intercepts after model emits tool call |
| serve_cached is invisible to model | `critic.ts:166-174` | Returns cached result as normal tool result |
| force_progress_block is reactive | `critic.ts:158-164` | Blocks after ≥3 wasted inferences |
| proactivePrune removes content | `agenticRunner.ts:2973-3049` | Replaces old results with `[file_read aged out, summary: ...]` |
| Age thresholds too aggressive | `agenticRunner.ts:2976-2977` | 10 turns for files, 5 for shell |
| No pre-call knowledge injection | LLM call path in agenticRunner | No step injects "what you already know" before model generates |
| Per-turn dedup resets | `agenticRunner.ts:1130-1171` | REG-37/38/49b Sets clear each turn |
| Fingerprint-only dedup | `agenticRunner.ts:4489` | Exact match only, misses semantic duplicates |
| recentToolResults keyed by fingerprint | `agenticRunner.ts:5635-5638` | Same file different args = different entry |
| dedupHitCount persists but reactive | `agenticRunner.ts:5645` | Only triggers serve_cached, not prevention |
| 12+ REG patches | Various | All post-hoc interception, none pre-hoc prevention |

## Summary

The duplicate calls are an **emergent property of the conversation-as-context architecture**. The model doesn't know what it already knows because:

1. **The Critic intercepts after the call** — the model already decided to re-call before any dedup logic runs (critic.ts:137)
2. **Prior results get pruned away** — proactivePrune replaces content with summaries the model can't use (agenticRunner.ts:2973-3049)
3. **No one tells the model what it already has** — no pre-call injection of "known facts" before the LLM generates
4. **Dedup resets per-turn** — per-turn Sets allow cross-turn duplicates (agenticRunner.ts:1130-1171)
5. **Dedup is syntactic only** — fingerprint matching misses semantic duplicates (agenticRunner.ts:4489)

The fix is not more post-hoc interception — it's **pre-hoc knowledge injection**: tell the model what it already knows *before* it decides to call.
