Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46e74110ad | ||
|
|
c669e37a3d | ||
|
|
7f4a6262ae | ||
|
|
34d5ab74b7 | ||
|
|
7b268e17fe |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# Rune
|
||||
|
||||
**Current release: v0.66.14**
|
||||
**Current release: v0.66.16**
|
||||
|
||||
**Rune is a persistent personal agent designed to accumulate experience, develop reusable competence, and remain recognisably the same agent even when its underlying foundation model changes.**
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
"tool_executor",
|
||||
"tool_protocol",
|
||||
"local_tool_runtime",
|
||||
"openai_compat_transport",
|
||||
"forge_client",
|
||||
"message_queue",
|
||||
"connection_pool",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
def broken(:
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rune"
|
||||
version = "0.66.14"
|
||||
version = "0.66.16"
|
||||
description = "Rune — a stateful coding assistant with persistent memory, orchestration, and TUI"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ This is the main entry point for the Rune program.
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
__version__ = "0.66.14"
|
||||
__version__ = "0.66.16"
|
||||
|
||||
# One build identity (handoff 2026-09-10 §4): the semantic version above is
|
||||
# the repo-level truth (auto-bumped at merge); the DEPLOYED revision (exact
|
||||
|
||||
@@ -231,7 +231,7 @@ def main() -> int:
|
||||
)
|
||||
check(
|
||||
"Rune minor version advertises provisioning and restore verification",
|
||||
'__version__ = "0.66.14"' in rune_version,
|
||||
'__version__ = "0.66.16"' in rune_version,
|
||||
failures,
|
||||
)
|
||||
# #169 policy update: auto-bump on merge is sanctioned (Nick directive:
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""OpenAI-compatible chat transport (T15 PL1 held-out substrate fix).
|
||||
|
||||
The operator's execution substrate had exactly one transport —
|
||||
OllamaChatTransport (localhost:11434) — so send_task(model=<cloud>) posted a
|
||||
cloud model name to Ollama's /api/chat and died with HTTP 400 (observed live
|
||||
2026-09-12: 'Verboo Code/glm-5.3-flash' → 400 in 0.2s). The supported
|
||||
model= override could not execute ANY cloud provider.
|
||||
|
||||
This transport speaks the OpenAI wire format (/chat/completions) and returns
|
||||
Ollama-SHAPED responses, so LocalToolRuntime's loop is unchanged. Selection
|
||||
is deterministic (routing_mode == 'cloud') — no router, ladder, or escalation
|
||||
logic is touched.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
|
||||
class OpenAICompatTransport:
|
||||
"""ChatTransport for OpenAI-compatible providers (Verboo Code, Xioami...)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
provider_name: str,
|
||||
timeout: float = 120,
|
||||
context_window: int = 131072,
|
||||
):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.provider = provider_name
|
||||
self.timeout = timeout
|
||||
self._context_window = context_window
|
||||
# Usage accumulators — same contract as OllamaChatTransport (I2).
|
||||
self.usage_calls = 0
|
||||
self.usage_prompt_tokens = 0
|
||||
self.usage_output_tokens = 0
|
||||
self.usage_total_duration_ns = 0
|
||||
|
||||
# -- wire -----------------------------------------------------------
|
||||
|
||||
def _post(self, path: str, payload: dict) -> dict:
|
||||
request = urllib.request.Request(
|
||||
f"{self.base_url}{path}",
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||
decoded = json.loads(response.read())
|
||||
if not isinstance(decoded, dict):
|
||||
raise ValueError("provider returned a non-object response")
|
||||
return decoded
|
||||
|
||||
# -- message translation --------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _to_openai_messages(messages: list[dict]) -> list[dict]:
|
||||
"""Ollama-shape → OpenAI-shape. Tool results carry tool_call_id
|
||||
already (the runtime's _tool_message sets it from request.call_id);
|
||||
assistant tool_calls gain ids and JSON-string arguments."""
|
||||
out: list[dict] = []
|
||||
for m in messages:
|
||||
role = m.get("role")
|
||||
if role == "tool":
|
||||
out.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": m.get("tool_call_id") or f"call_{len(out)}",
|
||||
"content": m.get("content", ""),
|
||||
})
|
||||
continue
|
||||
tool_calls = m.get("tool_calls") or []
|
||||
if role == "assistant" and tool_calls:
|
||||
tc = []
|
||||
for i, c in enumerate(tool_calls):
|
||||
fn = c.get("function", {})
|
||||
args = fn.get("arguments", {})
|
||||
args_str = json.dumps(args) if isinstance(args, dict) else str(args)
|
||||
tc.append({
|
||||
"id": f"call_{len(out)}_{i}",
|
||||
"type": "function",
|
||||
"function": {"name": fn.get("name"), "arguments": args_str},
|
||||
})
|
||||
out.append({"role": "assistant", "content": m.get("content") or None, "tool_calls": tc})
|
||||
continue
|
||||
out.append({"role": role, "content": m.get("content", "")})
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _from_openai_response(resp: dict) -> dict:
|
||||
"""OpenAI response → Ollama-shape (LocalToolRuntime's contract)."""
|
||||
choices = resp.get("choices") or [{}]
|
||||
message = choices[0].get("message", {}) or {}
|
||||
tool_calls = []
|
||||
for c in message.get("tool_calls") or []:
|
||||
fn = c.get("function", {}) or {}
|
||||
args = fn.get("arguments", {})
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
args = json.loads(args)
|
||||
except json.JSONDecodeError:
|
||||
args = {"_raw": args}
|
||||
tool_calls.append({"function": {"name": fn.get("name"), "arguments": args or {}}})
|
||||
out: dict = {"message": {"role": "assistant", "content": message.get("content") or ""}}
|
||||
if tool_calls:
|
||||
out["message"]["tool_calls"] = tool_calls
|
||||
return out
|
||||
|
||||
# -- ChatTransport protocol -------------------------------------------
|
||||
|
||||
def chat(self, *, model: str, messages: list[dict], tools: list[dict] | None,
|
||||
options: dict | None = None, think: bool | str | None = None) -> dict:
|
||||
# The runtime passes the qualified name ('<Provider>/<model>'); the
|
||||
# provider API wants the bare model id ('glm-5.3-flash'). Observed
|
||||
# live: qualified name → 400 on every call (PL1 held-out attempt).
|
||||
wire_model = model.split("/", 1)[-1] if "/" in model else model
|
||||
payload: dict = {
|
||||
"model": wire_model,
|
||||
"messages": self._to_openai_messages(messages),
|
||||
"stream": False,
|
||||
}
|
||||
if tools:
|
||||
payload["tools"] = tools # Ollama tool schema == OpenAI tool schema
|
||||
if options:
|
||||
for k in ("temperature", "top_p", "max_tokens"):
|
||||
if k in options:
|
||||
payload[k] = options[k]
|
||||
started = time.monotonic()
|
||||
resp = self._post("/chat/completions", payload)
|
||||
elapsed_ns = int((time.monotonic() - started) * 1e9)
|
||||
usage = resp.get("usage", {}) or {}
|
||||
self.usage_calls += 1
|
||||
self.usage_prompt_tokens += int(usage.get("prompt_tokens") or 0)
|
||||
self.usage_output_tokens += int(usage.get("completion_tokens") or 0)
|
||||
self.usage_total_duration_ns += elapsed_ns
|
||||
return self._from_openai_response(resp)
|
||||
|
||||
def supports_tools(self, model: str) -> bool:
|
||||
return True
|
||||
|
||||
def supports_thinking(self, model: str) -> bool:
|
||||
return False
|
||||
|
||||
def context_length(self, model: str) -> int:
|
||||
return self._context_window
|
||||
|
||||
|
||||
def cloud_transport_for(model: str, timeout: float = 120) -> OpenAICompatTransport:
|
||||
"""Resolve provider config for a '<Provider>/<model>' name from the same
|
||||
source of truth the mimo CLI uses (mimocode.jsonc + auth.json)."""
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
if "/" not in model:
|
||||
raise ValueError(f"cloud model must be '<Provider>/<model>': {model!r}")
|
||||
provider_name, short_model = model.split("/", 1)
|
||||
base_url = api_key = None
|
||||
context_window = 131072
|
||||
cfg_path = Path(os.path.expanduser("~/.config/mimocode/mimocode.jsonc"))
|
||||
if cfg_path.is_file():
|
||||
text = cfg_path.read_text()
|
||||
m = re.search(rf'"{re.escape(provider_name)}"\s*:\s*\{{', text)
|
||||
if m:
|
||||
block = text[m.start():m.start() + 2000]
|
||||
bm = re.search(r'"baseURL":\s*"([^"]+)"', block)
|
||||
if bm:
|
||||
base_url = bm.group(1)
|
||||
wm = re.search(rf'"{re.escape(short_model)}"\s*:\s*\{{[^}}]*?"context_window"\s*:\s*(\d+)', block)
|
||||
if wm:
|
||||
context_window = int(wm.group(1))
|
||||
if not base_url:
|
||||
raise ValueError(f"no baseURL configured for provider {provider_name!r}")
|
||||
key = os.environ.get(f"{provider_name.upper().replace(' ', '_')}_API_KEY", "")
|
||||
if not key:
|
||||
auth_path = Path(os.path.expanduser("~/.local/share/mimocode/auth.json"))
|
||||
if auth_path.is_file():
|
||||
auth = json.loads(auth_path.read_text())
|
||||
key = (auth.get(provider_name) or {}).get("key", "")
|
||||
if not key:
|
||||
raise ValueError(f"no API key for provider {provider_name!r}")
|
||||
return OpenAICompatTransport(
|
||||
base_url, key, provider_name, timeout=timeout, context_window=context_window
|
||||
)
|
||||
@@ -1953,8 +1953,20 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
|
||||
if hasattr(capsule, "capture_messages"):
|
||||
capsule.capture_messages(messages)
|
||||
|
||||
# Transport selection is deterministic substrate (T15 PL1
|
||||
# substrate fix): the pinned provider override
|
||||
# (send_task(model='<Provider>/<model>')) requires an
|
||||
# OpenAI-compatible wire for cloud models — OllamaChatTransport
|
||||
# is localhost-only and 400s on any cloud model name. Router,
|
||||
# ladder, and escalation logic are untouched.
|
||||
if routing_mode == "cloud" and "/" in model:
|
||||
from rune.openai_compat_transport import cloud_transport_for
|
||||
|
||||
transport = cloud_transport_for(model, timeout=timeout)
|
||||
else:
|
||||
transport = OllamaChatTransport(timeout=timeout)
|
||||
runtime = LocalToolRuntime(
|
||||
transport=OllamaChatTransport(timeout=timeout),
|
||||
transport=transport,
|
||||
executor=tool_executor.execute_tool,
|
||||
model=model,
|
||||
tools=capability_selection["tools"],
|
||||
|
||||
@@ -8,7 +8,7 @@ mode system, subject header, shared memory and identity.
|
||||
|
||||
This is not an interface. This is how I see, speak, and interact with the world.
|
||||
|
||||
Version: v0.66.14
|
||||
Version: v0.66.16
|
||||
Features: 41 (24 original + 17 new native commands)
|
||||
|
||||
Usage:
|
||||
@@ -1856,7 +1856,7 @@ class RuneApp(App):
|
||||
# Show version info
|
||||
self.chat_channel.add_message(
|
||||
"system",
|
||||
f"[dim]Rune v0.66.14 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
f"[dim]Rune v0.66.16 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
)
|
||||
|
||||
# Auto-show night mode suggestion if evening/night hours
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""OpenAI-compatible transport (T15 PL1 substrate fix).
|
||||
|
||||
The operator had exactly one transport (Ollama) — send_task(model=<cloud>)
|
||||
posted cloud model names to Ollama's /api/chat and 400'd. These tests prove
|
||||
the translation layer both directions; the live wire is smoke-tested
|
||||
separately (provider credentials are not test fixtures)."""
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from rune.openai_compat_transport import OpenAICompatTransport # noqa: E402
|
||||
|
||||
|
||||
class TestMessageTranslation:
|
||||
def test_ollama_to_openai_roundtrip(self):
|
||||
msgs = [
|
||||
{"role": "system", "content": "sys"},
|
||||
{"role": "user", "content": "task"},
|
||||
{"role": "assistant", "content": "", "tool_calls": [
|
||||
{"function": {"name": "edit_file", "arguments": {"path": "/x.py", "old": "a", "new": "b"}}}]},
|
||||
{"role": "tool", "tool_call_id": "call_2_0", "content": "{\"success\": true}"},
|
||||
]
|
||||
out = OpenAICompatTransport._to_openai_messages(msgs)
|
||||
# assistant tool_calls: dict args → JSON string, ids assigned
|
||||
tc = out[2]["tool_calls"][0]
|
||||
assert tc["function"]["arguments"] == json.dumps({"path": "/x.py", "old": "a", "new": "b"})
|
||||
assert tc["id"] == "call_2_0"
|
||||
# tool result keeps its call id
|
||||
assert out[3]["tool_call_id"] == "call_2_0"
|
||||
# plain roles pass through
|
||||
assert out[0] == {"role": "system", "content": "sys"}
|
||||
|
||||
def test_openai_response_to_ollama_shape(self):
|
||||
resp = {"choices": [{"message": {"role": "assistant", "content": None, "tool_calls": [
|
||||
{"id": "z", "type": "function", "function": {"name": "run_tests", "arguments": "{\"path\": \"t\"}"}}]}}]}
|
||||
shape = OpenAICompatTransport._from_openai_response(resp)
|
||||
tc = shape["message"]["tool_calls"][0]
|
||||
assert tc["function"]["name"] == "run_tests"
|
||||
assert tc["function"]["arguments"] == {"path": "t"} # string → dict
|
||||
|
||||
def test_malformed_arguments_become_raw(self):
|
||||
resp = {"choices": [{"message": {"tool_calls": [
|
||||
{"function": {"name": "x", "arguments": "not-json"}}]}}]}
|
||||
shape = OpenAICompatTransport._from_openai_response(resp)
|
||||
assert shape["message"]["tool_calls"][0]["function"]["arguments"] == {"_raw": "not-json"}
|
||||
|
||||
def test_no_tool_calls_plain_content(self):
|
||||
resp = {"choices": [{"message": {"content": "hello"}}]}
|
||||
shape = OpenAICompatTransport._from_openai_response(resp)
|
||||
assert shape["message"]["content"] == "hello"
|
||||
assert "tool_calls" not in shape["message"]
|
||||
|
||||
def test_protocol_methods(self):
|
||||
t = OpenAICompatTransport("http://x/v1", "k", "Test")
|
||||
assert t.supports_tools("m") is True
|
||||
assert t.supports_thinking("m") is False
|
||||
assert t.context_length("m") == 131072
|
||||
|
||||
def test_usage_accumulators(self):
|
||||
t = OpenAICompatTransport("http://x/v1", "k", "Test")
|
||||
t.chat = lambda **kw: ( # simulate the accumulator path
|
||||
setattr(t, "usage_calls", t.usage_calls + 1),
|
||||
setattr(t, "usage_prompt_tokens", t.usage_prompt_tokens + 10),
|
||||
setattr(t, "usage_output_tokens", t.usage_output_tokens + 5),
|
||||
)[-1]
|
||||
t.chat(model="m", messages=[])
|
||||
assert t.usage_calls == 1 and t.usage_prompt_tokens == 10 and t.usage_output_tokens == 5
|
||||
|
||||
|
||||
def test_qualified_model_name_sent_as_bare_id(self):
|
||||
"""Runtime passes '<Provider>/<model>'; the wire must carry the bare
|
||||
model id (observed live: qualified name → 400 on every call)."""
|
||||
captured = {}
|
||||
|
||||
def fake_post(path, payload):
|
||||
captured.update(payload)
|
||||
return {"choices": [{"message": {"role": "assistant", "content": "ok"}}], "usage": {}}
|
||||
|
||||
t = OpenAICompatTransport("http://x/v1", "k", "Verboo Code")
|
||||
t._post = fake_post
|
||||
t.chat(model="Verboo Code/glm-5.3-flash", messages=[{"role": "user", "content": "hi"}], tools=None)
|
||||
assert captured["model"] == "glm-5.3-flash"
|
||||
@@ -1 +0,0 @@
|
||||
def broken(:
|
||||
Reference in new issue
Block a user