6.0 KiB
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_filewith 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:
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.