# Plan: Tool Call Guardrails (Repetitive Error Loop Breaking)

## Problem

When a model fails to use a tool correctly (e.g. passing a string where an array is required for `run_command`), the error message is returned as a tool result. Some models ignore the corrective feedback and repeat the exact same malformed call, burning through turns until `max_turns` is exhausted. In the observed case, `qwen3-coder-next` made the identical mistake 7 times in a row (turns 44–50) without ever self-correcting.

Two sub-problems:

1. **The error message itself is misleading.** The current `_run_command` string-detection path splits the raw string on whitespace to produce an "example", which generates nonsense when the input contains embedded JSON brackets: `["[\"grep\",", "\"-n\",", ...]`. The model sees a confusing example and may not understand what it should do differently.

2. **No loop detection.** The agent loop has no mechanism to notice that the same tool is failing with the same error repeatedly. It just keeps forwarding the error and hoping the model figures it out.

## Design

Three layers, each independently useful:

### Layer 1: Better error messages for known misuse patterns

Fix the error messages themselves so they are unambiguous and include a concrete before/after example.

**`run_command` string-instead-of-array** — Replace the current `.split()` hack with a hardcoded clear example:

```python
if isinstance(command, str):
    return (
        'error: "command" must be a JSON array of strings, not a single string.\n'
        'Wrong: "command": "grep -n pattern file.py"\n'
        'Right: "command": ["grep", "-n", "pattern", "file.py"]\n'
        'Each argument must be a separate element in the array.'
    )
```

No attempt to parse or split the user's bad input — just show the canonical correct format.

### Layer 2: Repetitive error detection and escalating intervention

Track recent tool results in the agent loop. When the same tool fails with the same error N times consecutively, inject a **user message** that forcefully tells the model what it's doing wrong, rather than relying on the tool result alone.

**Tracking structure** (in the agent loop, not in `tools.py`):

```python
# tool_name -> (canonical_error, repeat_count)
# Tracks the current streak of identical errors per tool.
consecutive_errors: dict[str, tuple[str, int]] = {}
```

After each tool call:
- If the result starts with `"error:"`, canonicalize it (see below) and compare to the stored canonical error for that tool.
  - If it matches: increment the count.
  - If it differs: replace with the new error, reset count to 1.
- If the result does NOT start with `"error:"`, delete the entry for that tool (reset).

**Canonicalization:** Strip variable content from the error string so that structurally identical errors with different embedded values are treated as repeats. The canonical form is produced by:

1. Take the first line of the error (everything before the first `\n`). This captures the error type while dropping example/hint text that may vary.
2. That's it — no regex substitution or value stripping. The first line of each error type in the codebase is already a fixed string (e.g. `'error: "command" must be a JSON array of strings, not a single string.'`, `'error: command list is empty'`, `'error: command {name!r} is not allowed.'`). If a future error type embeds variable data in its first line, the canonicalization can be revisited then — but adding speculative normalization now would make the logic harder to reason about.

```python
def _canonical_error(error: str) -> str:
    """Extract the stable part of an error for repeat detection."""
    return error.split("\n", 1)[0]
```

**Intervention threshold:** 2 consecutive identical (canonicalized) errors from the same tool.

**Intervention action:** After appending the normal tool result message, also append a **user message** with an explicit correction:

```python
messages.append({
    "role": "user",
    "content": (
        f"IMPORTANT: You have called `{tool_name}` {count} times with the same error. "
        f"The error is: {error_msg}\n"
        f"Please carefully re-read the error message and fix your tool call. "
        f"If you cannot use this tool correctly, use a different approach."
    ),
})
```

**Escalation at 3+ identical errors:** After 3 consecutive identical errors, the intervention becomes stronger — suggest the model abandon this tool and try a different approach entirely:

```python
messages.append({
    "role": "user",
    "content": (
        f"STOP: You have failed to use `{tool_name}` correctly {count} times in a row "
        f"with the same error. Do NOT call `{tool_name}` again with the same arguments. "
        f"Either fix the arguments or use a completely different approach to accomplish your task."
    ),
})
```

### Layer 3: Auto-repair for known misuse patterns (optional)

For specific well-understood error patterns, attempt to fix the arguments and retry instead of returning an error. This avoids wasting even a single turn on a known fixable mistake.

**`run_command` string→array:** If `command` is a string, attempt to recover it into a list. The available recovery strategies depend on whether we're in yolo mode:

```python
import shlex

if isinstance(command, str):
    repaired = False

    # Strategy 1 (always): Try JSON parse — model may have stringified a JSON array
    try:
        parsed = json.loads(command)
        if isinstance(parsed, list) and all(isinstance(x, str) for x in parsed):
            command = parsed
            repaired = True
    except (json.JSONDecodeError, TypeError):
        pass

    # Strategy 2 (yolo mode only): Fall back to shlex.split()
    # In normal mode, shlex.split() is off-limits because it could let the
    # model smuggle metacharacters or construct arguments that bypass the
    # command whitelist. In yolo mode there is no whitelist — the sandbox
    # is already disabled — so shell-style splitting is acceptable.
    if not repaired and unrestricted:
        try:
            parts = shlex.split(command)
            if parts:
                command = parts
                repaired = True
        except ValueError:
            pass

    if not repaired:
        return (
            'error: "command" must be a JSON array of strings, not a single string.\n'
            'Wrong: "command": "grep -n pattern file.py"\n'
            'Right: "command": ["grep", "-n", "pattern", "file.py"]\n'
            'Each argument must be a separate element in the array.'
        )
```

**Security rationale:** In normal (sandboxed) mode, the whole point of requiring an explicit array is to prevent argument injection and whitelist bypass via embedded separators. `shlex.split()` would undermine that. In yolo mode, the sandbox and whitelist are already disabled, so `shlex.split()` adds no new risk — and it catches the common case where the model writes `"grep -n pattern file.py"` as a flat string.

**Data flow after repair:** The repair logic lives at the top of `_run_command()`, replacing the current `isinstance(command, str)` block. When repair succeeds, `command` is now a `list[str]` and execution falls through to the existing whitelist/resolution and subprocess logic — no separate code path. The function appends a one-line note to whatever result the command produces:

```python
if isinstance(command, str):
    repaired = False
    # ... repair strategies as above ...

    if not repaired:
        return 'error: "command" must be a JSON array ...'

    # Repair succeeded — flag it so we can annotate the result
    was_repaired = True

# ... existing whitelist check, subprocess execution, result assembly ...

if was_repaired:
    result = f"{result}\n(auto-corrected: command was passed as a string, converted to array)"
return result
```

`was_repaired` is initialized to `False` at the top of `_run_command()`. The annotation is appended (not prepended) to the result just before return, so it applies uniformly regardless of whether the command succeeded, failed, timed out, or spilled to file. Appending is important: Layer 2's loop detection checks `result.startswith("error:")`, so the annotation must not appear at the beginning or it would prevent repaired-but-failed commands from being counted as errors.

## Implementation plan

### Changes to `tools.py`

1. Fix the `_run_command` string-detection error message (Layer 1).
2. Optionally add auto-repair logic (Layer 3).

### Changes to `agent.py`

3. Add `consecutive_errors` tracking dict, initialized before the agent loop.
4. After each `handle_tool_call()`, check if the result is an error and update tracking.
5. When the threshold is hit (2+ identical errors), inject an escalating user message.
6. Log the intervention via `fmt` so the operator can see it in stderr.

### Changes to `fmt.py`

7. Add a `fmt.guardrail(tool_name, count, error)` function for logging interventions.

### Tests

8. `tests/test_guardrails.py` (or extend `test_run_command.py`):
   - String command → clear error message (no nonsense split output).
   - Auto-repair: JSON-encoded array string → correctly parsed and executed, result ends with "(auto-corrected: ...)" (both modes).
   - Auto-repair: plain string + yolo mode → `shlex.split()` succeeds, result ends with "(auto-corrected: ...)".
   - Auto-repair: plain string + sandboxed mode → error, no `shlex` fallback.
   - Auto-repair annotation: verify "(auto-corrected: ...)" is appended (not prepended) on success, error, and timeout results.
   - Auto-repair + loop detection interaction: a repaired command that fails still starts with "error:" and is correctly counted by Layer 2.
   - Repeated error detection: mock a model that makes the same mistake N times, verify intervention messages are injected at the right thresholds.
   - Repeated error reset on different error: two different errors from the same tool don't trigger intervention.
   - Repeated error reset on success: a success between two identical errors resets the count.

## Scope decisions

- **Layer 1 (better error messages):** Do this unconditionally. No downside.
- **Layer 2 (loop detection):** Do this unconditionally. The intervention is just a user message — worst case, the model ignores it too, but we lose nothing.
- **Layer 3 (auto-repair):** Start with `run_command` string→array only. Two recovery strategies: `json.loads()` (always available) and `shlex.split()` (yolo mode only — in sandboxed mode it would let the model smuggle metacharacters past the whitelist). Don't generalize to other tools yet — wait for more data on what other patterns occur.

## Non-goals

- Don't try to detect semantic loops (model calling a tool correctly but getting the same unhelpful result). That's a different problem.
- Don't add a global "bail out" that stops the loop early. The operator chose `--max-turns` and should get that many attempts. We just try to make each attempt more likely to succeed.
- Don't persist error patterns across sessions. This is per-run only.
