# Native Iterative Tool Calling — the local tool loop (P0-05, #21) > Status: **shipped** on `p0-05-native-tools`. Frozen contract: > `tests/test_p0_05_native_tools.py`. Acceptance: `tests/test_p0_05_acceptance.py` > (live-model gated). Canary: `src/rune/canary_p0_05.py` (registered in > `.gitea/workflows/ci.yml`). The issue's original handoff ("models print JSON, one tool/follow-up cycle") predates the runtime's evolution: `LocalToolRuntime._run_loop` (`src/rune/local_tool_runtime.py`) is a real iterative loop — `while True` under a hard `max_iterations` cap (1–8, enforced at construction), typed `ToolRequest`/`ToolEvent` dataclasses, approval pauses with serializable resume state, and executor errors fed back to the model as tool messages. P0-05 closed the four gaps the empirical probes found; it did NOT rebuild the loop. ## Loop architecture ``` run(messages) └─ transport.supports_tools(model) # /api/show capability discovery ├─ True → native tool_calls (Ollama /api/chat `tools` param) └─ False → text-fallback protocol + runtime-owned fallback prompt └─ _run_loop: while True ├─ execute queued requests (dedup: name + canonical arguments) │ ├─ read-only → dispatch immediately │ └─ mutating → prepare → pending_approval pause (serializable │ state) → operator approve()/reject() → resume() ├─ iterations_used >= max_iterations → typed failure "iteration limit" ├─ consecutive identical failures >= 2 → typed failure (breaker, below) └─ transport.chat(...) → tool_calls? loop : terminal visible text ``` Stop conditions: terminal visible text (success), approval pause (returns to the operator), iteration limit, circuit breaker, transport/protocol failure (all typed `OperatorResponse(success=False, error=...)`). ## Budget visibility (P3) Every response that entered the loop carries: | Field | Meaning | |---|---| | `iterations_used` | model turns consumed (one chat call = one iteration) | | `iteration_budget` | the runtime's `max_iterations` cap | Set on terminal success, the approval-pause return, and every in-loop failure. Responses that never entered the loop (pre-loop transport error, invalid pending state) report `iterations_used=0`. The TUI/operator can now render "step 5/8" instead of flying blind. ## Consecutive-failure circuit breaker (P4) Before the loop continues past a failed execution, `_run_loop` computes the request signature — `tool name + json.dumps(arguments, sort_keys=True)` (the same canonical form used for dedup) — and tracks the consecutive identical failure streak: - **same signature fails again** → streak grows; at **>= 2** the loop returns `tool {name} failed {n} consecutive times with identical arguments — stopping before the iteration budget burns` (typed failure, events preserved); - **tool succeeds** → streak resets; - **same tool, different arguments** → new signature, own streak (the model adapting is never punished); - covers BOTH failure sites: read-only dispatch and the approval-branch inline execution (e.g. a path-denied `edit_file` with the authority executor). Without it, one always-failing tool burned the whole 8-iteration budget (P4 probe). With it, the burn stops at 2 identical executions. ## Sandbox error contract (P1) All write denials flow through `tool_executor.validate_write_path` (the single choke point for `write_file` / `edit_file` / `format_code` prepare, apply-after-approval, and revalidation). The denial message names the allowed directories so the model can self-correct in one step: ``` Access denied: /etc/nope.txt is outside allowed write directories: ['/home/nick', '/mnt/workdrive']. Write inside one of those directories instead. ``` Sandbox facts: `ALLOWED_READ_DIRS` = home, `/mnt/workdrive`, `/tmp`; `ALLOWED_WRITE_DIRS` = home, `/mnt/workdrive` — **`/tmp` is read-ok but write-blocked**; `/etc`, `/var`, `/proc`, `/sys`, `~/.ssh`, `~/.gnupg` are hard-blocked. Disposable coding sandboxes belong under `~/.rune-e2e-probe/`. ## Tool-capable system guidance (P2) The operator's `tools` context block (`RuneOperator._contributor_tools`, bucket 90, non-evictable) is no longer a bare name list — a tool-capable model given only names claimed it could not read files (P2 probe). The block now ends with: ``` Use tools for every file/command action; never fabricate tool output. Read before editing; verify fixes by running them. ``` The runtime-owned fallback prompt (non-native models) is unchanged and still injected/traced by `LocalToolRuntime` itself. ## Capability discovery `OllamaChatTransport.supports_tools(model)` / `supports_thinking(model)` read `capabilities` from one cached `/api/show` call; the same response yields the trained context window (`context_length`, #19 Task 2). Models without the `tools` capability get the text-fallback protocol; models without `thinking` never receive a `think` parameter. ## Acceptance scenario (live model, CI-safe skip) `tests/test_p0_05_acceptance.py` replays the issue's acceptance end-to-end with real `qwen3:14b` native tool calls: a broken `add()` in a disposable sandbox (`~/.rune-e2e-probe/p0-05`), system prompt "You are a coding agent. Fix bugs using your tools…", approval pauses resolved programmatically via `runtime.approve(resp.pending_state)` (exactly the TUI flow, capped at 6 rounds), then asserts the fix landed (`mod.add(2, 3) == 5`). Run it live: ```bash PYTHONPATH=src:. python3 -m pytest tests/test_p0_05_acceptance.py -v ``` It skips cleanly when Ollama is unreachable (CI has no model). Local green run (2026-09-05): **PASSED in 14.1s** — 5 tool events, all `ollama-native`, `iterations_used 4/8`, `FIX VERIFIED: True` (recorded in the plan tail). The CI canary (`src/rune/canary_p0_05.py`) machine-checks the same claims with no model: breaker trips on a scripted loop, breaker message pinned in source, budget fields on a scripted response, sandbox denial text, max_iterations enforcement, tools-block guidance.