Bootstrap rein-openweights: minimal agentic loop over OpenRouter (REIN-OW-WP-0001)

Implements T01-T04:
- tools.py: green-commit-only-equivalent tool surface (read/write/edit/
  glob/grep + git status/diff/log/add+commit), path-traversal guarded.
- openrouter_client.py: own minimal chat-completions client — llm-connect's
  OpenRouterAdapter takes a single prompt string and never surfaces
  tool_calls, so it can't drive a multi-turn tool-calling loop without a
  breaking change to its frozen Core ABC. llm-connect stays an optional
  dependency (pyproject.toml), not load-bearing.
- loop.py: plan -> tool call -> observe -> repeat, budget- and
  turn-bounded, tool errors reported back to the model instead of
  crashing the loop.
- credentials.py: own OpenBao AppRole/ambient-token acquisition, per
  glas-harness ADR-002 (Option B) — glas-harness does not broker this.
- runner.py/hub.py: commit-verified success criterion + State Hub
  progress/token reporting, mirroring rein-aharness's model.

26 tests, all mocked at the httpx/subprocess boundary — no real
OpenRouter or OpenBao calls made. T05 (Forgejo repo creation) stays
open, deferred to the operator.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-07-26 13:37:09 +02:00
parent ac5407a83e
commit 4ee612140b
21 changed files with 1207 additions and 36 deletions

View file

View file

@ -0,0 +1,32 @@
"""Minimal token budget tracker.
llm-connect ships a near-identical `BudgetTracker`; rein-openweights
vendors its own so the base agentic loop never requires llm-connect as
a hard dependency (glas-harness ADR-002: llm-connect stays optional,
not load-bearing here).
"""
from __future__ import annotations
class BudgetExceededError(RuntimeError):
pass
class BudgetTracker:
def __init__(self, total: int) -> None:
if total <= 0:
raise ValueError(f"BudgetTracker total must be positive, got {total}")
self.total = total
self.spent = 0
def remaining(self) -> int:
return max(0, self.total - self.spent)
def consume(self, tokens: int) -> None:
new_spent = self.spent + tokens
if new_spent > self.total:
raise BudgetExceededError(
f"Token budget exceeded: {new_spent} tokens used, cap is {self.total}"
)
self.spent = new_spent

View file

@ -0,0 +1,41 @@
"""rein-openweights CLI."""
from __future__ import annotations
import argparse
import json
import sys
from dataclasses import asdict
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="rein-openweights")
sub = parser.add_subparsers(dest="command", required=True)
run = sub.add_parser("run", help="Run one task via the OpenRouter agentic loop")
run.add_argument("--task-file", required=True)
run.add_argument("--model", default=None)
run.add_argument("--max-turns", type=int, default=20)
run.add_argument("--budget-tokens", type=int, default=60_000)
run.add_argument("--no-hub", action="store_true", help="Skip hub reporting")
args = parser.parse_args(argv)
if args.command == "run":
from rein_openweights.runner import DEFAULT_MODEL, run_task
result = run_task(
args.task_file,
model=args.model or DEFAULT_MODEL,
max_turns=args.max_turns,
budget_tokens=args.budget_tokens,
report_to_hub=not args.no_hub,
)
print(json.dumps(asdict(result), indent=2))
return 0 if result.ok else 1
return 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,75 @@
"""OpenRouter credential acquisition via OpenBao.
Per glas-harness ADR-002 (Option B): rein-openweights acquires its own
credential, consistent with rein-aharness's principle that the harness
is the only credential holder. glas-harness never brokers this.
Mirrors rein-aharness's mailscan.py pattern: AppRole (unattended lane)
first, then an ambient bao token (operator lane), then a single kv read.
"""
from __future__ import annotations
import os
import subprocess
from pathlib import Path
_KV_PATH_ENV = "REIN_OPENWEIGHTS_OPENROUTER_KV_PATH"
_DEFAULT_KV_PATH = "reins/rein-openweights/openrouter"
_KV_FIELD = "api_key"
_BAO_TIMEOUT = 30
class CredentialError(RuntimeError):
pass
def _bao(*args: str, env: dict[str, str] | None = None) -> str:
result = subprocess.run(
["bao", *args],
capture_output=True,
text=True,
timeout=_BAO_TIMEOUT,
env=env or os.environ.copy(),
)
if result.returncode != 0:
raise CredentialError(f"bao {args[0]} failed: {result.stderr.strip()[:200]}")
return result.stdout.strip()
def _acquire_token() -> str | None:
approle_dir = os.environ.get("REIN_OPENWEIGHTS_APPROLE_DIR")
if not approle_dir:
return None
role_id_file = Path(approle_dir) / "role_id"
secret_id_file = Path(approle_dir) / "secret_id"
if not (role_id_file.is_file() and secret_id_file.is_file()):
return None
return _bao(
"write",
"-field=token",
"auth/approle/login",
f"role_id={role_id_file.read_text().strip()}",
f"secret_id={secret_id_file.read_text().strip()}",
)
def resolve_openrouter_api_key() -> str | None:
"""Resolution order: explicit env var, then OpenBao (AppRole or ambient token)."""
explicit = os.environ.get("OPENROUTER_API_KEY")
if explicit:
return explicit
kv_path = os.environ.get(_KV_PATH_ENV, _DEFAULT_KV_PATH)
try:
token = _acquire_token()
except CredentialError:
return None
env = os.environ.copy()
if token:
env["BAO_TOKEN"] = token
try:
return _bao("kv", "get", f"-field={_KV_FIELD}", kv_path, env=env)
except CredentialError:
return None

View file

@ -0,0 +1,65 @@
"""Custodian State Hub reporting (REST, no MCP) — mirrors rein-aharness's hub.py."""
from __future__ import annotations
import os
from typing import Any
import httpx
_DEFAULT_URL = "http://127.0.0.1:8000"
_TIMEOUT = 10.0
def _base_url() -> str:
return os.environ.get("STATE_HUB_URL", _DEFAULT_URL).rstrip("/")
def post_progress_event(
summary: str,
event_type: str,
detail: dict[str, Any],
task_id: str | None = None,
) -> bool:
payload: dict[str, Any] = {
"summary": summary,
"event_type": event_type,
"detail": detail,
"author": "agt-rein-openweights",
}
if task_id:
payload["task_id"] = task_id
try:
resp = httpx.post(f"{_base_url()}/progress/", json=payload, timeout=_TIMEOUT)
resp.raise_for_status()
return True
except httpx.HTTPError:
return False
def post_token_event(
repo: str,
tokens: int,
*,
budget: int | None = None,
ok: bool = True,
detail: dict[str, Any] | None = None,
) -> bool:
payload: dict[str, Any] = {
"repo": repo,
"tokens": tokens,
"source": "rein-openweights",
"ok": ok,
}
if budget is not None:
payload["budget"] = budget
if detail:
payload["detail"] = detail
try:
resp = httpx.post(f"{_base_url()}/token-events/upsert", json=payload, timeout=_TIMEOUT)
if resp.status_code >= 400:
resp = httpx.post(f"{_base_url()}/token-events/", json=payload, timeout=_TIMEOUT)
resp.raise_for_status()
return True
except httpx.HTTPError:
return False

View file

@ -0,0 +1,75 @@
"""Minimal agentic tool-use loop: plan -> tool call -> observe -> repeat."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from rein_openweights.budget import BudgetExceededError, BudgetTracker
from rein_openweights.openrouter_client import OpenRouterClient
from rein_openweights.tools import call_tool, openai_tool_schemas
_SYSTEM_PROMPT = (
"You are a bounded coding agent. You may only act through the provided "
"tools. When the task is complete, call git_add_commit with a "
"descriptive message, then reply with a final message containing the "
"word DONE and no further tool calls."
)
@dataclass
class LoopResult:
turns: int
finished: bool
transcript: list[dict[str, Any]] = field(default_factory=list)
error: str | None = None
def run_loop(
client: OpenRouterClient,
repo_root: Path,
title: str,
description: str,
*,
max_turns: int = 20,
budget: BudgetTracker | None = None,
) -> LoopResult:
messages: list[dict[str, Any]] = [
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": f"# {title}\n\n{description}"},
]
tools = openai_tool_schemas()
for turn in range(1, max_turns + 1):
data = client.chat(messages, tools=tools)
if budget is not None:
usage = data.get("usage", {})
try:
budget.consume(usage.get("total_tokens", 0))
except BudgetExceededError as exc:
return LoopResult(turns=turn, finished=False, transcript=messages, error=str(exc))
message = data["choices"][0]["message"]
messages.append(message)
tool_calls = message.get("tool_calls") or []
if not tool_calls:
finished = "DONE" in (message.get("content") or "")
return LoopResult(turns=turn, finished=finished, transcript=messages)
for call in tool_calls:
fn = call["function"]
name = fn["name"]
try:
args = json.loads(fn.get("arguments") or "{}")
result = call_tool(repo_root, name, args)
except Exception as exc: # noqa: BLE001 - report failure to the model, keep looping
result = f"error: {exc}"
messages.append(
{"role": "tool", "tool_call_id": call.get("id", name), "content": result}
)
return LoopResult(turns=max_turns, finished=False, transcript=messages, error="max_turns exceeded")

View file

@ -0,0 +1,64 @@
"""Minimal, independent OpenRouter chat-completions client.
Deliberately does not depend on llm-connect's `OpenRouterAdapter`: its
`execute_prompt(prompt: str, config)` takes a single prompt string,
builds a fixed [system, user] message pair itself, and never surfaces
`message.tool_calls` in its response it cannot drive a multi-turn
tool-calling loop without a breaking change to llm-connect's frozen
Core `LLMAdapter` ABC (`execute_prompt` signature). Rather than force
that change onto every llm-connect consumer, rein-openweights owns this
thin transport directly. See glas-harness ADR-002.
"""
from __future__ import annotations
from typing import Any
import httpx
_DEFAULT_API_BASE = "https://openrouter.ai/api/v1"
class OpenRouterError(RuntimeError):
pass
class OpenRouterClient:
def __init__(
self,
api_key: str,
model: str,
api_base: str = _DEFAULT_API_BASE,
timeout: float = 120.0,
) -> None:
self.api_key = api_key
self.model = model
self.api_base = api_base.rstrip("/")
self.timeout = timeout
def chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
temperature: float = 0.2,
max_tokens: int = 4096,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"model": self.model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
if tools:
payload["tools"] = tools
payload["tool_choice"] = "auto"
response = httpx.post(
f"{self.api_base}/chat/completions",
json=payload,
headers={"Authorization": f"Bearer {self.api_key}"},
timeout=self.timeout,
)
if response.status_code >= 400:
raise OpenRouterError(f"OpenRouter {response.status_code}: {response.text[:300]}")
return response.json()

View file

@ -0,0 +1,93 @@
"""rein-openweights run — bounded agentic session, commit-verified.
Success criterion mirrors rein-aharness: a local git commit landing is
the sole success signal. Reports a State Hub progress + token event on
completion unless --no-hub is set.
"""
from __future__ import annotations
import subprocess
from dataclasses import asdict, dataclass
from pathlib import Path
from rein_openweights import hub
from rein_openweights.budget import BudgetTracker
from rein_openweights.credentials import resolve_openrouter_api_key
from rein_openweights.loop import run_loop
from rein_openweights.openrouter_client import OpenRouterClient
from rein_openweights.taskspec import TaskSpec
DEFAULT_MODEL = "meta-llama/llama-3.1-70b-instruct"
@dataclass
class RunResult:
ok: bool
committed: bool
commit_sha: str
turns: int
tokens_spent: int
reason: str = ""
def _git_head(repo: Path) -> str:
proc = subprocess.run(
["git", "-C", str(repo), "rev-parse", "HEAD"], capture_output=True, text=True
)
return proc.stdout.strip() if proc.returncode == 0 else ""
def run_task(
task_file: str,
*,
model: str = DEFAULT_MODEL,
max_turns: int = 20,
budget_tokens: int = 60_000,
api_key: str | None = None,
report_to_hub: bool = True,
) -> RunResult:
task = TaskSpec.load(task_file)
repo = Path(task.target_repo).expanduser().resolve()
head_before = _git_head(repo)
key = api_key or resolve_openrouter_api_key()
if not key:
result = RunResult(
ok=False, committed=False, commit_sha="", turns=0, tokens_spent=0,
reason="no OpenRouter credential resolved",
)
_report(result, task, report_to_hub)
return result
client = OpenRouterClient(api_key=key, model=model)
budget = BudgetTracker(total=budget_tokens)
loop_result = run_loop(
client, repo, task.title, task.description, max_turns=max_turns, budget=budget
)
head_after = _git_head(repo)
committed = bool(head_after) and head_after != head_before
result = RunResult(
ok=committed,
committed=committed,
commit_sha=head_after,
turns=loop_result.turns,
tokens_spent=budget.spent,
reason=loop_result.error or ("" if committed else "no commit produced"),
)
_report(result, task, report_to_hub)
return result
def _report(result: RunResult, task: TaskSpec, report_to_hub: bool) -> None:
if not report_to_hub:
return
hub.post_progress_event(
summary=f"rein-openweights run {'ok' if result.ok else 'failed'}: {task.title}",
event_type="executor_run",
detail=asdict(result),
)
hub.post_token_event(repo=task.target_repo, tokens=result.tokens_spent, ok=result.ok)

View file

@ -0,0 +1,29 @@
"""TaskSpec — local JSON task definition, mirrors rein-aharness's taskspec.py."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
@dataclass
class TaskSpec:
title: str
description: str
target_repo: str
agent: str = "rein-openweights"
labels: list[str] = field(default_factory=list)
timeout_seconds: int = 600
@classmethod
def load(cls, path: str) -> TaskSpec:
data = json.loads(Path(path).expanduser().read_text())
return cls(
title=data["title"],
description=data["description"],
target_repo=data["target_repo"],
agent=data.get("agent", "rein-openweights"),
labels=data.get("labels", []),
timeout_seconds=data.get("timeout_seconds", 600),
)

View file

@ -0,0 +1,187 @@
"""Tool surface — mirrors rein-aharness's `green-commit-only` allow-list.
Read/Write/Edit/Glob/Grep + local git status/diff/log/add+commit. No
push, no network, no shell escape hatch the same bounded surface, just
invoked by a different rein.
"""
from __future__ import annotations
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
class ToolPolicyError(RuntimeError):
pass
def _resolve(repo_root: Path, rel_path: str) -> Path:
repo_root = repo_root.resolve()
candidate = (repo_root / rel_path).resolve()
if candidate != repo_root and repo_root not in candidate.parents:
raise ToolPolicyError(f"path escapes repo root: {rel_path}")
return candidate
def read_file(repo_root: Path, path: str) -> str:
return _resolve(repo_root, path).read_text()
def write_file(repo_root: Path, path: str, content: str) -> str:
target = _resolve(repo_root, path)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content)
return f"wrote {len(content)} bytes to {path}"
def edit_file(repo_root: Path, path: str, old: str, new: str) -> str:
target = _resolve(repo_root, path)
text = target.read_text()
if old not in text:
raise ToolPolicyError(f"old string not found in {path}")
target.write_text(text.replace(old, new, 1))
return f"replaced 1 occurrence in {path}"
def glob_files(repo_root: Path, pattern: str) -> str:
repo_root = repo_root.resolve()
matches = sorted(str(p.relative_to(repo_root)) for p in repo_root.rglob(pattern) if p.is_file())
return "\n".join(matches) or "(no matches)"
def grep_files(repo_root: Path, pattern: str, path: str = ".") -> str:
base = _resolve(repo_root, path)
hits: list[str] = []
for file in sorted(base.rglob("*")):
if not file.is_file() or ".git" in file.parts:
continue
try:
text = file.read_text()
except (UnicodeDecodeError, OSError):
continue
for lineno, line in enumerate(text.splitlines(), start=1):
if pattern in line:
hits.append(f"{file.relative_to(repo_root)}:{lineno}:{line}")
return "\n".join(hits) or "(no matches)"
def _git(repo_root: Path, *args: str) -> str:
proc = subprocess.run(["git", "-C", str(repo_root), *args], capture_output=True, text=True)
if proc.returncode != 0:
raise ToolPolicyError(f"git {' '.join(args)} failed: {proc.stderr.strip()}")
return proc.stdout
def git_status(repo_root: Path) -> str:
return _git(repo_root, "status", "--short") or "(clean)"
def git_diff(repo_root: Path) -> str:
return _git(repo_root, "diff") or "(no diff)"
def git_log(repo_root: Path, max_count: int = 10) -> str:
return _git(repo_root, "log", f"-{max_count}", "--oneline")
def git_add_commit(repo_root: Path, message: str) -> str:
_git(repo_root, "add", "-A")
return _git(repo_root, "commit", "-m", message)
@dataclass
class Tool:
name: str
description: str
parameters: dict[str, Any]
fn: Callable[..., str]
TOOLS: list[Tool] = [
Tool(
"read_file",
"Read a file's contents.",
{"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]},
read_file,
),
Tool(
"write_file",
"Write (overwrite) a file's contents, creating parent dirs as needed.",
{
"type": "object",
"properties": {"path": {"type": "string"}, "content": {"type": "string"}},
"required": ["path", "content"],
},
write_file,
),
Tool(
"edit_file",
"Replace the first occurrence of `old` with `new` in a file.",
{
"type": "object",
"properties": {
"path": {"type": "string"},
"old": {"type": "string"},
"new": {"type": "string"},
},
"required": ["path", "old", "new"],
},
edit_file,
),
Tool(
"glob_files",
"List files under the repo matching a glob pattern (e.g. '**/*.py').",
{"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]},
glob_files,
),
Tool(
"grep_files",
"Search file contents under `path` for a literal substring.",
{
"type": "object",
"properties": {"pattern": {"type": "string"}, "path": {"type": "string"}},
"required": ["pattern"],
},
grep_files,
),
Tool("git_status", "Show `git status --short`.", {"type": "object", "properties": {}}, git_status),
Tool("git_diff", "Show `git diff`.", {"type": "object", "properties": {}}, git_diff),
Tool(
"git_log",
"Show recent commit log (oneline).",
{"type": "object", "properties": {"max_count": {"type": "integer"}}},
git_log,
),
Tool(
"git_add_commit",
"Stage all changes (`git add -A`) and commit with a message. "
"Call this once the task is complete.",
{"type": "object", "properties": {"message": {"type": "string"}}, "required": ["message"]},
git_add_commit,
),
]
TOOLS_BY_NAME = {t.name: t for t in TOOLS}
def openai_tool_schemas() -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": t.name,
"description": t.description,
"parameters": t.parameters,
},
}
for t in TOOLS
]
def call_tool(repo_root: Path, name: str, args: dict[str, Any]) -> str:
tool = TOOLS_BY_NAME.get(name)
if tool is None:
raise ToolPolicyError(f"unknown tool: {name}")
return tool.fn(repo_root, **args)