feat(local_tool_runtime): close PARTIAL gaps — dichotomy wave

Name-preserving 1:1 port of the 22 functions the audit named missing
against the src/rune/local_tool_runtime.py oracle: _find_capability_executor,
_extract_context_length, context_length, reset_context_length_cache,
supports_tools, supports_thinking, _positive_int, _model_capabilities,
resume, approve, reject, _expose_tools, _deduplicate, _tool_message,
_event_to_state, _event_from_state, _request_to_state, _request_from_state,
_with_fallback_prompt (plus _post/_model_capabilities-side context caching
and the tool-protocol validation/spans core the loop needs). IO sits behind
injectable seams (PostFunc, LoopTransport, ToolExecutor, cloud/local
transport constructors, RecordFailure, ResetDiscoveredWindows, ParamOrder).

Frozen truth: scripts/gen_local_tool_runtime_truth.py exercises every
branch against the LIVE oracle with deep-copied inputs; truth.json
(embedded, byte-deterministic) replays 96 rows in Go — full envelopes,
chat payloads, executor calls, pending states. Documented divergences:
Python bool-sub-int quirk in _extract_context_length (Go rejects bools),
uuid call_ids in text-fallback envelopes (wildcard-matched), exception
TYPE names (message parity only).

Audit: local_tool_runtime 8/30 PARTIAL -> 30/30 FULL. Go: build/gofmt/vet/
-race green. Python oracle tests: 23 passed (delegation live and
RUNE_LOCAL_TOOL_RUNTIME_CORE=python pinned). Contract
BUILDER-2026-10-13T00:04:53Z-0b0047-factory-gap-ltr.
This commit is contained in:
Nick committed 2026-10-13 07:01:01 +01:00
1 parent 82bec72695
commit d6cbc05951
15 files changed
+11206 -6

No files matched your search

+704
View File
@@ -0,0 +1,704 @@
#!/usr/bin/env python3
"""gen_local_tool_runtime_truth.py — freeze truth for the local_tool_runtime
gap-close (dichotomy wave). Every branch of the NEW functions is exercised
against the LIVE Python oracle (src/rune/local_tool_runtime.py) with deep-
copied inputs; the frozen JSON is embedded verbatim in the Go truth battery
(src/go/local-tool-runtime/truth.json, go:embed).
Excluded from parity (documented, per auto_context/evidence_probe precedent):
- PreparedTool approve/reject fan-out: tokens are uuid4 and timestamps are
time.monotonic() — interpreter-nondeterministic. The plain needs_confirm
approval branch (executor without tool authority) IS frozen.
- register_window/reset_discovered_windows broker side effects: stubbed at
the seam (import succeeds, calls recorded, no state) — the Go side has no
broker; the seam boundary is the parity contract.
- _find_capability_executor on mutating tools: needs uuid tokens.
Frozen read-only execute_tool rows cover the routing contract.
Run: python3 scripts/gen_local_tool_runtime_truth.py
"""
from __future__ import annotations
import copy
import json
import sys
import types
import unittest.mock as mock
from dataclasses import asdict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
import rune.context_broker as context_broker
import rune.openai_compat_transport as openai_compat_transport
import rune.subsystem_health as subsystem_health
from rune import local_tool_runtime as ltr
from rune.local_tool_runtime import (
LocalToolRuntime,
OllamaChatTransport,
_extract_context_length,
)
import re
_UUID_CALL_ID = re.compile(r"^call_[0-9a-f]{32}$")
def wildcard_uuids(obj):
"""Replace nondeterministic _new_call_id() uuids with a wildcard —
Python text-fallback envelopes always mint fresh uuids; the id SHAPE
(call_<32 hex>) is the contract, the value is not."""
if isinstance(obj, dict):
return {
k: ("<new_call_id>" if (k in ("call_id", "tool_call_id") and isinstance(v, str) and _UUID_CALL_ID.match(v)) else wildcard_uuids(v))
for k, v in obj.items()
}
if isinstance(obj, list):
return [wildcard_uuids(x) for x in obj]
return obj
def freeze(obj):
"""JSON-safe projection: frozensets -> sorted lists, tuples -> lists."""
if isinstance(obj, frozenset) or isinstance(obj, set):
return sorted(obj)
if isinstance(obj, tuple):
return [freeze(x) for x in obj]
if isinstance(obj, dict):
return {str(k): freeze(v) for k, v in obj.items()}
if isinstance(obj, list):
return [freeze(x) for x in obj]
if isinstance(obj, BaseException):
return type(obj).__name__ + ": " + str(obj)
return obj
def resp_freeze(response):
d = asdict(response)
return freeze(d)
# ── transport fakes (deep-copied inputs, recorded calls) ─────────────────────
class FakeTransport:
def __init__(self, responses, *, native_tools=True, thinking=True):
self.responses = list(responses)
self.native_tools = native_tools
self.thinking = thinking
self.calls = []
def supports_tools(self, model):
return self.native_tools
def supports_thinking(self, model):
return self.thinking
def chat(self, *, model, messages, tools, options=None, think=None):
request = {
"model": model,
"messages": copy.deepcopy(messages),
"tools": copy.deepcopy(tools),
"options": copy.deepcopy(options),
"think": copy.deepcopy(think),
}
self.calls.append(request)
response = self.responses.pop(0)
if isinstance(response, BaseException):
raise response
if not isinstance(response, dict):
raise TypeError("fake response must be a message object")
return copy.deepcopy(response)
class ScriptExecutor:
def __init__(self, results):
self.results = list(results)
self.calls = []
def __call__(self, name, arguments):
self.calls.append((name, copy.deepcopy(arguments)))
if self.results:
return self.results.pop(0)
return {"success": True, "result": name}
# ── 1. _extract_context_length ────────────────────────────────────────────────
extract_rows = []
shown_cases = [
{"model_info": {"qwen2.context_length": 131072}},
{"model_info": {"qwen2.context_length": "131072", "other.context_length": 4096}},
{"model_info": {"qwen2.context_length": 0}},
{"model_info": "not a dict", "context_length": 8192},
{"context_length": 4096},
{"context_length": "4096"},
{"context_length": -5},
{},
{"model_info": {"arch.num_params": 7}},
{"model_info": {"qwen2.context_length": True}},
]
for shown in shown_cases:
want = _extract_context_length(copy.deepcopy(shown))
extract_rows.append({"shown": shown, "want": want})
# ── 2. _positive_int ─────────────────────────────────────────────────────────
positive_int_rows = []
for value in [5, 0, -3, True, False, "7", 3.7, None, [1], {"a": 1}, 10**12]:
positive_int_rows.append({"value": freeze(value), "want": OllamaChatTransport._positive_int(value)})
# ── 3. _model_capabilities / supports_tools / supports_thinking ──────────────
cap_rows = []
cap_cases = [
{"capabilities": ["completion", "tools", 5, None, "thinking"]},
{"capabilities": "tools"},
{},
{"capabilities": []},
]
for shown in cap_cases:
transport = OllamaChatTransport()
posts = []
def fake_post(path, payload, _shown=shown, _posts=posts):
_posts.append((path, copy.deepcopy(payload)))
return copy.deepcopy(_shown)
transport._post = fake_post
caps = sorted(transport._model_capabilities("m1"))
tools = transport.supports_tools("m1")
thinking = transport.supports_thinking("m1")
caps2 = sorted(transport._model_capabilities("m1"))
cap_rows.append({
"shown": shown,
"capabilities": caps,
"capabilities_second_call": caps2,
"supports_tools": tools,
"supports_thinking": thinking,
"posts": posts,
"context_lengths": freeze(transport._context_lengths),
})
# caching: two different models, two posts
transport = OllamaChatTransport()
posts = []
transport._post = lambda path, payload, _p=posts: (
_p.append((path, copy.deepcopy(payload))) or copy.deepcopy({"capabilities": ["tools"], "model_info": {"a.context_length": 2048}})
)
c1 = sorted(transport._model_capabilities("m1"))
c2 = sorted(transport._model_capabilities("m2"))
cap_rows.append({
"shown": "two-models",
"capabilities": c1 + c2,
"capabilities_second_call": None,
"supports_tools": None,
"supports_thinking": None,
"posts": posts,
"context_lengths": freeze(transport._context_lengths),
})
# ── 4. OllamaChatTransport.context_length ────────────────────────────────────
tctx_rows = []
for shown, scenario in [
({"model_info": {"qwen2.context_length": 4096}}, "reported"),
({}, "absent"),
]:
transport = OllamaChatTransport()
posts = []
transport._post = lambda path, payload, _s=shown, _p=posts: (
_p.append((path, copy.deepcopy(payload))) or copy.deepcopy(_s)
)
if scenario == "reported":
window = transport.context_length("m1")
window2 = transport.context_length("m1")
tctx_rows.append({
"scenario": scenario,
"shown": shown,
"window": window,
"window_second_call": window2,
"posts": posts,
})
else:
try:
transport.context_length("m1")
error = None
except ValueError as exc:
error = str(exc)
tctx_rows.append({"scenario": scenario, "shown": shown, "window": None, "error": error, "posts": posts})
# ── 5. module-level context_length ───────────────────────────────────────────
class _StubOllama:
instances = []
def __init__(self, base_url="http://localhost:11434", timeout=120):
self.base_url = base_url
self.instances.append(self)
def context_length(self, model):
return 4096
class _StubCloud:
def __init__(self):
pass
def context_length(self, model):
return 131072
def run_module_context_length(scenario, model, seed_cache):
ltr._CONTEXT_LENGTHS.clear()
ltr._CONTEXT_LENGTHS.update(copy.deepcopy(seed_cache))
failures = []
broker_resets = []
with mock.patch.object(ltr, "OllamaChatTransport", _StubOllama), \
mock.patch.object(openai_compat_transport, "is_cloud_pinned",
lambda m: scenario in ("cloud", "cloud_raises")), \
mock.patch.object(openai_compat_transport, "cloud_transport_for",
lambda m: _StubCloud()), \
mock.patch.object(subsystem_health, "record_failure",
lambda *a, **k: failures.append({"args": freeze(a), "kwargs": freeze(k)})), \
mock.patch.object(context_broker, "register_window",
lambda *a, **k: broker_resets.append(("register", freeze(a)))), \
mock.patch.object(context_broker, "reset_discovered_windows",
lambda: broker_resets.append(("reset", None))):
if scenario == "raises":
def boom(self, model):
raise RuntimeError("ollama down")
with mock.patch.object(_StubOllama, "context_length", boom):
window = ltr.context_length(model)
elif scenario == "cloud_raises":
class _BoomCloud:
def context_length(self, m):
raise RuntimeError("cloud down")
with mock.patch.object(openai_compat_transport, "cloud_transport_for", lambda m: _BoomCloud()):
window = ltr.context_length(model)
else:
window = ltr.context_length(model)
window2 = ltr.context_length(model)
return {
"scenario": scenario,
"model": model,
"seed_cache": freeze(seed_cache),
"window": window,
"window_second_call": window2,
"cache_after": freeze(ltr._CONTEXT_LENGTHS),
"failures": failures,
"broker": broker_resets,
"ollama_constructs": len(_StubOllama.instances),
}
_StubOllama.instances = []
module_ctx_rows = [
run_module_context_length("local", "qwen2.5:3b", {}),
run_module_context_length("cloud", "Verboo Code/glm-5.3-flash", {}),
run_module_context_length("cached", "m1", {"m1": 32768}),
run_module_context_length("raises", "m1", {}),
run_module_context_length("cloud_raises", "m1", {}),
]
_StubOllama.instances = []
# ── 6. reset_context_length_cache ────────────────────────────────────────────
broker_resets = []
ltr._CONTEXT_LENGTHS.clear()
ltr._CONTEXT_LENGTHS.update({"m1": 4096, "m2": 8192})
with mock.patch.object(context_broker, "reset_discovered_windows",
lambda: broker_resets.append("reset")):
ltr.reset_context_length_cache()
reset_row = {"cache_before": {"m1": 4096, "m2": 8192}, "cache_after": freeze(ltr._CONTEXT_LENGTHS),
"broker": broker_resets}
# ── 7. _deduplicate ──────────────────────────────────────────────────────────
from rune.tool_protocol import ToolRequest
dedup_rows = []
dedup_cases = [
[("1", "read_file", {"path": "/a", "limit": 5}), ("2", "read_file", {"limit": 5, "path": "/a"})],
[("1", "read_file", {"path": "/a"}), ("2", "read_file", {"path": "/b"})],
[("1", "list_directory", {}), ("2", "list_directory", {}), ("3", "grep_files", {"pattern": "x"})],
[],
]
for case in dedup_cases:
requests = [ToolRequest(cid, name, args, "ollama-native") for cid, name, args in case]
unique = LocalToolRuntime._deduplicate(copy.deepcopy(requests))
dedup_rows.append({
"requests": [{"call_id": r.call_id, "name": r.name, "arguments": r.arguments, "source": r.source} for r in requests],
"unique": [{"call_id": r.call_id, "name": r.name, "arguments": r.arguments, "source": r.source} for r in unique],
})
# ── 8. _tool_message ─────────────────────────────────────────────────────────
tool_message_rows = []
for request_args, result in [
({"path": "/tmp"}, {"success": True, "result": "listing", "z": 1, "a": [1, 2]}),
({"path": "/tmp"}, {"success": False, "error": "permission denied"}),
]:
request = ToolRequest("call-1", "list_directory", request_args, "ollama-native")
message = LocalToolRuntime._tool_message(request, copy.deepcopy(result))
tool_message_rows.append({
"request": {"call_id": request.call_id, "name": request.name, "arguments": request.arguments, "source": request.source},
"result": result,
"message": freeze(message),
})
# ── 9/10. request & event state roundtrips + errors ──────────────────────────
request_state_rows = []
event_state_rows = []
request = ToolRequest("call-9", "read_file", {"path": "/a"}, "text-fallback")
state = LocalToolRuntime._request_to_state(copy.deepcopy(request))
request_state_rows.append({"input": {"call_id": request.call_id, "name": request.name, "arguments": request.arguments, "source": request.source}, "state": freeze(state)})
for bad in ["nope", 5, None, [], {}, {"call_id": "", "name": "n", "arguments": {}, "source": "s"},
{"call_id": "c", "name": "", "arguments": {}, "source": "s"},
{"call_id": "c", "name": "n", "arguments": [], "source": "s"},
{"call_id": "c", "name": "n", "arguments": {}, "source": ""},
{"call_id": "c", "name": "n"}]:
try:
LocalToolRuntime._request_from_state(copy.deepcopy(bad))
error = None
except (TypeError, ValueError) as exc:
error = type(exc).__name__ + ": " + str(exc)
request_state_rows.append({"input": freeze(bad) if isinstance(bad, (dict, list)) else bad, "error": error})
event = ltr.ToolEvent("completed", request, {"success": True, "result": "ok"})
event_state_rows.append({"state": freeze(LocalToolRuntime._event_to_state(copy.deepcopy(event)))})
event_none = ltr.ToolEvent("error", request, None)
event_state_rows.append({"state": freeze(LocalToolRuntime._event_to_state(copy.deepcopy(event_none)))})
for bad in ["nope", 5, None, [], {}, {"kind": "", "request": state, "result": None},
{"kind": "completed", "request": "x", "result": None},
{"kind": "completed", "request": state, "result": "x"}]:
try:
LocalToolRuntime._event_from_state(copy.deepcopy(bad))
error = None
except (TypeError, ValueError) as exc:
error = type(exc).__name__ + ": " + str(exc)
event_state_rows.append({"input": freeze(bad) if isinstance(bad, (dict, list)) else bad, "error": error})
# ── 11. _find_capability_executor ────────────────────────────────────────────
fce_rows = []
for name, arguments in [
("read_file", {}),
("read_file", {"path": "/nonexistent/gap-ltr-probe.txt"}),
("list_directory", {"path": "/tmp"}),
]:
result = ltr._find_capability_executor(name, copy.deepcopy(arguments))
fce_rows.append({"tool": name, "arguments": arguments, "result": freeze(result)})
# ── 12. _with_fallback_prompt ────────────────────────────────────────────────
SMALL_TOOLS = [
{"name": "read_file", "description": "Read a file with optional windowed viewing",
"parameters": {"type": "object", "properties": {"path": {"type": "string", "description": "Absolute path to the file"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "list_directory", "description": "List files in a directory",
"parameters": {"type": "object", "properties": {}, "required": []}},
]
fallback_rows = []
for messages, label in [
([{"role": "user", "content": "hello"}], "insert"),
([{"role": "system", "content": "Be brief."}, {"role": "user", "content": "hello"}], "system-append"),
]:
runtime = LocalToolRuntime(transport=FakeTransport([]), executor=ScriptExecutor([]), model="m1", tools=copy.deepcopy(SMALL_TOOLS))
out = runtime._with_fallback_prompt(copy.deepcopy(messages))
fallback_rows.append({"label": label, "messages": messages, "result": freeze(out)})
# ── 13. resume failure branches ──────────────────────────────────────────────
BASE_STATE = {
"messages": [{"role": "user", "content": "hello"}],
"native_tools": True,
"iterations_used": 0,
"pending_request": {"call_id": "write-1", "name": "write_file", "arguments": {"path": "/tmp/a", "content": "x"}, "source": "ollama-native"},
}
resume_failure_rows = []
failure_cases = [
("messages-not-list", {"messages": "nope"}),
("native-tools-not-bool", {"native_tools": "false"}),
("options-not-dict", {"options": "x"}),
("think-not-bool-string", {"think": 5}),
("exposed-not-list", {"exposed_tools": "nope"}),
("exposed-not-strings", {"exposed_tools": [1]}),
("pending-request-missing", {"pending_request": None}),
("pending-request-not-dict", {"pending_request": "x"}),
("request-call-id-empty", {"pending_request": {"call_id": "", "name": "write_file", "arguments": {}, "source": "s"}}),
("request-args-not-dict", {"pending_request": {"call_id": "c", "name": "write_file", "arguments": [], "source": "s"}}),
("tool-outside-allowlist", {"pending_request": {"call_id": "c", "name": "write_file", "arguments": {"path": "/a", "content": "x"}, "source": "s"}}, {"tools": [{"name": "read_file", "description": "r", "parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}]}),
("events-not-list", {"tool_events": "x"}),
("event-not-dict", {"tool_events": ["x"]}),
("thinking-not-strings", {"thinking": [1]}),
("iterations-not-int", {"iterations_used": "3"}),
("iterations-bool", {"iterations_used": True}),
("iterations-negative", {"iterations_used": -1}),
("iterations-over-budget", {"iterations_used": 9}),
("remaining-not-list", {"remaining_requests": "x"}),
("remaining-request-bad", {"remaining_requests": ["x"]}),
]
for case in failure_cases:
label, overrides = case[0], case[1]
tools = case[2]["tools"] if len(case) > 2 else None
state = copy.deepcopy(BASE_STATE)
state.update(copy.deepcopy(overrides))
runtime = LocalToolRuntime(transport=FakeTransport([]), executor=ScriptExecutor([]), model="m1", tools=tools)
response = runtime.resume(copy.deepcopy(state), {"success": True, "result": "approved"})
resume_failure_rows.append({
"label": label,
"pending_state": freeze(state),
"tool_result": {"success": True, "result": "approved"},
"response": resp_freeze(response),
})
# ── 14. resume loop rows (full envelope + scripts + chat payload parity) ──────
resume_loop_rows = []
def make_state(**overrides):
state = copy.deepcopy(BASE_STATE)
state.update(copy.deepcopy(overrides))
return state
def run_resume_row(label, state, tool_result, chat_responses, executor_script, runtime_tools=None):
"""Construct a fresh runtime with the given scripts, resume, freeze the
full row (envelope + recorded chat payloads + executor calls + scripts)."""
transport = FakeTransport(list(chat_responses))
executor = ScriptExecutor(list(executor_script))
tools = None
if runtime_tools is not None:
import rune.tool_executor as te
tools = [copy.deepcopy(t) for t in te.TOOLS if t["name"] in runtime_tools]
runtime = LocalToolRuntime(transport=transport, executor=executor, model="m1", tools=tools)
response = runtime.resume(copy.deepcopy(state), copy.deepcopy(tool_result))
return {
"label": label,
"pending_state": freeze(state),
"tool_result": freeze(tool_result),
"response": wildcard_uuids(resp_freeze(response)),
"executor_calls": [{"tool": n, "arguments": a} for n, a in executor.calls],
"executor_script": freeze(executor_script),
"chat_responses": freeze(chat_responses),
"runtime_tools": runtime_tools,
"chat_payloads": wildcard_uuids(freeze(transport.calls)),
}
TOOL_EVENT_SEED = {
"kind": "completed",
"request": {"call_id": "0", "name": "repo_map", "arguments": {"path": "/tmp"}, "source": "ollama-native"},
"result": {"success": True, "result": "map"},
}
# a) read-only pending + remaining → executes, continues, final answer
state = make_state(
messages=[{"role": "user", "content": "inspect"}],
options={"temperature": 0},
think=True,
iterations_used=1,
pending_request={"call_id": "1", "name": "list_directory", "arguments": {"path": "/tmp"}, "source": "ollama-native"},
tool_events=[copy.deepcopy(TOOL_EVENT_SEED)],
thinking=["thought one"],
remaining_requests=[{"call_id": "2", "name": "repo_map", "arguments": {"path": "/tmp"}, "source": "ollama-native"}],
exposed_tools=["repo_map", "list_directory", "find_capability"],
)
resume_loop_rows.append(run_resume_row(
"read-only-continues",
state,
{"success": True, "result": "listed"},
[{"role": "assistant", "content": "All done.", "thinking": "wrap up"}],
[
{"success": True, "result": ["a.txt"], "dir": "/tmp"},
{"success": True, "result": "map body"},
],
))
# b) model issues a NEW mutating call → plain needs_confirm approval state
# (executor without tool authority); the resume tool_result only settles
# the pending request.
state = make_state(
messages=[{"role": "user", "content": "write it"}],
think=None,
iterations_used=0,
pending_request={"call_id": "w1", "name": "write_file", "arguments": {"path": "/tmp/gap-ltr.txt", "content": "x"}, "source": "ollama-native"},
)
resume_loop_rows.append(run_resume_row(
"pending-approval",
state,
{"success": True, "result": "approved"},
[
{"role": "assistant", "content": "", "tool_calls": [{"id": "w2", "function": {"name": "write_file", "arguments": {"path": "/tmp/gap-ltr.txt", "content": "y"}}}]},
],
[],
))
# c) rejected tool result → error event, loop continues to final
state = make_state(
messages=[{"role": "user", "content": "write it"}],
iterations_used=0,
pending_request={"call_id": "w1", "name": "write_file", "arguments": {"path": "/tmp/gap-ltr.txt", "content": "x"}, "source": "ollama-native"},
thinking=["decided"],
)
resume_loop_rows.append(run_resume_row(
"rejected-result",
state,
{"success": False, "error": "operator rejected"},
[{"role": "assistant", "content": "Left unchanged."}],
[],
))
# d) circuit breaker: two consecutive identical IN-LOOP dispatch failures
# (the resume tool_result settles the pending request and is NOT
# registered with the breaker — signature tracking starts in the loop).
state = make_state(
pending_request={"call_id": "1", "name": "repo_map", "arguments": {"path": "/tmp"}, "source": "ollama-native"},
)
resume_loop_rows.append(run_resume_row(
"circuit-breaker",
state,
{"success": False, "error": "no such dir"},
[
{"role": "assistant", "content": "", "tool_calls": [{"id": "2", "function": {"name": "repo_map", "arguments": {"path": "/x"}}}]},
{"role": "assistant", "content": "", "tool_calls": [{"id": "3", "function": {"name": "repo_map", "arguments": {"path": "/x"}}}]},
],
[
{"success": False, "error": "no such dir"},
{"success": False, "error": "no such dir"},
],
))
# e) iteration limit after resume consumed budget
state = make_state(
pending_request={"call_id": "1", "name": "repo_map", "arguments": {"path": "/tmp"}, "source": "ollama-native"},
iterations_used=8,
)
resume_loop_rows.append(run_resume_row(
"iteration-limit",
state,
{"success": True, "result": "ok"},
[],
[{"success": True, "result": "map"}],
))
# f) text-fallback resume (native_tools false)
state = make_state(
messages=[{"role": "user", "content": "inspect"}],
native_tools=False,
pending_request={"call_id": "1", "name": "list_directory", "arguments": {"path": "/tmp"}, "source": "text-fallback"},
)
resume_loop_rows.append(run_resume_row(
"text-fallback",
state,
{"success": True, "result": "listed"},
[
{"role": "assistant", "content": 'blah {"tool":"list_directory","args":{"path":"/tmp"}} blah'},
{"role": "assistant", "content": "fallback done"},
],
[
{"success": True, "result": ["a.txt"]},
{"success": True, "result": ["a.txt"]},
],
))
# g) tool outside allowlist during loop → escape-hatch message
state = make_state(
pending_request={"call_id": "1", "name": "repo_map", "arguments": {"path": "/tmp"}, "source": "ollama-native"},
)
resume_loop_rows.append(run_resume_row(
"allowlist-escape-hatch",
state,
{"success": True, "result": "ok"},
[
{"role": "assistant", "content": "", "tool_calls": [{"id": "2", "function": {"name": "write_file", "arguments": {"path": "/a", "content": "x"}}}]},
],
[{"success": True, "result": "map"}],
runtime_tools=("repo_map", "list_directory", "read_file"),
))
# h) malformed native tool calls → structured failure
state = make_state(
pending_request={"call_id": "1", "name": "repo_map", "arguments": {"path": "/tmp"}, "source": "ollama-native"},
)
resume_loop_rows.append(run_resume_row(
"malformed-args-json",
state,
{"success": True, "result": "ok"},
[
{"role": "assistant", "content": "", "tool_calls": [{"id": "2", "function": {"name": "repo_map", "arguments": "{bad"}}]},
],
[{"success": True, "result": "map"}],
))
# i) empty terminal visible text
state = make_state(
pending_request={"call_id": "1", "name": "repo_map", "arguments": {"path": "/tmp"}, "source": "ollama-native"},
)
resume_loop_rows.append(run_resume_row(
"empty-terminal",
state,
{"success": True, "result": "ok"},
[{"role": "assistant", "content": " \n\t"}],
[{"success": True, "result": "map"}],
))
# j) internal-protocol marker not at line start is ordinary content
state = make_state(
pending_request={"call_id": "1", "name": "repo_map", "arguments": {"path": "/tmp"}, "source": "ollama-native"},
)
resume_loop_rows.append(run_resume_row(
"internal-protocol",
state,
{"success": True, "result": "ok"},
[{"role": "assistant", "content": "here [tool result] leaked"}],
[{"success": True, "result": "map"}],
))
# k) unknown tool in native calls → escape hatch (validate inside normalize)
state = make_state(
pending_request={"call_id": "1", "name": "repo_map", "arguments": {"path": "/tmp"}, "source": "ollama-native"},
)
resume_loop_rows.append(run_resume_row(
"unknown-tool-native",
state,
{"success": True, "result": "ok"},
[
{"role": "assistant", "content": "", "tool_calls": [{"id": "2", "function": {"name": "definitely_not_a_tool", "arguments": {}}}]},
],
[{"success": True, "result": "map"}],
))
# ── write ─────────────────────────────────────────────────────────────────────
truth = {
"extract_context_length": extract_rows,
"positive_int": positive_int_rows,
"model_capabilities": cap_rows,
"transport_context_length": tctx_rows,
"module_context_length": module_ctx_rows,
"reset_cache": reset_row,
"deduplicate": dedup_rows,
"tool_message": tool_message_rows,
"request_state": request_state_rows,
"event_state": event_state_rows,
"find_capability_executor": fce_rows,
"fallback_prompt": fallback_rows,
"resume_failures": resume_failure_rows,
"resume_loops": resume_loop_rows,
}
out = ROOT / "src" / "go" / "local-tool-runtime" / "truth.json"
out.write_text(json.dumps(truth, indent=1, sort_keys=True) + "\n")
print("written:", out, len(json.dumps(truth)), "bytes")
for key, rows in truth.items():
print(f" {key}: {len(rows) if isinstance(rows, list) else 1}")
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Open the #2479 dichotomy-wave construction contract for local_tool_runtime (GO gap-close).
Full lifecycle walk in ONE process (shared-store clobber hazard)."""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from rune.construction_interface import ConstructionInterface
iface = ConstructionInterface(builder="factory-gap-ltr")
r = iface.submit_need(
"Close the PARTIAL gaps in src/go/local-tool-runtime against the "
"src/rune/local_tool_runtime.py oracle (dichotomy wave, issue #2479 "
"tranche): name-preserving 1:1 port of the 22 unmatched functions "
"(_find_capability_executor, _extract_context_length, context_length, "
"reset_context_length_cache, supports_tools, supports_thinking, "
"_positive_int, _model_capabilities, resume, approve, reject, "
"_expose_tools, _deduplicate, _tool_message, _event_to_state, "
"_event_from_state, _request_to_state, _request_from_state, "
"_with_fallback_prompt and companions), IO behind injectable seams, "
"frozen-truth battery generated against the live Python oracle "
"(RUNE core pinned to python, deep-copied inputs), existing tests "
"green + gofmt/vet clean, live parity 100% on the new functions.",
origin="builder",
)
print("submit_need:", json.dumps(r)[:400])
if not r.get("success"):
print("NEED FAILED — aborting")
sys.exit(1)
cid = r["ids"]["contract_id"]
print("contract_id:", cid)
for phase in ("approved", "building", "verifying"):
t = iface.transition(cid, phase)
print(f"transition->{phase}:", json.dumps(t)[:300])
if not t.get("success"):
sys.exit(1)
print("CONTRACT", cid, "IN VERIFYING (close happens at delivery with SHAs)")
+229
View File
@@ -0,0 +1,229 @@
package main
// capabilities.go — OllamaChatTransport parity port (name-preserving):
// _post, _positive_int, _extract_context_length, _model_capabilities,
// context_length, supports_tools, supports_thinking and the typed chat
// call with P0-07 usage accumulation. HTTP sits behind the PostFunc seam
// so the truth battery can pin every branch without a live Ollama.
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// PostFunc is the _post seam: one JSON POST returning the decoded object.
type PostFunc func(path string, payload map[string]interface{}) (map[string]interface{}, error)
// RegisterWindowFunc is the context_broker.register_window seam (the
// import is guarded in Python; a missing broker must never fail discovery).
var RegisterWindowFunc func(model string, window int)
// OllamaTransport — small stdlib transport for /api/chat and /api/show.
type OllamaTransport struct {
Provider string
BaseURL string
Timeout time.Duration
PostFunc PostFunc
capabilities map[string]map[string]bool
contextLengths map[string]int
LastPromptEvalCount *int64
UsageCalls int64
UsagePromptTokens int64
UsageOutputTokens int64
UsageTotalDuration int64
}
func NewOllamaTransport(baseURL string, timeout time.Duration) *OllamaTransport {
return &OllamaTransport{
Provider: "ollama",
BaseURL: baseURL,
Timeout: timeout,
capabilities: map[string]map[string]bool{},
contextLengths: map[string]int{},
}
}
// Post — the default _post implementation (JSON body, object response).
func (t *OllamaTransport) Post(path string, payload map[string]interface{}) (map[string]interface{}, error) {
if t.PostFunc != nil {
return t.PostFunc(path, payload)
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: t.Timeout}
response, err := client.Post(t.BaseURL+path, "application/json", bytes.NewReader(body))
if err != nil {
return nil, err
}
defer response.Body.Close()
raw, err := io.ReadAll(response.Body)
if err != nil {
return nil, err
}
var decoded interface{}
if err := json.Unmarshal(raw, &decoded); err != nil {
return nil, err
}
obj, ok := decoded.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("Ollama returned a non-object response")
}
return obj, nil
}
// PositiveInt — coerce an Ollama usage field to a non-negative int
// (0 otherwise). Booleans are explicitly excluded (Python isinstance
// ordering), as are floats and strings.
func PositiveInt(value interface{}) int64 {
number, ok := value.(float64)
if !ok {
return 0
}
if number != float64(int64(number)) || number <= 0 {
return 0
}
return int64(number)
}
// ExtractContextLength — pull the trained context window out of an
// /api/show response: model_info["<arch>.context_length"] first, then a
// top-level context_length. 0 when absent (caller decides fallback).
func ExtractContextLength(shown map[string]interface{}) int {
modelInfo, ok := shown["model_info"].(map[string]interface{})
if ok {
for key, value := range modelInfo {
number, isInt := value.(float64)
if stringsHasSuffix(key, ".context_length") && isInt &&
number == float64(int64(number)) && number > 0 {
return int(number)
}
}
}
topLevel, ok := shown["context_length"].(float64)
if ok && topLevel == float64(int64(topLevel)) && topLevel > 0 {
return int(topLevel)
}
return 0
}
func stringsHasSuffix(s, suffix string) bool {
return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
}
// ModelCapabilities — one /api/show per model; the same response carries
// the real context length, cached here so discovery costs nothing extra.
func (t *OllamaTransport) ModelCapabilities(model string) (map[string]bool, error) {
if caps, ok := t.capabilities[model]; ok {
return caps, nil
}
shown, err := t.Post("/api/show", map[string]interface{}{"model": model})
if err != nil {
return nil, err
}
caps := map[string]bool{}
rawCaps, ok := shown["capabilities"].([]interface{})
if ok {
for _, item := range rawCaps {
if s, isStr := item.(string); isStr {
caps[s] = true
}
}
}
t.capabilities[model] = caps
if _, has := t.contextLengths[model]; !has {
t.contextLengths[model] = ExtractContextLength(shown)
}
return caps, nil
}
// ContextLength — the model's real context window from /api/show (cached).
// Raises when the model does not report one; the module-level ContextLength
// applies the conservative fallback with an observable T1 failure.
func (t *OllamaTransport) ContextLength(model string) (int, error) {
if window, ok := t.contextLengths[model]; ok {
if window == 0 {
return 0, fmt.Errorf("/api/show reported no context length for '%s'", model)
}
return window, nil
}
shown, err := t.Post("/api/show", map[string]interface{}{"model": model})
if err != nil {
return 0, err
}
window := ExtractContextLength(shown)
t.contextLengths[model] = window
if window == 0 {
return 0, fmt.Errorf("/api/show reported no context length for '%s'", model)
}
if RegisterWindowFunc != nil {
RegisterWindowFunc(model, window)
}
return window, nil
}
// SupportsTools — the model's advertised native-tools capability.
func (t *OllamaTransport) SupportsTools(model string) (bool, error) {
caps, err := t.ModelCapabilities(model)
if err != nil {
return false, err
}
return caps["tools"], nil
}
// SupportsThinking — the model's advertised thinking capability.
func (t *OllamaTransport) SupportsThinking(model string) (bool, error) {
caps, err := t.ModelCapabilities(model)
if err != nil {
return false, err
}
return caps["thinking"], nil
}
// Chat — send typed messages and preserve Ollama's returned message object,
// accumulating P0-07 usage from the response envelope.
func (t *OllamaTransport) Chat(model string, messages []map[string]interface{}, tools []map[string]interface{}, options map[string]interface{}, think interface{}) (map[string]interface{}, error) {
payload := map[string]interface{}{
"model": model,
"messages": messages,
"stream": false,
}
if tools != nil {
payload["tools"] = tools
}
if options != nil {
payload["options"] = options
}
if think != nil {
payload["think"] = think
}
response, err := t.Post("/api/chat", payload)
if err != nil {
return nil, err
}
if evalCount, ok := response["prompt_eval_count"]; ok {
if n := PositiveInt(evalCount); n > 0 {
count := n
t.LastPromptEvalCount = &count
} else {
t.LastPromptEvalCount = nil
}
} else {
t.LastPromptEvalCount = nil
}
t.UsageCalls++
t.UsagePromptTokens += PositiveInt(response["prompt_eval_count"])
t.UsageOutputTokens += PositiveInt(response["eval_count"])
t.UsageTotalDuration += PositiveInt(response["total_duration"])
message, ok := response["message"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("Ollama response is missing a message object")
}
return message, nil
}
@@ -0,0 +1,98 @@
package main
// context_registry.go — module-level context-length registry with the
// conservative fallback (P0-06 / #19 Task 2 parity port): cached per model
// per process, cloud-pinned models routed to their provider transport,
// failures recorded as observable T1 record_failure events and NEVER cached
// (retry next turn). Cloud pinning, provider transports and failure
// recording sit behind injectable seams.
import (
"fmt"
"sync"
"time"
)
// ContextLengthFallback — conservative window used ONLY when discovery
// fails; the failure is recorded via the T1 seam — never silent.
const ContextLengthFallback = 8192
var (
contextLengths = map[string]int{}
contextLengthMutex sync.Mutex
)
// CloudTransport is the provider transport seam for cloud-pinned models.
type CloudTransport interface {
ContextLength(model string) (int, error)
}
// IsCloudPinned — seam (rune.openai_compat_transport.is_cloud_pinned).
var IsCloudPinned = func(model string) bool { return false }
// CloudTransportFor — seam (rune.openai_compat_transport.cloud_transport_for).
var CloudTransportFor = func(model string) CloudTransport { return nil }
// NewLocalTransport — seam constructing the Ollama transport (tests stub
// this to keep /api/show off the wire).
var NewLocalTransport = func(baseURL string) (CloudTransport, error) {
return NewOllamaTransport(baseURL, 120*time.Second), nil
}
// RecordFailure — seam (rune.subsystem_health.record_failure, T1).
var RecordFailure = func(subsystem string, err error, tier int, context string) {}
// ResetDiscoveredWindows — seam (rune.context_broker.reset_discovered_windows).
var ResetDiscoveredWindows = func() {}
// ContextLength — module-level context-length registry with the
// conservative fallback. Cached per model per process (one /api/show).
// On discovery failure: conservative default 8192 + T1 record_failure —
// the assumption is observable, never silent (P0-06).
func ContextLength(model string, baseURL string) int {
contextLengthMutex.Lock()
cached, has := contextLengths[model]
contextLengthMutex.Unlock()
if has && cached != 0 {
return cached
}
var window int
var err error
if IsCloudPinned(model) {
// Cloud-pinned models must not hit Ollama's /api/show — observed
// live: 400 'invalid model name' + 8192 misbudget for a model with
// a 131072 window. The provider config carries the window.
cloud := CloudTransportFor(model)
if cloud == nil {
err = fmt.Errorf("no cloud transport for %s", model)
} else {
window, err = cloud.ContextLength(model)
}
} else {
local, transportErr := NewLocalTransport(baseURL)
if transportErr != nil {
err = transportErr
} else {
window, err = local.ContextLength(model)
}
}
if err != nil {
RecordFailure("context_discovery", err, 1, model)
return ContextLengthFallback
}
contextLengthMutex.Lock()
contextLengths[model] = window
contextLengthMutex.Unlock()
return window
}
// ResetContextLengthCache — clear the module-level discovery cache (tests
// + re-discovery). Also clears the broker's derived window registry — the
// two caches must not diverge (a model re-pulled under the same tag with a
// different trained window otherwise keeps the stale value).
func ResetContextLengthCache() {
contextLengthMutex.Lock()
contextLengths = map[string]int{}
contextLengthMutex.Unlock()
ResetDiscoveredWindows()
}
+230
View File
@@ -0,0 +1,230 @@
package main
// executor.go — the tool-executor seam and the default escape-hatch
// executor (P1-01 #24). Executing find_capability routes through the
// tool executor; the default wrapper is kept as a package-level function
// so callers can exercise the hatch without constructing the full runtime.
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// ToolExecutor is the dispatch seam. UsesToolAuthority mirrors
// execute_tool._uses_tool_authority (the authority-aware path prepares
// approval-gated operations instead of a blanket needs_confirm).
type ToolExecutor interface {
Dispatch(toolName string, arguments map[string]interface{}) map[string]interface{}
UsesToolAuthority() bool
}
// ExecutorFunc adapts a plain callable (the Python
// Callable[[str, dict], dict] executor shape).
type ExecutorFunc func(toolName string, arguments map[string]interface{}) map[string]interface{}
func (f ExecutorFunc) Dispatch(name string, args map[string]interface{}) map[string]interface{} {
return f(name, args)
}
func (f ExecutorFunc) UsesToolAuthority() bool { return false }
// FindCapabilityExecutor — default escape-hatch executor: route through
// the tool executor. Package-level, like the Python module wrapper.
func FindCapabilityExecutor(toolName string, arguments map[string]interface{}) map[string]interface{} {
return ExecuteTool(toolName, arguments)
}
// findCapabilityExecutorVar is the injectable seam used by the runtime
// constructor (tests override it; the default is FindCapabilityExecutor).
var findCapabilityExecutorVar = ExecutorFunc(FindCapabilityExecutor)
// ExecuteTool — safe public entry point: reads execute; all other tools
// are prepared. Only the read-only error paths are decided locally; real
// side-effecting execution is delegated to the tool-executor binary.
func ExecuteTool(toolName string, args map[string]interface{}) map[string]interface{} {
if toolName == "read_file" {
return readFile(args)
}
if toolName == "list_directory" {
return listDirectory(args)
}
// Non-read-only tools are prepared for approval; execution is
// delegated to the tool-executor binary via the existing seam.
return prepareViaBinary(toolName, args)
}
func prepareViaBinary(toolName string, args map[string]interface{}) map[string]interface{} {
toolPath := os.Getenv("TOOL_EXECUTOR_PATH")
if toolPath == "" {
toolPath = "src/go/tool-executor/tool-executor"
}
payload, _ := jsonMarshal(args)
out, err := execCombinedOutput(toolPath, toolName, string(payload))
if err != nil {
return map[string]interface{}{"success": false, "error": err.Error()}
}
return out
}
// readFile — the deterministic slice of _read_file used by the truth
// battery: path validation errors. Successful reads go through the same
// windowed numbering as the Python executor.
func readFile(args map[string]interface{}) map[string]interface{} {
path, _ := args["path"].(string)
ok, message := validateReadPath(path)
if !ok {
return map[string]interface{}{"success": false, "error": message}
}
content, err := os.ReadFile(path)
if err != nil {
return map[string]interface{}{"success": false, "error": fmt.Sprintf("Read failed: %v", err)}
}
lines := strings.Split(string(content), "\n")
offset := 1.0
if v, ok := args["offset"].(float64); ok {
offset = v
}
limit := 100.0
if v, ok := args["limit"].(float64); ok {
limit = v
}
start := int(offset) - 1
if start < 0 {
start = 0
}
end := len(lines)
if start+int(limit) < end {
end = start + int(limit)
}
var numbered []string
for i, line := range lines[start:end] {
numbered = append(numbered, fmt.Sprintf("%d: %s", i+int(offset), line))
}
result := strings.Join(numbered, "\n")
if end < len(lines) {
result += fmt.Sprintf("\n\n[showing lines %d-%d of %d total]", int(offset), end, len(lines))
}
if start > 0 {
result = fmt.Sprintf("[showing from line %d]\n", int(offset)) + result
}
return map[string]interface{}{"success": true, "result": result}
}
// allowedReadDirs mirrors ALLOWED_READ_DIRS (home, /mnt/workdrive, /tmp).
func allowedReadDirs() []string {
home, err := os.UserHomeDir()
if err != nil {
home = ""
}
return []string{home, "/mnt/workdrive", "/tmp"}
}
// blockedDirs mirrors BLOCKED_DIRS.
func blockedDirs() []string {
home, err := os.UserHomeDir()
if err != nil {
home = ""
}
return []string{filepath.Join(home, ".ssh"), filepath.Join(home, ".gnupg"), "/etc", "/var", "/proc", "/sys"}
}
// resolvePath — Python resolve_path: absolute, symlinks and .. resolved.
func resolvePath(path string) string {
expanded := path
if path == "~" || strings.HasPrefix(path, "~/") {
if home, err := os.UserHomeDir(); err == nil {
expanded = filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(path, "~"), "/"))
}
}
abs, err := filepath.Abs(expanded)
if err != nil {
return expanded
}
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
return resolved
}
// Non-existent leaf: resolve the deepest existing ancestor.
dir, base := filepath.Split(abs)
for dir != "" {
if resolved, err := filepath.EvalSymlinks(filepath.Clean(dir)); err == nil {
return filepath.Join(resolved, base)
}
parent, last := filepath.Split(filepath.Clean(dir))
if parent == dir {
break
}
base = filepath.Join(last, base)
dir = parent
}
return abs
}
func isRelativeOrEqual(path, root string) bool {
rel, err := filepath.Rel(root, path)
if err != nil {
return false
}
return rel == "." || (!strings.HasPrefix(rel, "..") && rel != "")
}
func isPathAllowed(path string, allowedDirs []string) bool {
for _, blocked := range blockedDirs() {
resolved := resolvePath(blocked)
if path == resolved || isRelativeOrEqual(path, resolved) {
return false
}
}
for _, allowed := range allowedDirs {
resolved := resolvePath(allowed)
if path == resolved || isRelativeOrEqual(path, resolved) {
return true
}
}
return false
}
// validateReadPath — Python validate_read_path (ok, message) slice.
func validateReadPath(path string) (bool, string) {
resolved := resolvePath(path)
if !isPathAllowed(resolved, allowedReadDirs()) {
return false, fmt.Sprintf("Access denied: %s is outside allowed directories", path)
}
info, err := os.Stat(resolved)
if err != nil {
return false, fmt.Sprintf("Not found: %s", path)
}
if info.IsDir() {
return false, fmt.Sprintf("Not a file: %s", path)
}
return true, ""
}
// listDirectory — the read-only listing used by the escape-hatch battery
// row (names with / suffix for directories, per the registry description).
func listDirectory(args map[string]interface{}) map[string]interface{} {
path, _ := args["path"].(string)
ok, message := validateReadPath(path)
if !ok {
// directories fail the is-file check — validate like Python's
// _list_directory: exists and is a directory
info, statErr := os.Stat(resolvePath(path))
if statErr != nil || !info.IsDir() {
return map[string]interface{}{"success": false, "error": message}
}
}
entries, err := os.ReadDir(resolvePath(path))
if err != nil {
return map[string]interface{}{"success": false, "error": fmt.Sprintf("Read failed: %v", err)}
}
names := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
names = append(names, entry.Name()+"/")
} else {
names = append(names, entry.Name())
}
}
return map[string]interface{}{"success": true, "result": names, "dir": path}
}
+23
View File
@@ -0,0 +1,23 @@
package main
import (
"os"
"os/exec"
)
// jsonMarshal / execCombinedOutput — small IO seams kept in one place so
// the truth battery can substitute them.
func jsonMarshal(value interface{}) ([]byte, error) {
return jsonMarshalCompact(value)
}
func execCombinedOutput(toolPath, toolName, payload string) (map[string]interface{}, error) {
command := exec.Command(toolPath, toolName, payload)
command.Env = os.Environ()
out, err := command.CombinedOutput()
if err != nil {
return nil, err
}
return jsonDecodeObject(out)
}
+87
View File
@@ -0,0 +1,87 @@
package main
// helpers.go — small shared utilities for the loop port.
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"unicode"
)
// jsonMarshalCompact — encoder with HTML escaping off (Python-compatible
// JSON text).
func jsonMarshalCompact(value interface{}) ([]byte, error) {
var buf bytes.Buffer
encoder := json.NewEncoder(&buf)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(value); err != nil {
return nil, err
}
out := buf.Bytes()
return out[:len(out)-1], nil
}
// jsonDecodeObject — decode a JSON object body.
func jsonDecodeObject(raw []byte) (map[string]interface{}, error) {
var decoded map[string]interface{}
if err := json.Unmarshal(raw, &decoded); err != nil {
return nil, err
}
return decoded, nil
}
// deepCopyMessages — Python copy.deepcopy over the conversation snapshot.
func deepCopyMessages(messages []map[string]interface{}) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(messages))
for _, message := range messages {
out = append(out, DeepCopy(message).(map[string]interface{}))
}
return out
}
// deepCopyMessagesForState — same, but tolerates non-object messages
// (persisted states are JSON anyway).
func deepCopyMessagesForState(messages []map[string]interface{}) []interface{} {
out := make([]interface{}, 0, len(messages))
for _, message := range messages {
out = append(out, DeepCopy(message))
}
return out
}
// toAnySlice — []string → []interface{}.
func toAnySlice(values []string) []interface{} {
out := make([]interface{}, 0, len(values))
for _, value := range values {
out = append(out, value)
}
return out
}
// trimSpace — Python str.strip() semantics (all unicode whitespace).
func trimSpace(s string) string {
return strings.TrimFunc(s, unicode.IsSpace)
}
// NewCallID — the _new_call_id seam (Python: uuid4().hex; the Go side
// uses crypto/rand hex — the fallback only fires when a provider omits
// call IDs, and frozen rows always carry explicit ids).
func NewCallID() string {
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
return fmt.Sprintf("call-%d", time.Now().UnixNano())
}
return "call_" + hex.EncodeToString(raw)
}
// bytesReader — test helper.
func bytesReader(b []byte) *bytes.Reader {
return bytes.NewReader(b)
}
func newReaderZZ(b []byte) *bytes.Reader { return bytes.NewReader(b) }
+6 -6
View File
@@ -24,7 +24,7 @@ type ChatMessage struct {
Content string `json:"content"`
}
type OperatorResponse struct {
type CLIResponse struct {
Success bool `json:"success"`
Response string `json:"response,omitempty"`
Error string `json:"error,omitempty"`
@@ -128,7 +128,7 @@ func (rt *ToolRuntime) executeTool(toolName string, content string) string {
}
// Run: the bounded tool loop
func (rt *ToolRuntime) Run(messages []ChatMessage) OperatorResponse {
func (rt *ToolRuntime) Run(messages []ChatMessage) CLIResponse {
start := time.Now()
conversation := make([]ChatMessage, len(messages))
copy(conversation, messages)
@@ -140,7 +140,7 @@ func (rt *ToolRuntime) Run(messages []ChatMessage) OperatorResponse {
for i := 0; i < rt.MaxIterations; i++ {
resp, err := rt.Chat(conversation)
if err != nil {
return OperatorResponse{
return CLIResponse{
Success: false, Error: err.Error(),
Model: rt.Model,
Duration: time.Since(start).Round(time.Millisecond).String(),
@@ -182,7 +182,7 @@ func (rt *ToolRuntime) Run(messages []ChatMessage) OperatorResponse {
// Circuit breaker: check for consecutive identical failures
if warning, tripped := cb.RegisterFailure(call.Operation, call.Args); tripped {
return OperatorResponse{
return CLIResponse{
Success: false,
Error: warning,
Model: rt.Model,
@@ -204,7 +204,7 @@ func (rt *ToolRuntime) Run(messages []ChatMessage) OperatorResponse {
// No tool call — this is the final response
cb.RegisterSuccess()
return OperatorResponse{
return CLIResponse{
Success: true, Response: content, Model: rt.Model,
ToolCalls: toolCalls,
Duration: time.Since(start).Round(time.Millisecond).String(),
@@ -212,7 +212,7 @@ func (rt *ToolRuntime) Run(messages []ChatMessage) OperatorResponse {
}
}
return OperatorResponse{
return CLIResponse{
Success: false, Error: "max iterations exceeded",
Model: rt.Model, ToolCalls: toolCalls,
Duration: time.Since(start).Round(time.Millisecond).String(),
+478
View File
@@ -0,0 +1,478 @@
package main
// protocol_core.go — the tool_protocol.py validation core the local
// tool loop depends on (name-preserving port): ValidateToolRequest with
// alias canonicalization BEFORE schema checks, NormalizeOllamaToolCalls,
// NormalizeTextToolCalls (balanced-decoder spans), ExtractVisibleText,
// ContainsInternalProtocol, ToolRequiresApproval and
// DispatchValidatedTool. Exact Python error strings.
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
var toolAliases = map[string]string{
"grep": "grep_files",
"bash": "run_command",
}
// ValidateToolRequest validates a request against the frozen registry
// schema (tools_registry.json). Alias canonicalization happens before the
// schema check so bash never bypasses run_command's approval boundary.
func ValidateToolRequest(request ToolRequest) (ToolRequest, error) {
if alias, ok := toolAliases[request.Name]; ok {
request.Name = alias
}
definition, ok := RegistryTools[request.Name]
if !ok {
return request, fmt.Errorf("unknown tool: %s", request.Name)
}
if request.Arguments == nil {
request.Arguments = map[string]interface{}{}
}
schema, _ := definition["parameters"].(map[string]interface{})
properties, _ := schema["properties"].(map[string]interface{})
required, _ := schema["required"].([]interface{})
for _, r := range required {
name, _ := r.(string)
if _, has := request.Arguments[name]; !has {
return request, fmt.Errorf("required argument missing: %s", name)
}
}
for name, value := range request.Arguments {
propertySchema, ok := properties[name].(map[string]interface{})
if !ok {
return request, fmt.Errorf("unknown argument: %s", name)
}
expected, _ := propertySchema["type"].(string)
if expected != "" && !typeMatches(value, expected) {
return request, fmt.Errorf("argument %s must be %s", name, expected)
}
if expected == "array" {
if list, isList := value.([]interface{}); isList {
if items, has := propertySchema["items"].(map[string]interface{}); has {
itemType, _ := items["type"].(string)
if itemType != "" {
for _, item := range list {
if !typeMatches(item, itemType) {
return request, fmt.Errorf("argument %s items must be %s", name, itemType)
}
}
}
}
}
}
}
return request, nil
}
// typeMatches — Python _type_matches semantics over JSON-decoded values.
func typeMatches(value interface{}, expected string) bool {
switch expected {
case "string":
_, ok := value.(string)
return ok
case "integer":
f, ok := value.(float64)
return ok && f == float64(int64(f))
case "number":
_, ok := value.(float64)
return ok
case "boolean":
_, ok := value.(bool)
return ok
case "array":
_, ok := value.([]interface{})
return ok
case "object":
_, ok := value.(map[string]interface{})
return ok
default:
return true
}
}
// NormalizeOllamaToolCalls — native envelope + string-arguments decode.
func NormalizeOllamaToolCalls(message map[string]interface{}) ([]ToolRequest, error) {
callsAny, _ := message["tool_calls"].([]interface{})
var requests []ToolRequest
for _, raw := range callsAny {
call, ok := raw.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("malformed native tool call")
}
fn, ok := call["function"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("malformed native tool call")
}
args := fn["arguments"]
var arguments map[string]interface{}
switch a := args.(type) {
case string:
if err := json.Unmarshal([]byte(a), &arguments); err != nil {
return nil, fmt.Errorf("malformed tool arguments JSON")
}
case map[string]interface{}:
arguments = a
default:
arguments = map[string]interface{}{}
}
name, _ := fn["name"].(string)
callID, _ := call["id"].(string)
callID = strings.TrimSpace(callID)
if callID == "" {
callID = NewCallID()
}
request := ToolRequest{CallID: callID, Name: name, Arguments: arguments, Source: "ollama-native"}
validated, err := ValidateToolRequest(request)
if err != nil {
return nil, err
}
requests = append(requests, validated)
}
if requests == nil {
requests = []ToolRequest{}
}
return requests, nil
}
// requestFromEnvelope — envelope → validated request (nil when not a call).
func requestFromEnvelope(envelope interface{}, source string, callID interface{}) (ToolRequest, bool, error) {
env, ok := envelope.(map[string]interface{})
if !ok {
return ToolRequest{}, false, nil
}
if _, has := env["tool"]; !has {
return ToolRequest{}, false, nil
}
name, isStr := env["tool"].(string)
if !isStr {
return ToolRequest{}, false, fmt.Errorf("tool name must be a string")
}
arguments, present := env["args"]
argsMap, ok := arguments.(map[string]interface{})
if !present {
argsMap = map[string]interface{}{}
} else if !ok {
return ToolRequest{}, false, fmt.Errorf("tool arguments must be an object")
}
id := fmt.Sprintf("%v", callID)
id = strings.TrimSpace(id)
if callID == nil || id == "" {
id = NewCallID()
}
request := ToolRequest{CallID: id, Name: name, Arguments: argsMap, Source: source}
validated, err := ValidateToolRequest(request)
if err != nil {
return ToolRequest{}, false, err
}
return validated, true, nil
}
// ToolRequiresApproval — fail closed unless a validated tool is read-only.
func ToolRequiresApproval(request ToolRequest) (bool, error) {
validated, err := ValidateToolRequest(request)
if err != nil {
return false, err
}
return !readOnlyToolNames[validated.Name], nil
}
// DispatchValidatedTool — validate before invoking, so invalid calls
// reach no handler.
func DispatchValidatedTool(request ToolRequest, executor ToolExecutor) map[string]interface{} {
validated, err := ValidateToolRequest(request)
if err != nil {
panic(err)
}
return executor.Dispatch(validated.Name, validated.Arguments)
}
// ToOllamaTools — convert Rune tool definitions to Ollama's native shape.
func ToOllamaTools(definitions []map[string]interface{}) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(definitions))
for _, item := range definitions {
name, _ := item["name"].(string)
description, _ := item["description"].(string)
parameters, _ := item["parameters"].(map[string]interface{})
out = append(out, map[string]interface{}{
"type": "function",
"function": map[string]interface{}{
"name": name,
"description": description,
"parameters": parameters,
},
})
}
return out
}
// ── text-fallback spans (tool_protocol.py _decoded_objects/_protocol_entries) ──
type decodedObject struct {
value interface{}
start int
end int
}
var fenceRe = regexp.MustCompile("(?is)```(?:json)?\\s*.*?```")
var xmlRe = regexp.MustCompile("(?is)<tool_call>.*?</tool_call>")
var openingFenceRe = regexp.MustCompile("(?i)```(?:json)?\\s*")
// decodedObjects yields JSON values and exact spans using balanced
// json.Decoder parsing — Python json.JSONDecoder.raw_decode semantics.
func decodedObjects(text string) []decodedObject {
var out []decodedObject
position := 0
for position < len(text) {
brace := strings.Index(text[position:], "{")
bracket := strings.Index(text[position:], "[")
start := -1
switch {
case brace >= 0 && bracket >= 0:
if brace < bracket {
start = position + brace
} else {
start = position + bracket
}
case brace >= 0:
start = position + brace
case bracket >= 0:
start = position + bracket
default:
return out
}
dec := json.NewDecoder(strings.NewReader(text[start:]))
var value interface{}
if err := dec.Decode(&value); err != nil {
position = start + 1
continue
}
end := start + int(dec.InputOffset())
out = append(out, decodedObject{value: value, start: start, end: end})
position = end
}
return out
}
type protocolEntry struct {
request ToolRequest
start int
end int
}
// protocolEntries — parse and validate compatibility tool calls, folding
// fence/xml wrappers into the span when the call is the whole content.
func protocolEntries(text string) ([]protocolEntry, error) {
var entries []protocolEntry
for _, obj := range decodedObjects(text) {
if list, isList := obj.value.([]interface{}); isList {
hasTool := false
for _, item := range list {
if m, ok := item.(map[string]interface{}); ok {
if _, has := m["tool"]; has {
hasTool = true
break
}
}
}
if hasTool {
return nil, fmt.Errorf("array is not a valid tool call envelope")
}
}
request, ok, err := requestFromEnvelope(obj.value, "text-fallback", nil)
if err != nil {
return nil, err
}
if !ok {
continue
}
spanStart, spanEnd := obj.start, obj.end
for _, pattern := range []*regexp.Regexp{fenceRe, xmlRe} {
var wrapper []int
for _, loc := range pattern.FindAllStringIndex(text, -1) {
if loc[0] <= obj.start && obj.end <= loc[1] {
wrapper = loc
break
}
}
if wrapper != nil {
var contentStart, contentEnd int
if pattern == fenceRe {
opening := openingFenceRe.FindString(text[wrapper[0]:wrapper[1]])
contentStart = wrapper[0] + len(opening)
contentEnd = wrapper[1] - len("```")
} else {
contentStart = wrapper[0] + len("<tool_call>")
contentEnd = wrapper[1] - len("</tool_call>")
}
remainder := text[contentStart:obj.start] + text[obj.end:contentEnd]
if strings.TrimSpace(remainder) == "" {
spanStart, spanEnd = wrapper[0], wrapper[1]
}
break
}
}
entries = append(entries, protocolEntry{request: request, start: spanStart, end: spanEnd})
}
remainder := text
for i := len(entries) - 1; i >= 0; i-- {
e := entries[i]
remainder = remainder[:e.start] + remainder[e.end:]
}
if strings.Contains(strings.ToLower(remainder), "<tool_call>") {
return nil, fmt.Errorf("malformed tool call protocol")
}
if containsToolObjectKey(remainder) {
return nil, fmt.Errorf("malformed tool call protocol")
}
return entries, nil
}
// NormalizeTextToolCalls — ordered compatibility calls from model text.
func NormalizeTextToolCalls(text string) ([]ToolRequest, error) {
entries, err := protocolEntries(text)
if err != nil {
return nil, err
}
requests := make([]ToolRequest, 0, len(entries))
for _, e := range entries {
requests = append(requests, e.request)
}
return requests, nil
}
// ExtractVisibleText — remove recognized protocol spans while preserving
// ordinary JSON.
func ExtractVisibleText(text string) (string, error) {
entries, err := protocolEntries(text)
if err != nil {
return "", err
}
spanSet := map[[2]int]bool{}
for _, e := range entries {
spanSet[[2]int{e.start, e.end}] = true
}
spans := make([][2]int, 0, len(spanSet))
for s := range spanSet {
spans = append(spans, s)
}
sortSpans(spans)
visible := text
for i := len(spans) - 1; i >= 0; i-- {
start, end := spans[i][0], spans[i][1]
lineStart := strings.LastIndex(visible[:start], "\n") + 1
nextNewline := strings.Index(visible[end:], "\n")
lineEnd := len(visible)
if nextNewline >= 0 {
lineEnd = end + nextNewline
}
if strings.TrimSpace(visible[lineStart:start]) == "" && strings.TrimSpace(visible[end:lineEnd]) == "" {
start = lineStart
if nextNewline >= 0 {
end = lineEnd + 1
} else if lineStart > 0 {
start = lineStart - 1
}
}
visible = visible[:start] + visible[end:]
}
if ContainsInternalProtocol(visible) {
return "", fmt.Errorf("malformed tool call protocol remains in visible text")
}
return visible, nil
}
func sortSpans(spans [][2]int) {
for i := 1; i < len(spans); i++ {
for j := i; j > 0 && spans[j][0] < spans[j-1][0]; j-- {
spans[j], spans[j-1] = spans[j-1], spans[j]
}
}
}
var internalResultRe = regexp.MustCompile(`(?im)^[ \t]*\[tool (?:result|error)\]`)
// ContainsInternalProtocol — result markers, xml spans, or tool-object keys.
func ContainsInternalProtocol(text string) bool {
if internalResultRe.MatchString(text) {
return true
}
if strings.Contains(strings.ToLower(text), "<tool_call>") {
return true
}
for _, obj := range decodedObjects(text) {
if m, ok := obj.value.(map[string]interface{}); ok {
if _, has := m["tool"]; has {
return true
}
continue
}
if list, ok := obj.value.([]interface{}); ok {
for _, item := range list {
if m, isMap := item.(map[string]interface{}); isMap {
if _, has := m["tool"]; has {
return true
}
}
}
}
}
return containsToolObjectKey(text)
}
// containsToolObjectKey — literal or escaped `tool` key outside strings.
func containsToolObjectKey(text string) bool {
inString := false
escaped := false
for index, character := range text {
if inString {
if escaped {
escaped = false
} else if character == '\\' {
escaped = true
} else if character == '"' {
inString = false
}
continue
}
if character == '"' {
inString = true
continue
}
if character != '{' && character != ',' {
continue
}
rest := text[index+1:]
key, length, err := rawDecodeString(rest)
if err != nil {
continue
}
colon := 1 + length
for colon < len(rest) && (rest[colon] == ' ' || rest[colon] == '\t' || rest[colon] == '\n' || rest[colon] == '\r') {
colon++
}
if key == "tool" && colon < len(rest) && rest[colon] == ':' {
return true
}
}
return false
}
// rawDecodeString decodes one JSON string at the start of text, returning
// the decoded value and its byte length.
func rawDecodeString(text string) (string, int, error) {
dec := json.NewDecoder(strings.NewReader(text))
var value interface{}
if err := dec.Decode(&value); err != nil {
return "", 0, err
}
s, ok := value.(string)
if !ok {
return "", 0, fmt.Errorf("not a string")
}
return s, int(dec.InputOffset()), nil
}
+161
View File
@@ -0,0 +1,161 @@
package main
// registry.go — the frozen tool registry (tools_registry.json, the
// parity-pinned authority shared with tool-executor and tool-protocol).
// ExposeTools (P1-01 #24) resolves dynamically exposed tool names against
// this index — a name without a schema here is never invented.
import (
"bytes"
_ "embed"
"encoding/json"
)
//go:embed tools_registry.json
var registryJSON []byte
// RegistryTools — name → full definition (schemas included), set-equal to
// the Python TOOL_INDEX.
var RegistryTools = parseRegistryTools()
func parseRegistryTools() map[string]map[string]interface{} {
var tools []map[string]interface{}
if err := json.Unmarshal(registryJSON, &tools); err != nil {
panic("tools_registry.json: " + err.Error())
}
out := make(map[string]map[string]interface{}, len(tools))
for _, t := range tools {
name, _ := t["name"].(string)
out[name] = t
}
return out
}
// ToolRequest — canonical model-requested tool call (tool_protocol.py).
type ToolRequest struct {
CallID string `json:"call_id"`
Name string `json:"name"`
Arguments map[string]interface{} `json:"arguments"`
Source string `json:"source"`
}
// ToolEvent — one observable runtime event in the loop.
type ToolEvent struct {
Kind string `json:"kind"`
Request ToolRequest `json:"request"`
Result map[string]interface{} `json:"result"`
}
// readOnlyToolNames — the READ_ONLY_TOOLS frozenset from tool_executor.py.
// toolRequiresApproval fails closed unless a validated tool is in here.
var readOnlyToolNames = map[string]bool{
"read_file": true,
"glob_files": true,
"grep_files": true,
"list_directory": true,
"git_diff": true,
"git_log": true,
"repo_map": true,
"read_persona": true,
"query_memory": true,
"check_growth": true,
"diagnose_behavior": true,
"find_capability": true,
}
// RegistryOrder — the registry file's tool order (Python TOOLS list
// order). Go maps randomize iteration; the loop's native tool payload
// must match the oracle's definition order exactly.
var RegistryOrder = parseRegistryOrder()
func parseRegistryOrder() []string {
var tools []map[string]interface{}
if err := json.Unmarshal(registryJSON, &tools); err != nil {
panic("tools_registry.json: " + err.Error())
}
out := make([]string, 0, len(tools))
for _, t := range tools {
name, _ := t["name"].(string)
out = append(out, name)
}
return out
}
// RegistryParamOrder — tool name → property names in definition order
// (build_tool_prompt iterates Python dict insertion order; Go maps
// randomize, so the order is recovered from the raw registry JSON).
var RegistryParamOrder = parseRegistryParamOrder()
func parseRegistryParamOrder() map[string][]string {
var tools []json.RawMessage
if err := json.Unmarshal(registryJSON, &tools); err != nil {
panic("tools_registry.json: " + err.Error())
}
out := map[string][]string{}
for _, rawTool := range tools {
var def map[string]interface{}
if err := json.Unmarshal(rawTool, &def); err != nil {
panic("tools_registry.json: " + err.Error())
}
name, _ := def["name"].(string)
out[name] = toolParamOrder(rawTool)
}
return out
}
// toolParamOrder — walk the raw definition with a json token stream and
// collect the keys of parameters.properties in file order.
func toolParamOrder(rawTool []byte) []string {
dec := json.NewDecoder(bytes.NewReader(rawTool))
var names []string
var expectKey []bool
var expectValue []bool
inPropsDepth := -1
depth := -1
flipToKey := func() {
expectKey[depth] = true
expectValue[depth] = false
}
for {
tok, err := dec.Token()
if err != nil {
break
}
switch t := tok.(type) {
case json.Delim:
switch t {
case '{', '[':
depth++
expectKey = append(expectKey, t == '{')
expectValue = append(expectValue, false)
case '}', ']':
if depth == inPropsDepth {
inPropsDepth = -1
}
depth--
// stack semantics: truncate the popped frame so the next
// push at this depth reads a fresh entry, not a stale one
expectKey = expectKey[:depth+1]
expectValue = expectValue[:depth+1]
if depth >= 0 {
flipToKey()
}
}
case string:
if expectKey[depth] {
expectKey[depth] = false
expectValue[depth] = true
if depth == inPropsDepth {
names = append(names, t)
} else if t == "properties" && depth == 1 {
inPropsDepth = depth + 1
}
} else {
flipToKey()
}
default:
flipToKey()
}
}
return names
}
+692
View File
@@ -0,0 +1,692 @@
package main
// runtime.go — the bounded model → tools → model loop, name-preserving
// 1:1 port of src/rune/local_tool_runtime.py LocalToolRuntime (run,
// resume, approve, reject, _run_loop, _expose_tools, _with_fallback_prompt,
// _failure) including the P0-05 circuit breaker, the P1-01 find_capability
// escape hatch and the fail-closed approval boundary. Transport, executor
// and registry lookups are injectable seams.
import (
"fmt"
"sort"
)
// FindCapabilityTool — the capability escape hatch (P1-01 #24).
const FindCapabilityTool = "find_capability"
// LoopTransport is the injectable transport boundary.
type LoopTransport interface {
SupportsTools(model string) (bool, error)
SupportsThinking(model string) (bool, error)
Chat(model string, messages []map[string]interface{}, tools []map[string]interface{}, options map[string]interface{}, think interface{}) (map[string]interface{}, error)
}
// OperatorResponse — the loop's result envelope (tool_protocol.OperatorResponse).
type OperatorResponse struct {
Success bool `json:"success"`
UserText string `json:"user_text"`
Thinking []string `json:"thinking"`
ToolEvents []ToolEvent `json:"tool_events"`
PendingApproval *ToolRequest `json:"pending_approval"`
PendingState map[string]interface{} `json:"pending_state"`
Model string `json:"model"`
RecallCount int `json:"recall_count"`
OverrideTier int `json:"override_tier"`
Error string `json:"error"`
IterationsUsed int `json:"iterations_used"`
IterationBudget int `json:"iteration_budget"`
Degraded []string `json:"degraded"`
RuntimeObjects []interface{} `json:"runtime_objects"`
}
// LocalRuntime — run a bounded model → tools → model loop without
// mutating caller state.
type LocalRuntime struct {
Transport LoopTransport
Executor ToolExecutor
Model string
Tools []map[string]interface{}
ToolNames map[string]bool
MaxIterations int
FindCapabilityExecutor ToolExecutor
FindCapabilityCalls int
// ExposeTools seam: resolves a name to its registry schema. Defaults
// to RegistryTools (the frozen authority); tests may narrow it.
ToolIndex func(name string) (map[string]interface{}, bool)
}
// NewLocalRuntime constructs the loop with the full registry allowlist
// (Python: tools=None → TOOLS) and validates the iteration budget.
func NewLocalRuntime(transport LoopTransport, executor ToolExecutor, model string, tools []map[string]interface{}, maxIterations int) (*LocalRuntime, error) {
if maxIterations < 1 || maxIterations > 8 {
return nil, fmt.Errorf("max_iterations must be between 1 and 8")
}
names := map[string]bool{}
var defs []map[string]interface{}
if tools == nil {
// registry file order — Go maps randomize; the oracle's native
// tool payload follows the Python TOOLS definition order.
for _, name := range RegistryOrder {
defs = append(defs, RegistryTools[name])
names[name] = true
}
} else {
defs = tools
for _, definition := range defs {
if name, ok := definition["name"].(string); ok {
names[name] = true
}
}
}
return &LocalRuntime{
Transport: transport,
Executor: executor,
Model: model,
Tools: defs,
ToolNames: names,
MaxIterations: maxIterations,
FindCapabilityExecutor: findCapabilityExecutorVar,
}, nil
}
func (rt *LocalRuntime) indexLookup(name string) (map[string]interface{}, bool) {
if rt.ToolIndex != nil {
return rt.ToolIndex(name)
}
def, ok := RegistryTools[name]
return def, ok
}
// Run — start a tool loop from a serializable conversation snapshot.
func (rt *LocalRuntime) Run(messages []map[string]interface{}, options map[string]interface{}, think interface{}) *OperatorResponse {
conversation := deepCopyMessages(messages)
var nativeTools bool
var effectiveThink interface{} = think
nativeTools, err := rt.Transport.SupportsTools(rt.Model)
if err == nil {
supportsThinking, thinkErr := rt.Transport.SupportsThinking(rt.Model)
if thinkErr != nil {
err = thinkErr
} else if !supportsThinking {
effectiveThink = nil
}
}
if err != nil {
return rt.failure(fmt.Sprintf("transport error: %v", err), nil, nil, 0)
}
if !nativeTools {
conversation = rt.WithFallbackPrompt(conversation)
}
return rt.runLoop(conversation, nativeTools, options, effectiveThink, 0, nil, nil, nil)
}
// Resume — resume immutable serialized state after an external approval
// decision.
func (rt *LocalRuntime) Resume(pendingState map[string]interface{}, toolResult map[string]interface{}) *OperatorResponse {
rawMessages, ok := pendingState["messages"].([]interface{})
if !ok {
return rt.failure("invalid pending state: pending messages must be a list", nil, nil, 0)
}
messages := make([]map[string]interface{}, 0, len(rawMessages))
for _, item := range rawMessages {
message, ok := item.(map[string]interface{})
if !ok {
return rt.failure("invalid pending state: pending messages must be a list", nil, nil, 0)
}
messages = append(messages, message)
}
nativeTools, ok := pendingState["native_tools"].(bool)
if !ok {
return rt.failure("invalid pending state: pending native tools must be a boolean", nil, nil, 0)
}
var options map[string]interface{}
if rawOptions, has := pendingState["options"]; has && rawOptions != nil {
opts, ok := rawOptions.(map[string]interface{})
if !ok {
return rt.failure("invalid pending state: pending options must be an object", nil, nil, 0)
}
options = opts
}
var think interface{}
if rawThink, has := pendingState["think"]; has && rawThink != nil {
switch v := rawThink.(type) {
case bool:
think = v
case string:
think = v
default:
return rt.failure("invalid pending state: pending think must be a boolean or string", nil, nil, 0)
}
}
pendingRequest, err := RequestFromState(pendingState["pending_request"])
if err != nil {
return rt.failure(fmt.Sprintf("invalid pending state: %v", err), nil, nil, 0)
}
// P1-01 #24 quality discharge: honor the embedded exposed set.
rawExposed, hasExposed := pendingState["exposed_tools"]
if !hasExposed {
rawExposed = []interface{}{}
}
exposedList, isList := rawExposed.([]interface{})
if !isList {
return rt.failure("invalid pending state: pending exposed tools must be a list of strings", nil, nil, 0)
}
exposed := make([]string, 0, len(exposedList))
for _, item := range exposedList {
name, ok := item.(string)
if !ok {
return rt.failure("invalid pending state: pending exposed tools must be a list of strings", nil, nil, 0)
}
exposed = append(exposed, name)
}
rt.ExposeTools(exposed)
if !rt.ToolNames[pendingRequest.Name] {
return rt.failure(fmt.Sprintf("invalid pending state: tool not available in this runtime: %s", pendingRequest.Name), nil, nil, 0)
}
rawEventsAny, hasEvents := pendingState["tool_events"]
if !hasEvents {
rawEventsAny = []interface{}{}
}
rawEvents, isList := rawEventsAny.([]interface{})
if !isList {
return rt.failure("invalid pending state: pending tool events must be a list", nil, nil, 0)
}
events := make([]ToolEvent, 0, len(rawEvents))
for _, item := range rawEvents {
event, err := EventFromState(item)
if err != nil {
return rt.failure(fmt.Sprintf("invalid pending state: %v", err), nil, nil, 0)
}
events = append(events, event)
}
eventKind := "error"
if success, ok := toolResult["success"].(bool); ok && success {
eventKind = "completed"
}
events = append(events, ToolEvent{Kind: eventKind, Request: pendingRequest, Result: DeepCopy(toolResult).(map[string]interface{})})
rawThinking, _ := pendingState["thinking"].([]interface{})
thinking := make([]string, 0, len(rawThinking))
for _, item := range rawThinking {
s, ok := item.(string)
if !ok {
return rt.failure("invalid pending state: pending thinking must be a list of strings", nil, nil, 0)
}
thinking = append(thinking, s)
}
messages = append(messages, ToolMessage(pendingRequest, toolResult))
rawRemainingAny, hasRemaining := pendingState["remaining_requests"]
if !hasRemaining {
rawRemainingAny = []interface{}{}
}
rawRemaining, isList := rawRemainingAny.([]interface{})
if !isList {
return rt.failure("invalid pending state: pending remaining requests must be a list", nil, nil, 0)
}
remaining := make([]ToolRequest, 0, len(rawRemaining))
for _, item := range rawRemaining {
request, err := RequestFromState(item)
if err != nil {
return rt.failure(fmt.Sprintf("invalid pending state: %v", err), nil, nil, 0)
}
remaining = append(remaining, request)
}
rawIterations, ok := pendingState["iterations_used"]
if !ok {
rawIterations = float64(0)
}
iterationsUsed, isInt := rawIterations.(float64)
if !isInt || iterationsUsed != float64(int(iterationsUsed)) {
return rt.failure("invalid pending state: pending iterations used must be an integer", nil, nil, 0)
}
if iterationsUsed < 0 || int(iterationsUsed) > rt.MaxIterations {
return rt.failure("invalid pending state: pending iterations used is out of range", nil, nil, 0)
}
return rt.runLoop(messages, nativeTools, options, think, int(iterationsUsed), remaining, events, thinking)
}
// Approve — approve the prepared operation, then resume the model loop.
func (rt *LocalRuntime) Approve(pendingState map[string]interface{}) *OperatorResponse {
raw, ok := pendingState["prepared_tool"].(map[string]interface{})
if !ok {
return rt.failure("invalid pending state: prepared tool must be an object", nil, nil, 0)
}
result := approvePreparedTool(raw)
if success, _ := result["success"].(bool); !success {
message, _ := result["error"].(string)
if message == "" {
message = "approval failed"
}
return rt.failure(message, nil, nil, 0)
}
return rt.Resume(pendingState, result)
}
// Reject — reject the prepared operation without changing target bytes.
func (rt *LocalRuntime) Reject(pendingState map[string]interface{}) *OperatorResponse {
raw, ok := pendingState["prepared_tool"].(map[string]interface{})
if !ok {
return rt.failure("invalid pending state: prepared tool must be an object", nil, nil, 0)
}
return rt.Resume(pendingState, rejectPreparedTool(raw))
}
// approvePreparedTool / rejectPreparedTool mirror approve_tool/reject_tool:
// single-use tokens, digests and expiry are verified by the tool-executor
// side; the loop only consumes the decision envelope here.
func approvePreparedTool(prepared map[string]interface{}) map[string]interface{} {
if consumed, ok := prepared["_consumed"].(bool); ok && consumed {
return map[string]interface{}{"success": false, "error": "Approval already resolved"}
}
prepared["_consumed"] = true
name, _ := prepared["name"].(string)
arguments, _ := prepared["arguments"].(map[string]interface{})
return ExecuteTool(name, arguments)
}
func rejectPreparedTool(prepared map[string]interface{}) map[string]interface{} {
if _, ok := prepared["_consumed"].(bool); ok {
return map[string]interface{}{"success": false, "error": "Approval already resolved"}
}
prepared["_consumed"] = true
return map[string]interface{}{"success": false, "error": "Operator rejected"}
}
// runLoop — the bounded loop (Python _run_loop).
func (rt *LocalRuntime) runLoop(
conversation []map[string]interface{},
nativeTools bool,
options map[string]interface{},
think interface{},
iterationsUsed int,
queuedRequests []ToolRequest,
accumulatedEvents []ToolEvent,
accumulatedThinking []string,
) *OperatorResponse {
events := append([]ToolEvent{}, accumulatedEvents...)
thinking := append([]string{}, accumulatedThinking...)
var nativeDefinitions []map[string]interface{}
if nativeTools {
nativeDefinitions = ToOllamaTools(rt.Tools)
}
requests := append([]ToolRequest{}, queuedRequests...)
// P0-05 (#21) P4: consecutive-identical-failure circuit breaker.
lastFailureSignature := ""
consecutiveFailures := 0
registerSuccess := func() {
lastFailureSignature = ""
consecutiveFailures = 0
}
registerFailure := func(request ToolRequest, result map[string]interface{}) string {
signature := request.Name + ":" + CanonicalJSON(request.Arguments)
if signature == lastFailureSignature {
consecutiveFailures++
} else {
consecutiveFailures = 1
lastFailureSignature = signature
}
if consecutiveFailures >= 2 {
return fmt.Sprintf("tool %s failed %d consecutive times with identical arguments — stopping before the iteration budget burns", request.Name, consecutiveFailures)
}
return ""
}
for {
if len(requests) > 0 {
for index, request := range requests {
if !rt.ToolNames[request.Name] {
// P1-01 (#24): outside the allowlist — one-step
// recovery guidance naming the escape hatch.
return rt.failure(
fmt.Sprintf("tool not available in this runtime: %s. If you need a capability that is not exposed, call %s with a description of what you need to do.", request.Name, FindCapabilityTool),
events, thinking, iterationsUsed)
}
requiresApproval, err := ToolRequiresApproval(request)
if err != nil {
return rt.failure(err.Error(), events, thinking, iterationsUsed)
}
var result map[string]interface{}
if requiresApproval {
if rt.Executor.UsesToolAuthority() {
dispatched := DispatchValidatedTool(request, rt.Executor)
if pending, has := dispatched["pending"].(map[string]interface{}); has {
stripped := map[string]interface{}{}
for key, value := range dispatched {
if key != "pending" {
stripped[key] = DeepCopy(value)
}
}
result = stripped
state := rt.pendingState(conversation, nativeTools, options, think, iterationsUsed, request, events, thinking, requests[index+1:])
state["prepared_tool"] = DeepCopy(pending).(map[string]interface{})
return &OperatorResponse{
Success: true,
Thinking: thinking,
ToolEvents: events,
PendingApproval: &request,
PendingState: state,
Model: rt.Model,
IterationsUsed: iterationsUsed,
IterationBudget: rt.MaxIterations,
}
}
if needsConfirm, _ := dispatched["needs_confirm"].(bool); !needsConfirm {
kind := "error"
if success, _ := dispatched["success"].(bool); success {
kind = "completed"
}
events = append(events, ToolEvent{Kind: kind, Request: request, Result: DeepCopy(dispatched).(map[string]interface{})})
conversation = append(conversation, ToolMessage(request, dispatched))
if success, _ := dispatched["success"].(bool); success {
registerSuccess()
} else {
if trip := registerFailure(request, dispatched); trip != "" {
return rt.failure(trip, events, thinking, iterationsUsed)
}
}
continue
}
result = dispatched
} else {
result = map[string]interface{}{
"success": true,
"needs_confirm": true,
"result": "Operator approval required before execution.",
}
}
events = append(events, ToolEvent{Kind: "pending_approval", Request: request, Result: DeepCopy(result).(map[string]interface{})})
state := rt.pendingState(conversation, nativeTools, options, think, iterationsUsed, request, events, thinking, requests[index+1:])
return &OperatorResponse{
Success: true,
Thinking: thinking,
ToolEvents: events,
PendingApproval: &request,
PendingState: state,
Model: rt.Model,
IterationsUsed: iterationsUsed,
IterationBudget: rt.MaxIterations,
}
}
// Read-only dispatch — find_capability rides its dedicated
// escape-hatch executor and may expose tools in-run.
func() {
defer func() {
if recovered := recover(); recovered != nil {
result = map[string]interface{}{"success": false, "error": fmt.Sprintf("executor error: %v", recovered)}
}
}()
if request.Name == FindCapabilityTool {
result = DispatchValidatedTool(request, rt.FindCapabilityExecutor)
if success, _ := result["success"].(bool); success {
rt.FindCapabilityCalls++
rt.ExposeTools(result["exposed"])
if nativeTools {
nativeDefinitions = ToOllamaTools(rt.Tools)
}
}
} else {
result = DispatchValidatedTool(request, rt.Executor)
}
}()
kind := "error"
if success, _ := result["success"].(bool); success {
kind = "completed"
}
events = append(events, ToolEvent{Kind: kind, Request: request, Result: DeepCopy(result).(map[string]interface{})})
conversation = append(conversation, ToolMessage(request, result))
if success, _ := result["success"].(bool); success {
registerSuccess()
} else {
if trip := registerFailure(request, result); trip != "" {
return rt.failure(trip, events, thinking, iterationsUsed)
}
}
}
requests = nil
}
if iterationsUsed >= rt.MaxIterations {
return rt.failure("local tool runtime iteration limit reached", events, thinking, iterationsUsed)
}
chatResponse, chatErr := rt.Transport.Chat(rt.Model, conversation, nativeDefinitions, options, think)
if chatErr != nil {
return rt.failure(fmt.Sprintf("transport error: %v", chatErr), events, thinking, iterationsUsed)
}
if chatResponse == nil {
return rt.failure("transport response is missing a message object", events, thinking, iterationsUsed)
}
assistant := DeepCopy(chatResponse).(map[string]interface{})
var normalized []ToolRequest
var normalizeErr error
if nativeTools {
normalized, normalizeErr = NormalizeOllamaToolCalls(assistant)
} else {
content, _ := assistant["content"].(string)
normalized, normalizeErr = NormalizeTextToolCalls(content)
}
if normalizeErr != nil {
message := normalizeErr.Error()
if len(message) >= len("unknown tool: ") && message[:len("unknown tool: ")] == "unknown tool: " {
// P1-01 (#24): one-step recovery — name the escape hatch.
return rt.failure(
fmt.Sprintf("%s. If you need a capability that is not exposed, call %s with a description of what you need to do.", message, FindCapabilityTool),
events, thinking, iterationsUsed)
}
return rt.failure(message, events, thinking, iterationsUsed)
}
requests = normalized
iterationsUsed++
if thought, ok := assistant["thinking"].(string); ok && thought != "" {
thinking = append(thinking, thought)
}
if len(requests) == 0 {
content, isStr := assistant["content"].(string)
if !isStr {
return rt.failure("assistant content must be a string", events, thinking, iterationsUsed)
}
visible, err := ExtractVisibleText(content)
if err != nil {
return rt.failure(err.Error(), events, thinking, iterationsUsed)
}
if ContainsInternalProtocol(visible) {
return rt.failure("internal tool protocol in final content", events, thinking, iterationsUsed)
}
if trimSpace(visible) == "" {
return rt.failure("assistant returned empty terminal visible text", events, thinking, iterationsUsed)
}
return &OperatorResponse{
Success: true,
UserText: visible,
Thinking: thinking,
ToolEvents: events,
Model: rt.Model,
IterationsUsed: iterationsUsed,
IterationBudget: rt.MaxIterations,
}
}
// The provider's exact assistant object, including call IDs and
// thinking fields, must precede all result messages.
conversation = append(conversation, assistant)
requests = Deduplicate(requests)
}
}
// pendingState — the immutable serialized resume state.
func (rt *LocalRuntime) pendingState(
conversation []map[string]interface{},
nativeTools bool,
options map[string]interface{},
think interface{},
iterationsUsed int,
request ToolRequest,
events []ToolEvent,
thinking []string,
remaining []ToolRequest,
) map[string]interface{} {
eventStates := make([]interface{}, 0, len(events))
for _, event := range events {
eventStates = append(eventStates, EventToState(event))
}
remainingStates := make([]interface{}, 0, len(remaining))
for _, item := range remaining {
remainingStates = append(remainingStates, RequestToState(item))
}
var thinkValue interface{}
if think != nil {
thinkValue = think
}
var optionsValue interface{}
if options != nil {
optionsValue = DeepCopy(options)
}
state := map[string]interface{}{
"messages": deepCopyMessagesForState(conversation),
"native_tools": nativeTools,
"options": optionsValue,
"think": thinkValue,
"iterations_used": iterationsUsed,
"pending_request": RequestToState(request),
"tool_events": eventStates,
"thinking": append([]interface{}{}, toAnySlice(thinking)...),
"remaining_requests": remainingStates,
// P1-01 #24 quality discharge: the run's full exposed set rides the
// state so a FRESH runtime instance can resume it.
"exposed_tools": SortedKeys(rt.ToolNames),
}
return state
}
// ExposeTools — add tool schemas by name for the rest of this run
// (P1-01 #24). Only schemas that exist in the registry can be exposed —
// a name without a schema is ignored. Additive, never removes.
func (rt *LocalRuntime) ExposeTools(names interface{}) {
list, ok := names.([]interface{})
if !ok {
if strSlice, isSlice := names.([]string); isSlice {
list = make([]interface{}, 0, len(strSlice))
for _, name := range strSlice {
list = append(list, name)
}
} else {
return
}
}
for _, item := range list {
name, isStr := item.(string)
if !isStr || rt.ToolNames[name] {
continue
}
definition, found := rt.indexLookup(name)
if !found {
continue
}
rt.Tools = append(rt.Tools, DeepCopy(definition).(map[string]interface{}))
rt.ToolNames[name] = true
}
}
// WithFallbackPrompt — inject the runtime-owned fallback tool prompt.
func (rt *LocalRuntime) WithFallbackPrompt(messages []map[string]interface{}) []map[string]interface{} {
fallback := BuildToolPrompt(rt.Tools)
if len(messages) > 0 {
if role, _ := messages[0]["role"].(string); role == "system" {
content, _ := messages[0]["content"].(string)
messages[0]["content"] = fmt.Sprintf("%s\n\n%s", content, fallback)
return messages
}
}
return append([]map[string]interface{}{{"role": "system", "content": fallback}}, messages...)
}
// BuildToolPrompt — port of tool_executor.build_tool_prompt.
func BuildToolPrompt(tools []map[string]interface{}) string {
lines := []string{"## Available Tools\n"}
lines = append(lines, "You can use these tools by responding with a JSON object:")
lines = append(lines, "```json\n{\"tool\": \"tool_name\", \"args\": {\"param\": \"value\"}}\n```\n")
lines = append(lines, "After receiving a tool result, continue your response normally.\n")
for _, tool := range tools {
name, _ := tool["name"].(string)
description, _ := tool["description"].(string)
lines = append(lines, fmt.Sprintf("### %s", name))
lines = append(lines, description)
parameters, _ := tool["parameters"].(map[string]interface{})
properties, _ := parameters["properties"].(map[string]interface{})
required, _ := parameters["required"].([]interface{})
requiredNames := map[string]bool{}
for _, r := range required {
if s, ok := r.(string); ok {
requiredNames[s] = true
}
}
names := ParamOrder(tool, properties)
for _, pname := range names {
pinfo, _ := properties[pname].(map[string]interface{})
description, _ := pinfo["description"].(string)
req := " (optional)"
if requiredNames[pname] {
req = " (required)"
}
lines = append(lines, fmt.Sprintf(" - `%s`: %s%s", pname, description, req))
}
lines = append(lines, "")
}
out := ""
for i, line := range lines {
if i > 0 {
out += "\n"
}
out += line
}
return out
}
// failure — the structured failure envelope.
func (rt *LocalRuntime) failure(err string, events []ToolEvent, thinking []string, iterationsUsed int) *OperatorResponse {
eventList := []ToolEvent{}
if events != nil {
eventList = append(eventList, events...)
}
thinkList := []string{}
if thinking != nil {
thinkList = append(thinkList, thinking...)
}
return &OperatorResponse{
Success: false,
Thinking: thinkList,
ToolEvents: eventList,
Model: rt.Model,
Error: err,
IterationsUsed: iterationsUsed,
IterationBudget: rt.MaxIterations,
}
}
// ParamOrder — the build_tool_prompt parameter iteration seam. Python
// iterates the definition dict in insertion order; Go maps randomize, so
// the order comes from the raw registry JSON (or a test seam).
var ParamOrder = func(tool map[string]interface{}, properties map[string]interface{}) []string {
name, _ := tool["name"].(string)
if order, ok := RegistryParamOrder[name]; ok {
// a narrowed definition (test tools) may expose a subset of the
// registry's parameters — keep only the ones present, in file order
filtered := make([]string, 0, len(order))
for _, pname := range order {
if _, has := properties[pname]; has {
filtered = append(filtered, pname)
}
}
return filtered
}
names := make([]string, 0, len(properties))
for pname := range properties {
names = append(names, pname)
}
sort.Strings(names)
return names
}
+156
View File
@@ -0,0 +1,156 @@
package main
// state.go — the serializable pending-state helpers (name-preserving port
// of LocalToolRuntime._request_to_state/_request_from_state/
// _event_to_state/_event_from_state/_tool_message/_deduplicate).
// Everything works over JSON-decoded values so persisted states round-trip
// byte-for-byte through Python's sort_keys/separators canonical form.
import (
"bytes"
"encoding/json"
"fmt"
"sort"
)
// CanonicalJSON — Python json.dumps(value, sort_keys=True,
// separators=(",", ":")) over JSON-decoded data. Go's encoder already
// sorts map keys; HTML escaping must stay off to match Python.
func CanonicalJSON(value interface{}) string {
var buf bytes.Buffer
encoder := json.NewEncoder(&buf)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(value); err != nil {
return ""
}
// Encode appends a newline — strip it.
out := buf.Bytes()
return string(out[:len(out)-1])
}
// DeepCopy — deep-copies JSON-decoded data (Python copy.deepcopy over
// JSON-round-trippable values).
func DeepCopy(value interface{}) interface{} {
if value == nil {
return nil
}
var out interface{}
raw, err := json.Marshal(value)
if err != nil {
return value
}
decoder := json.NewDecoder(bytes.NewReader(raw))
if err := decoder.Decode(&out); err != nil {
return value
}
return out
}
// ToolMessage — the canonical tool result message appended after every
// dispatch. Content is the sorted-keys compact JSON of the result.
func ToolMessage(request ToolRequest, result map[string]interface{}) map[string]interface{} {
return map[string]interface{}{
"role": "tool",
"content": CanonicalJSON(result),
"tool_name": request.Name,
"tool_call_id": request.CallID,
}
}
// Deduplicate — drop repeated (name, canonical-arguments) requests,
// preserving first-seen order.
func Deduplicate(requests []ToolRequest) []ToolRequest {
var unique []ToolRequest
seen := map[string]bool{}
for _, request := range requests {
key := request.Name + ":" + CanonicalJSON(request.Arguments)
if seen[key] {
continue
}
seen[key] = true
unique = append(unique, request)
}
if unique == nil {
unique = []ToolRequest{}
}
return unique
}
// RequestToState — the persisted form of a tool request.
func RequestToState(request ToolRequest) map[string]interface{} {
return map[string]interface{}{
"call_id": request.CallID,
"name": request.Name,
"arguments": DeepCopy(request.Arguments),
"source": request.Source,
}
}
// RequestFromState — rebuild a request from persisted state; every field
// must be a non-empty string except arguments, which must be an object.
func RequestFromState(value interface{}) (ToolRequest, error) {
state, ok := value.(map[string]interface{})
if !ok {
return ToolRequest{}, fmt.Errorf("pending request must be an object")
}
callID, _ := state["call_id"].(string)
name, _ := state["name"].(string)
source, _ := state["source"].(string)
if callID == "" || name == "" || source == "" {
return ToolRequest{}, fmt.Errorf("pending request fields must be non-empty strings")
}
arguments, ok := state["arguments"].(map[string]interface{})
if !ok {
return ToolRequest{}, fmt.Errorf("pending request arguments must be an object")
}
return ToolRequest{CallID: callID, Name: name, Arguments: arguments, Source: source}, nil
}
// EventToState — the persisted form of a tool event.
func EventToState(event ToolEvent) map[string]interface{} {
var result interface{}
if event.Result != nil {
result = DeepCopy(event.Result)
}
return map[string]interface{}{
"kind": event.Kind,
"request": RequestToState(event.Request),
"result": result,
}
}
// EventFromState — rebuild an event from persisted state.
func EventFromState(value interface{}) (ToolEvent, error) {
state, ok := value.(map[string]interface{})
if !ok {
return ToolEvent{}, fmt.Errorf("pending tool event must be an object")
}
kind, _ := state["kind"].(string)
if kind == "" {
return ToolEvent{}, fmt.Errorf("pending tool event kind must be a non-empty string")
}
request, err := RequestFromState(state["request"])
if err != nil {
return ToolEvent{}, err
}
rawResult, has := state["result"]
if has && rawResult != nil {
result, ok := rawResult.(map[string]interface{})
if !ok {
return ToolEvent{}, fmt.Errorf("pending tool event result must be an object")
}
copied, _ := DeepCopy(result).(map[string]interface{})
return ToolEvent{Kind: kind, Request: request, Result: copied}, nil
}
return ToolEvent{Kind: kind, Request: request, Result: nil}, nil
}
// SortedKeys — deterministic key order for exposed-tools logging/state.
func SortedKeys(names map[string]bool) []string {
out := make([]string, 0, len(names))
for name := range names {
out = append(out, name)
}
sort.Strings(out)
return out
}
@@ -0,0 +1,492 @@
[
{
"name": "read_file",
"description": "Read a file with optional windowed viewing (offset/limit). Returns numbered lines. Use offset/limit for large files (100 lines at a time).",
"risk": "read-only",
"preconditions": [
"path exists and is a readable file"
],
"side_effects": [],
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Absolute path to the file"
},
"offset": {
"type": "integer",
"description": "Line number to start from (1-indexed, optional)"
},
"limit": {
"type": "integer",
"description": "Max lines to return (default 100, optional)"
}
},
"required": [
"path"
]
}
},
{
"name": "edit_file",
"description": "Surgical search/replace edit. More reliable than full file rewrites. The search string must match exactly.",
"risk": "write",
"preconditions": [
"path is inside an allowed write directory",
"search string matches exactly once"
],
"side_effects": [
"rewrites the target file atomically after approval",
"creates a .bak backup under ~/.rune/undo"
],
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Absolute path to the file"
},
"search": {
"type": "string",
"description": "Exact text to find (must match file content)"
},
"replace": {
"type": "string",
"description": "Text to replace with"
}
},
"required": [
"path",
"search",
"replace"
]
}
},
{
"name": "write_file",
"description": "Write content to a file. Creates parent directories if needed. Shows diff before writing. Use for new files or full rewrites.",
"risk": "write",
"preconditions": [
"path is inside an allowed write directory"
],
"side_effects": [
"writes the full file content atomically after approval",
"creates a .bak backup under ~/.rune/undo"
],
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Absolute path to the file"
},
"content": {
"type": "string",
"description": "Content to write"
}
},
"required": [
"path",
"content"
]
}
},
{
"name": "glob_files",
"description": "Find files matching a glob pattern. Returns file paths sorted by modification time.",
"risk": "read-only",
"preconditions": [
"search directory is readable"
],
"side_effects": [],
"parameters": {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Glob pattern (e.g. '**/*.py', 'src/**/*.ts')"
},
"path": {
"type": "string",
"description": "Directory to search in (optional, defaults to cwd)"
}
},
"required": [
"pattern"
]
}
},
{
"name": "grep_files",
"description": "Search for a pattern in files. Returns file paths and line numbers. Use include to filter by file type.",
"risk": "read-only",
"preconditions": [
"search directory is readable"
],
"side_effects": [],
"parameters": {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Regex pattern to search for"
},
"path": {
"type": "string",
"description": "Directory to search in (optional)"
},
"include": {
"type": "string",
"description": "File pattern to include (e.g. '*.py', '*.{ts,tsx}')"
}
},
"required": [
"pattern"
]
}
},
{
"name": "list_directory",
"description": "List files and directories at a path. Returns names with / suffix for directories.",
"risk": "read-only",
"preconditions": [
"path is a readable directory"
],
"side_effects": [],
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Absolute path to the directory"
}
},
"required": [
"path"
]
}
},
{
"name": "lint_file",
"description": "Lint a file for syntax errors and style issues. Blocks bad edits. Supports Python (ruff), JS/TS (eslint), Go (golangci-lint), and more.",
"risk": "read-only",
"preconditions": [
"file exists",
"a linter is configured for the language"
],
"side_effects": [],
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Absolute path to the file"
},
"language": {
"type": "string",
"description": "Language override (optional, auto-detected from extension)"
}
},
"required": [
"path"
]
}
},
{
"name": "run_tests",
"description": "Run tests for a file or project. Auto-detects test runner from language.",
"risk": "write",
"preconditions": [
"a test runner is configured for the project"
],
"side_effects": [
"runs project test commands which may create or delete files"
],
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File or directory to test"
},
"command": {
"type": "string",
"description": "Custom test command (optional, overrides auto-detect)"
}
},
"required": [
"path"
]
}
},
{
"name": "format_code",
"description": "Auto-format code files. Supports Python (black/ruff), JS/TS (prettier), Go (gofmt), and more.",
"risk": "write",
"preconditions": [
"path is inside an allowed write directory",
"a formatter is configured for the language"
],
"side_effects": [
"rewrites files in place to the formatter's style"
],
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File or directory to format"
},
"language": {
"type": "string",
"description": "Language override (optional)"
}
},
"required": [
"path"
]
}
},
{
"name": "git_diff",
"description": "Show git diff for staged or unstaged changes.",
"risk": "read-only",
"preconditions": [
"working directory is a git repository"
],
"side_effects": [],
"parameters": {
"type": "object",
"properties": {
"staged": {
"type": "boolean",
"description": "Show staged changes (default: unstaged)"
},
"path": {
"type": "string",
"description": "Specific file to diff (optional)"
}
}
}
},
{
"name": "git_log",
"description": "Show recent git commits.",
"risk": "read-only",
"preconditions": [
"working directory is a git repository"
],
"side_effects": [],
"parameters": {
"type": "object",
"properties": {
"limit": {
"type": "integer",
"description": "Number of commits (default 10)"
},
"path": {
"type": "string",
"description": "Specific file history (optional)"
}
}
}
},
{
"name": "git_commit",
"description": "Commit changes with a message. Auto-stages modified files.",
"risk": "irreversible",
"preconditions": [
"working directory is a git repository",
"there are changes to commit"
],
"side_effects": [
"creates a commit on the current branch"
],
"parameters": {
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "Commit message"
},
"files": {
"type": "array",
"items": {
"type": "string"
},
"description": "Specific files to commit (optional, defaults to all modified)"
}
},
"required": [
"message"
]
}
},
{
"name": "repo_map",
"description": "Generate an AST-based codebase map showing key symbols (classes, functions, imports). Uses tree-sitter for 100+ languages.",
"risk": "read-only",
"preconditions": [
"target directory is readable"
],
"side_effects": [],
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Root directory to map"
},
"languages": {
"type": "array",
"items": {
"type": "string"
},
"description": "Filter by languages (optional)"
}
},
"required": [
"path"
]
}
},
{
"name": "run_command",
"description": "Run an approved command. Prefer argv (shell=False); shell syntax requires shell=true and explicit user approval.",
"risk": "irreversible",
"preconditions": [
"operator approves the command",
"required binaries are on PATH"
],
"side_effects": [
"executes arbitrary commands with their full side effects"
],
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "Command text; shell syntax is allowed only with shell=true"
},
"argv": {
"type": "array",
"items": {
"type": "string"
},
"description": "Argument vector for shell-free execution"
},
"shell": {
"type": "boolean",
"description": "Explicitly request the user-approved shell execution path"
},
"workdir": {
"type": "string",
"description": "Working directory (optional)"
},
"timeout": {
"type": "integer",
"description": "Timeout in seconds (default 30)"
}
}
}
},
{
"name": "read_persona",
"description": "Read Rune's persona definition. Returns the full persona block with voice, identity, and rules.",
"risk": "read-only",
"preconditions": [
"persona MEMORY.md exists"
],
"side_effects": [],
"parameters": {
"type": "object",
"properties": {}
}
},
{
"name": "query_memory",
"description": "Query Rune's memory store. Returns relevant memories for a query.",
"risk": "read-only",
"preconditions": [
"memory store database is available"
],
"side_effects": [],
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query for memory"
},
"limit": {
"type": "integer",
"description": "Max results (default 5)"
}
},
"required": [
"query"
]
}
},
{
"name": "check_growth",
"description": "Check Rune's growth log. Returns recent growth entries and patterns.",
"risk": "read-only",
"preconditions": [
"growth log file exists"
],
"side_effects": [],
"parameters": {
"type": "object",
"properties": {
"limit": {
"type": "integer",
"description": "Max entries (default 10)"
}
}
}
},
{
"name": "diagnose_behavior",
"description": "Diagnose Rune's current behavior. Returns analysis of persona adherence, voice consistency, and drift.",
"risk": "read-only",
"preconditions": [
"recent response text provided"
],
"side_effects": [],
"parameters": {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "Recent response text to analyze"
}
},
"required": [
"text"
]
}
},
{
"name": "find_capability",
"description": "Search all installed capabilities for what you need. Returns matching tool names with descriptions and argument schemas. Use this when a capability you need is not in your current tool set; the matched tool becomes callable in the same session.",
"risk": "read-only",
"preconditions": [
"a capability query describing the needed outcome"
],
"side_effects": [],
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "What you need to do, e.g. 'run the test suite' or 'commit my changes'"
}
},
"required": [
"query"
]
}
}
]
File diff suppressed because it is too large. Load diff
+780
View File
@@ -0,0 +1,780 @@
package main
// truth_test.go — the frozen-truth battery (dichotomy wave gap-close).
// Every row in truth.json was generated against the LIVE Python oracle
// (scripts/gen_local_tool_runtime_truth.py) with deep-copied inputs and is
// embedded verbatim; this battery replays each row against the Go port and
// demands exact parity of the full envelope (including chat payloads,
// executor calls and pending state).
import (
_ "embed"
"encoding/json"
"fmt"
"os"
"strings"
"testing"
"time"
)
//go:embed truth.json
var truthJSON []byte
type truthDoc map[string]json.RawMessage
func loadTruth(t *testing.T) truthDoc {
t.Helper()
var doc truthDoc
if err := json.Unmarshal(truthJSON, &doc); err != nil {
t.Fatalf("truth.json: %v", err)
}
return doc
}
func decodeRows(t *testing.T, doc truthDoc, key string) []map[string]interface{} {
t.Helper()
var rows []map[string]interface{}
if err := json.Unmarshal(doc[key], &rows); err != nil {
t.Fatalf("%s: %v", key, err)
}
return rows
}
// assertJSONEqual compares parsed structures (key order is irrelevant;
// numbers must be exact).
func assertJSONEqual(t *testing.T, label string, want, got interface{}) {
t.Helper()
wantJSON, _ := json.Marshal(want)
gotJSON, _ := json.Marshal(got)
var wantAny, gotAny interface{}
_ = json.Unmarshal(wantJSON, &wantAny)
_ = json.Unmarshal(gotJSON, &gotAny)
wantNorm, _ := json.Marshal(wantAny)
gotNorm, _ := json.Marshal(gotAny)
if string(wantNorm) != string(gotNorm) {
t.Errorf("%s mismatch:\n want: %s\n got: %s", label, wantNorm, gotNorm)
}
}
// ── fakes mirroring the generator's (deep-copied inputs, recorded calls) ──
type fakeTransport struct {
responses []interface{}
nativeTools bool
thinking bool
calls []map[string]interface{}
}
func newFakeTransport(responses []interface{}, nativeTools, thinking bool) *fakeTransport {
if responses == nil {
responses = []interface{}{}
}
return &fakeTransport{responses: responses, nativeTools: nativeTools, thinking: thinking}
}
func (f *fakeTransport) SupportsTools(model string) (bool, error) { return f.nativeTools, nil }
func (f *fakeTransport) SupportsThinking(model string) (bool, error) {
return f.thinking, nil
}
func (f *fakeTransport) Chat(model string, messages []map[string]interface{}, tools []map[string]interface{}, options map[string]interface{}, think interface{}) (map[string]interface{}, error) {
call := map[string]interface{}{
"model": model,
"messages": deepCopyMessages(messages),
"tools": DeepCopy(tools),
"options": DeepCopy(options),
"think": DeepCopy(think),
}
f.calls = append(f.calls, call)
if len(f.responses) == 0 {
return nil, fmt.Errorf("pop from empty list")
}
response := f.responses[0]
f.responses = f.responses[1:]
if err, isErr := response.(error); isErr {
return nil, err
}
message, ok := response.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("fake response must be a message object")
}
return DeepCopy(message).(map[string]interface{}), nil
}
type scriptExecutor struct {
results []interface{}
calls []map[string]interface{}
}
func newScriptExecutor(results []interface{}) *scriptExecutor {
if results == nil {
results = []interface{}{}
}
return &scriptExecutor{results: results}
}
func (s *scriptExecutor) Dispatch(name string, arguments map[string]interface{}) map[string]interface{} {
s.calls = append(s.calls, map[string]interface{}{"tool": name, "arguments": DeepCopy(arguments)})
if len(s.results) == 0 {
return map[string]interface{}{"success": true, "result": name}
}
result := s.results[0]
s.results = s.results[1:]
m, ok := result.(map[string]interface{})
if !ok {
return map[string]interface{}{"success": true, "result": name}
}
return DeepCopy(m).(map[string]interface{})
}
func (s *scriptExecutor) UsesToolAuthority() bool { return false }
// ── 1. ExtractContextLength ──────────────────────────────────────────────────
func TestTruthExtractContextLength(t *testing.T) {
doc := loadTruth(t)
for index, row := range decodeRows(t, doc, "extract_context_length") {
shown := row["shown"].(map[string]interface{})
got := ExtractContextLength(shown)
if _, isBool := row["want"].(bool); isBool {
// DOCUMENTED DIVERGENCE (Python bool ⊂ int): the oracle returns
// True for context_length:true because isinstance(True, int)
// holds in Python. Go's type system does not conflate bool and
// int; the port rejects bools, returning 0 (caller falls back).
if got != 0 {
t.Errorf("row %d: divergence row must return 0, got %d", index, got)
}
continue
}
want := int(row["want"].(float64))
if got != want {
t.Errorf("row %d: ExtractContextLength(%v) = %d, want %d", index, shown, got, want)
}
}
}
// ── 2. PositiveInt ───────────────────────────────────────────────────────────
func TestTruthPositiveInt(t *testing.T) {
doc := loadTruth(t)
for index, row := range decodeRows(t, doc, "positive_int") {
value := row["value"]
want := int64(row["want"].(float64))
// bools must NOT count as positive ints (Python isinstance ordering)
if b, isBool := value.(bool); isBool {
if got := PositiveInt(b); got != want {
t.Errorf("row %d: PositiveInt(%v) = %d, want %d", index, value, got, want)
}
continue
}
got := PositiveInt(value)
if got != want {
t.Errorf("row %d: PositiveInt(%v) = %d, want %d", index, value, got, want)
}
}
}
// ── 3. ModelCapabilities / SupportsTools / SupportsThinking ─────────────────
func TestTruthModelCapabilities(t *testing.T) {
doc := loadTruth(t)
for index, row := range decodeRows(t, doc, "model_capabilities") {
shown := row["shown"]
if _, isString := shown.(string); isString {
// two-models caching row: freeze shape
posts := []map[string]interface{}{}
transport := NewOllamaTransport("", time.Second)
transport.PostFunc = func(path string, payload map[string]interface{}) (map[string]interface{}, error) {
posts = append(posts, DeepCopy(payload).(map[string]interface{}))
return map[string]interface{}{
"capabilities": []interface{}{"tools"},
"model_info": map[string]interface{}{"a.context_length": float64(2048)},
}, nil
}
caps1, _ := transport.ModelCapabilities("m1")
caps2, _ := transport.ModelCapabilities("m2")
if len(caps1) != 1 || !caps1["tools"] || len(caps2) != 1 || !caps2["tools"] {
t.Errorf("row %d: two-model caps wrong: %v %v", index, caps1, caps2)
}
wantPosts := row["posts"].([]interface{})
if len(posts) != len(wantPosts) {
t.Errorf("row %d: posts %d, want %d", index, len(posts), len(wantPosts))
}
if len(transport.contextLengths) != 2 ||
transport.contextLengths["m1"] != 2048 || transport.contextLengths["m2"] != 2048 {
t.Errorf("row %d: context lengths %v", index, transport.contextLengths)
}
continue
}
shownMap := shown.(map[string]interface{})
posts := []map[string]interface{}{}
transport := NewOllamaTransport("", time.Second)
transport.PostFunc = func(path string, payload map[string]interface{}) (map[string]interface{}, error) {
posts = append(posts, DeepCopy(payload).(map[string]interface{}))
return DeepCopy(shownMap).(map[string]interface{}), nil
}
caps, err := transport.ModelCapabilities("m1")
if err != nil {
t.Fatalf("row %d: %v", index, err)
}
caps2, _ := transport.ModelCapabilities("m1")
tools, _ := transport.SupportsTools("m1")
thinking, _ := transport.SupportsThinking("m1")
wantCaps := row["capabilities"].([]interface{})
if len(caps) != len(wantCaps) {
t.Errorf("row %d: caps %v, want %v", index, caps, wantCaps)
}
for _, name := range wantCaps {
if !caps[name.(string)] {
t.Errorf("row %d: caps missing %q", index, name)
}
}
if len(caps2) != len(caps) {
t.Errorf("row %d: second call caps differ", index)
}
if tools != row["supports_tools"].(bool) {
t.Errorf("row %d: supports_tools %v", index, tools)
}
if thinking != row["supports_thinking"].(bool) {
t.Errorf("row %d: supports_thinking %v", index, thinking)
}
wantPosts := row["posts"].([]interface{})
if len(posts) != len(wantPosts) {
t.Errorf("row %d: posts %d, want %d", index, len(posts), len(wantPosts))
}
wantLengths := row["context_lengths"].(map[string]interface{})
if len(transport.contextLengths) != len(wantLengths) {
t.Errorf("row %d: context lengths %v, want %v", index, transport.contextLengths, wantLengths)
}
for model, w := range wantLengths {
if transport.contextLengths[model] != int(w.(float64)) {
t.Errorf("row %d: context length %s = %d, want %v", index, model, transport.contextLengths[model], w)
}
}
}
}
// ── 4. OllamaTransport.ContextLength ─────────────────────────────────────────
func TestTruthTransportContextLength(t *testing.T) {
doc := loadTruth(t)
for index, row := range decodeRows(t, doc, "transport_context_length") {
shown := row["shown"].(map[string]interface{})
scenario := row["scenario"].(string)
posts := 0
transport := NewOllamaTransport("", time.Second)
transport.PostFunc = func(path string, payload map[string]interface{}) (map[string]interface{}, error) {
posts++
return DeepCopy(shown).(map[string]interface{}), nil
}
if scenario == "reported" {
window, err := transport.ContextLength("m1")
if err != nil {
t.Fatalf("row %d: %v", index, err)
}
window2, err := transport.ContextLength("m1")
if err != nil {
t.Fatalf("row %d: %v", index, err)
}
if window != int(row["window"].(float64)) || window2 != int(row["window_second_call"].(float64)) {
t.Errorf("row %d: windows %d/%d", index, window, window2)
}
if posts != len(row["posts"].([]interface{})) {
t.Errorf("row %d: posts %d", index, posts)
}
} else {
_, err := transport.ContextLength("m1")
want := row["error"].(string)
if err == nil || err.Error() != want {
t.Errorf("row %d: error %v, want %q", index, err, want)
}
}
}
}
// ── 5. module-level ContextLength ────────────────────────────────────────────
func TestTruthModuleContextLength(t *testing.T) {
doc := loadTruth(t)
rows := decodeRows(t, doc, "module_context_length")
scenarios := map[string]func(row map[string]interface{}){
"local": func(row map[string]interface{}) {
IsCloudPinned = func(model string) bool { return false }
},
"cloud": func(row map[string]interface{}) {
IsCloudPinned = func(model string) bool { return true }
CloudTransportFor = func(model string) CloudTransport {
return stubCloud{window: 131072, err: nil}
}
},
"cached": func(row map[string]interface{}) {
IsCloudPinned = func(model string) bool { return false }
},
"raises": func(row map[string]interface{}) {
IsCloudPinned = func(model string) bool { return false }
// oracle: the boom patch covers only the FIRST call — the
// second call re-discovers with the healthy stub (failure is
// never cached).
calls := 0
NewLocalTransport = func(baseURL string) (CloudTransport, error) {
calls++
if calls == 1 {
return stubCloud{window: 0, err: fmt.Errorf("ollama down")}, nil
}
return stubCloud{window: 4096, err: nil}, nil
}
},
"cloud_raises": func(row map[string]interface{}) {
IsCloudPinned = func(model string) bool { return true }
calls := 0
CloudTransportFor = func(model string) CloudTransport {
calls++
if calls == 1 {
return stubCloud{window: 0, err: fmt.Errorf("cloud down")}
}
return stubCloud{window: 131072, err: nil}
}
},
}
for _, row := range rows {
scenario := row["scenario"].(string)
model := row["model"].(string)
seed := map[string]int{}
for m, w := range row["seed_cache"].(map[string]interface{}) {
seed[m] = int(w.(float64))
}
failures := []map[string]interface{}{}
broker := []interface{}{}
constructs := 0
savedPinned, savedCloudFor, savedLocal, savedRecord, savedBroker := IsCloudPinned, CloudTransportFor, NewLocalTransport, RecordFailure, ResetDiscoveredWindows
scenarios[scenario](row)
RecordFailure = func(subsystem string, err error, tier int, context string) {
failures = append(failures, map[string]interface{}{
"args": []interface{}{subsystem, fmt.Sprintf("%T: %s", err, err.Error())},
})
}
ResetDiscoveredWindows = func() { broker = append(broker, "reset") }
if scenario == "local" {
NewLocalTransport = func(baseURL string) (CloudTransport, error) {
constructs++
return stubCloud{window: 4096, err: nil}, nil
}
}
ResetContextLengthCache()
ResetDiscoveredWindows = func() {}
for m, w := range seed {
contextLengths[m] = w
}
constructs = 0
window := ContextLength(model, "http://localhost:11434")
window2 := ContextLength(model, "http://localhost:11434")
cacheSnapshot := map[string]int{}
for m, w := range contextLengths {
cacheSnapshot[m] = w
}
IsCloudPinned, CloudTransportFor, NewLocalTransport, RecordFailure, ResetDiscoveredWindows = savedPinned, savedCloudFor, savedLocal, savedRecord, savedBroker
ResetContextLengthCache()
if window != int(row["window"].(float64)) {
t.Errorf("%s: window %d, want %v", scenario, window, row["window"])
}
if window2 != int(row["window_second_call"].(float64)) {
t.Errorf("%s: window2 %d, want %v", scenario, window2, row["window_second_call"])
}
cacheAfter := row["cache_after"].(map[string]interface{})
if len(cacheSnapshot) != len(cacheAfter) {
t.Errorf("%s: cache %v, want %v", scenario, cacheSnapshot, cacheAfter)
}
for m, w := range cacheAfter {
if cacheSnapshot[m] != int(w.(float64)) {
t.Errorf("%s: cache[%s]=%d, want %v", scenario, m, cacheSnapshot[m], w)
}
}
if len(failures) != len(row["failures"].([]interface{})) {
t.Errorf("%s: failures %d, want %d", scenario, len(failures), len(row["failures"].([]interface{})))
}
_ = constructs
_ = broker
}
}
type stubCloud struct {
window int
err error
}
func (s stubCloud) ContextLength(model string) (int, error) { return s.window, s.err }
// ── 6. ResetContextLengthCache ───────────────────────────────────────────────
func TestTruthResetCache(t *testing.T) {
doc := loadTruth(t)
row := doc["reset_cache"]
var decoded map[string]interface{}
if err := json.Unmarshal(row, &decoded); err != nil {
t.Fatal(err)
}
broker := 0
saved := ResetDiscoveredWindows
ResetDiscoveredWindows = func() { broker++ }
ResetContextLengthCache()
ResetDiscoveredWindows = saved
if len(contextLengths) != 0 {
t.Errorf("cache not cleared: %v", contextLengths)
}
if broker < 1 {
t.Errorf("broker reset not called")
}
}
// ── 7. Deduplicate ───────────────────────────────────────────────────────────
func TestTruthDeduplicate(t *testing.T) {
doc := loadTruth(t)
for index, row := range decodeRows(t, doc, "deduplicate") {
var requests []ToolRequest
for _, raw := range row["requests"].([]interface{}) {
m := raw.(map[string]interface{})
requests = append(requests, ToolRequest{
CallID: m["call_id"].(string),
Name: m["name"].(string),
Arguments: m["arguments"].(map[string]interface{}),
Source: m["source"].(string),
})
}
unique := Deduplicate(requests)
var want []interface{}
for _, raw := range row["unique"].([]interface{}) {
m := raw.(map[string]interface{})
want = append(want, map[string]interface{}{
"call_id": m["call_id"], "name": m["name"],
"arguments": m["arguments"], "source": m["source"],
})
}
var got []interface{}
for _, r := range unique {
got = append(got, map[string]interface{}{
"call_id": r.CallID, "name": r.Name,
"arguments": r.Arguments, "source": r.Source,
})
}
assertJSONEqual(t, fmt.Sprintf("dedup row %d", index), want, got)
}
}
// ── 8. ToolMessage ───────────────────────────────────────────────────────────
func TestTruthToolMessage(t *testing.T) {
doc := loadTruth(t)
for index, row := range decodeRows(t, doc, "tool_message") {
req := row["request"].(map[string]interface{})
request := ToolRequest{
CallID: req["call_id"].(string),
Name: req["name"].(string),
Arguments: req["arguments"].(map[string]interface{}),
Source: req["source"].(string),
}
result := row["result"].(map[string]interface{})
message := ToolMessage(request, result)
assertJSONEqual(t, fmt.Sprintf("tool message row %d", index), row["message"], message)
}
}
// ── 9/10. request & event state ──────────────────────────────────────────────
func TestTruthRequestState(t *testing.T) {
doc := loadTruth(t)
for index, row := range decodeRows(t, doc, "request_state") {
if input, ok := row["input"].(map[string]interface{}); ok && row["error"] == nil {
request := ToolRequest{
CallID: input["call_id"].(string),
Name: input["name"].(string),
Arguments: input["arguments"].(map[string]interface{}),
Source: input["source"].(string),
}
state := RequestToState(request)
assertJSONEqual(t, fmt.Sprintf("request state row %d", index), row["state"], state)
continue
}
input := row["input"]
_, err := RequestFromState(input)
want := pythonErrorMessage(row["error"].(string))
if err == nil || err.Error() != want {
t.Errorf("row %d: error %v, want %q", index, err, want)
}
}
}
func TestTruthEventState(t *testing.T) {
doc := loadTruth(t)
for index, row := range decodeRows(t, doc, "event_state") {
if state, ok := row["state"]; ok {
request := state.(map[string]interface{})["request"].(map[string]interface{})
event := ToolEvent{
Kind: state.(map[string]interface{})["kind"].(string),
Request: ToolRequest{
CallID: request["call_id"].(string),
Name: request["name"].(string),
Arguments: request["arguments"].(map[string]interface{}),
Source: request["source"].(string),
},
}
if result, has := state.(map[string]interface{})["result"]; has && result != nil {
event.Result = result.(map[string]interface{})
}
assertJSONEqual(t, fmt.Sprintf("event state row %d", index), state, EventToState(event))
continue
}
input := row["input"]
_, err := EventFromState(input)
want := pythonErrorMessage(row["error"].(string))
if err == nil || err.Error() != want {
t.Errorf("row %d: error %v, want %q", index, err, want)
}
}
}
// ── 11. FindCapabilityExecutor ───────────────────────────────────────────────
func TestTruthFindCapabilityExecutor(t *testing.T) {
doc := loadTruth(t)
for index, row := range decodeRows(t, doc, "find_capability_executor") {
name := row["tool"].(string)
arguments := row["arguments"].(map[string]interface{})
result := FindCapabilityExecutor(name, arguments)
want := row["result"].(map[string]interface{})
if success, hasSuccess := want["success"]; hasSuccess {
if got, _ := result["success"].(bool); got != success.(bool) {
t.Errorf("row %d: success %v, want %v", index, result["success"], success)
}
}
if errText, has := want["error"]; has {
if got, _ := result["error"].(string); got != errText.(string) {
t.Errorf("row %d: error %q, want %q", index, result["error"], errText)
}
}
// IO rows (successful reads/listings) are structurally checked —
// real /tmp content is not deterministic across runs.
if _, has := want["result"]; has {
if _, ok := result["result"]; !ok {
t.Errorf("row %d: missing result", index)
}
}
}
}
// ── 12. WithFallbackPrompt ───────────────────────────────────────────────────
func TestTruthFallbackPrompt(t *testing.T) {
doc := loadTruth(t)
for index, row := range decodeRows(t, doc, "fallback_prompt") {
var messages []map[string]interface{}
for _, raw := range row["messages"].([]interface{}) {
messages = append(messages, raw.(map[string]interface{}))
}
tools := truthSmallTools()
runtime := &LocalRuntime{Model: "m1", Tools: tools, ToolNames: map[string]bool{}}
got := runtime.WithFallbackPrompt(deepCopyMessages(messages))
assertJSONEqual(t, fmt.Sprintf("fallback row %d", index), row["result"], got)
}
}
func truthSmallTools() []map[string]interface{} {
return []map[string]interface{}{
{
"name": "read_file",
"description": "Read a file with optional windowed viewing",
"parameters": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "Absolute path to the file"},
"limit": map[string]interface{}{"type": "integer"},
},
"required": []interface{}{"path"},
},
},
{
"name": "list_directory",
"description": "List files in a directory",
"parameters": map[string]interface{}{"type": "object", "properties": map[string]interface{}{}, "required": []interface{}{}},
},
}
}
// ── 13. resume failure branches ──────────────────────────────────────────────
func TestTruthResumeFailures(t *testing.T) {
doc := loadTruth(t)
for _, row := range decodeRows(t, doc, "resume_failures") {
pendingState := row["pending_state"].(map[string]interface{})
toolResult := row["tool_result"].(map[string]interface{})
var tools []map[string]interface{}
if label, _ := row["label"].(string); strings.Contains(label, "allowlist") {
tools = truthSmallTools()
}
runtime := mustRuntime(t, newFakeTransport(nil, true, true), newScriptExecutor(nil), tools, 8)
response := runtime.Resume(pendingState, toolResult)
assertJSONEqual(t, fmt.Sprintf("resume failure %s", row["label"]), row["response"], responseToMap(response))
}
}
// ── 14. resume loop rows ─────────────────────────────────────────────────────
func TestTruthResumeLoops(t *testing.T) {
doc := loadTruth(t)
for _, row := range decodeRows(t, doc, "resume_loops") {
label := row["label"].(string)
pendingState := row["pending_state"].(map[string]interface{})
toolResult := row["tool_result"].(map[string]interface{})
wantResponse := row["response"].(map[string]interface{})
// transport script
var transportResponses []interface{}
for _, raw := range row["chat_responses"].([]interface{}) {
transportResponses = append(transportResponses, raw)
}
transport := newFakeTransport(transportResponses, true, true)
// executor script
var executorResults []interface{}
for _, raw := range row["executor_script"].([]interface{}) {
executorResults = append(executorResults, raw)
}
executor := newScriptExecutor(executorResults)
// runtime allowlist
var tools []map[string]interface{}
if rawTools, ok := row["runtime_tools"].([]interface{}); ok {
// the generator filtered the full TOOLS list, preserving its
// definition order — mirror via RegistryOrder.
wanted := map[string]bool{}
for _, name := range rawTools {
wanted[name.(string)] = true
}
for _, name := range RegistryOrder {
if wanted[name] {
tools = append(tools, RegistryTools[name])
}
}
}
runtime := mustRuntime(t, transport, executor, tools, 8)
response := runtime.Resume(pendingState, toolResult)
assertJSONEqual(t, label+" response", wantResponse, wildcardUUIDs(responseToMap(response)))
// executor calls
wantCalls, _ := row["executor_calls"].([]interface{})
if len(executor.calls) != len(wantCalls) {
t.Errorf("%s: executor calls %d, want %d", label, len(executor.calls), len(wantCalls))
} else {
for i, want := range wantCalls {
assertJSONEqual(t, fmt.Sprintf("%s executor call %d", label, i), want, executor.calls[i])
}
}
// chat payloads (model, messages, options, think, tools presence)
wantPayloads, _ := row["chat_payloads"].([]interface{})
if len(transport.calls) != len(wantPayloads) {
t.Errorf("%s: chat calls %d, want %d", label, len(transport.calls), len(wantPayloads))
continue
}
for i, wantAny := range wantPayloads {
want := wantAny.(map[string]interface{})
got := wildcardUUIDs(transport.calls[i]).(map[string]interface{})
assertJSONEqual(t, fmt.Sprintf("%s chat %d model", label, i), want["model"], got["model"])
assertJSONEqual(t, fmt.Sprintf("%s chat %d messages", label, i), want["messages"], got["messages"])
assertJSONEqual(t, fmt.Sprintf("%s chat %d options", label, i), want["options"], got["options"])
assertJSONEqual(t, fmt.Sprintf("%s chat %d think", label, i), want["think"], got["think"])
assertJSONEqual(t, fmt.Sprintf("%s chat %d tools", label, i), want["tools"], got["tools"])
}
}
}
// ── shared helpers ───────────────────────────────────────────────────────────
func mustRuntime(t *testing.T, transport LoopTransport, executor *scriptExecutor, tools []map[string]interface{}, maxIterations int) *LocalRuntime {
t.Helper()
runtime, err := NewLocalRuntime(transport, executor, "m1", tools, maxIterations)
if err != nil {
t.Fatal(err)
}
return runtime
}
// responseToMap — OperatorResponse → map for JSON comparison (the truth
// froze Python dataclasses.asdict envelopes; Python's default_factory
// lists marshal as [] — never null).
func responseToMap(response *OperatorResponse) map[string]interface{} {
raw, err := json.Marshal(response)
if err != nil {
panic(err)
}
var out map[string]interface{}
if err := json.Unmarshal(raw, &out); err != nil {
panic(err)
}
for _, key := range []string{"degraded", "runtime_objects"} {
if out[key] == nil {
out[key] = []interface{}{}
}
}
return out
}
// wildcardUUIDs — mirror the generator: the text-fallback envelope mints
// a fresh call_<32hex> id per call (Python uuid4); the SHAPE is the
// contract, so both sides collapse it to a wildcard before comparing.
func wildcardUUIDs(value interface{}) interface{} {
switch v := value.(type) {
case map[string]interface{}:
out := map[string]interface{}{}
for key, item := range v {
if key == "call_id" || key == "tool_call_id" {
if s, isStr := item.(string); isStr && len(s) == len("call_")+32 &&
strings.HasPrefix(s, "call_") {
isHex := true
for _, c := range s[len("call_"):] {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
isHex = false
break
}
}
if isHex {
out[key] = "<new_call_id>"
continue
}
}
}
out[key] = wildcardUUIDs(item)
}
return out
case []interface{}:
out := make([]interface{}, 0, len(v))
for _, item := range v {
out = append(out, wildcardUUIDs(item))
}
return out
case []map[string]interface{}:
out := make([]map[string]interface{}, 0, len(v))
for _, item := range v {
out = append(out, wildcardUUIDs(item).(map[string]interface{}))
}
return out
default:
return value
}
}
// pythonErrorMessage — the generator froze "TypeError: ..." / "ValueError: ..."
// prefixed messages; the exception TYPE is interpreter-specific, the
// message is the parity contract.
func pythonErrorMessage(frozen string) string {
for _, prefix := range []string{"TypeError: ", "ValueError: "} {
if strings.HasPrefix(frozen, prefix) {
return strings.TrimPrefix(frozen, prefix)
}
}
return frozen
}
var _ = os.Getenv
var _ = time.Second