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:
1 parent
82bec72695
commit
d6cbc05951
15 files changed
+11206
-6
No files matched your search
@@ -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)")
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user