Files
rune/docs/native-tools.md
T
Nick 21ae179d63
CI Pipeline / lint (push) Successful in 1m50s
CI Pipeline / unit-tests (push) Successful in 2m6s
CI Pipeline / canary (push) Successful in 1m38s
CI Pipeline / best-practices (push) Successful in 5s
CI Pipeline / documentation (push) Successful in 5s
CI Pipeline / type-check (push) Successful in 2m4s
CI Pipeline / gitea-offline (push) Successful in 43s
CI Pipeline / required-checks (push) Skipped
fix(pipeline-gate): flatten nested template literal breaking act_runner teach job
2026-09-05 13:21:34 +01:00

6.0 KiB
Raw Blame History

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:

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.