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

54
tests/test_credentials.py Normal file
View file

@ -0,0 +1,54 @@
from unittest.mock import patch
from rein_openweights.credentials import resolve_openrouter_api_key
def test_explicit_env_var_short_circuits(monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-explicit")
assert resolve_openrouter_api_key() == "sk-explicit"
def test_falls_back_to_bao_kv_when_no_approle(monkeypatch):
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.delenv("REIN_OPENWEIGHTS_APPROLE_DIR", raising=False)
with patch("rein_openweights.credentials._bao", return_value="sk-from-vault") as bao:
key = resolve_openrouter_api_key()
assert key == "sk-from-vault"
bao.assert_called_once()
assert bao.call_args.args[0] == "kv"
def test_approle_lane_exchanges_token_before_kv_read(monkeypatch, tmp_path):
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
role_id = tmp_path / "role_id"
secret_id = tmp_path / "secret_id"
role_id.write_text("role-123")
secret_id.write_text("secret-456")
monkeypatch.setenv("REIN_OPENWEIGHTS_APPROLE_DIR", str(tmp_path))
calls = []
def fake_bao(*args, env=None):
calls.append(args)
if args[0] == "write":
return "vault-token"
return "sk-from-vault"
with patch("rein_openweights.credentials._bao", side_effect=fake_bao):
key = resolve_openrouter_api_key()
assert key == "sk-from-vault"
assert calls[0][0] == "write"
assert calls[1][0] == "kv"
def test_returns_none_when_bao_fails(monkeypatch):
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.delenv("REIN_OPENWEIGHTS_APPROLE_DIR", raising=False)
from rein_openweights.credentials import CredentialError
with patch("rein_openweights.credentials._bao", side_effect=CredentialError("boom")):
assert resolve_openrouter_api_key() is None

118
tests/test_loop.py Normal file
View file

@ -0,0 +1,118 @@
import subprocess
from unittest.mock import MagicMock
from rein_openweights.budget import BudgetTracker
from rein_openweights.loop import run_loop
def _init_repo(tmp_path):
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
subprocess.run(["git", "config", "user.email", "t@example.com"], cwd=tmp_path, check=True)
subprocess.run(["git", "config", "user.name", "t"], cwd=tmp_path, check=True)
subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "init"], cwd=tmp_path, check=True)
return tmp_path
def _response(message, total_tokens=100):
return {
"choices": [{"message": message}],
"usage": {"total_tokens": total_tokens},
}
def test_run_loop_executes_tool_call_then_finishes(tmp_path):
repo = _init_repo(tmp_path)
client = MagicMock()
client.chat.side_effect = [
_response(
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"function": {
"name": "write_file",
"arguments": '{"path": "NOTES.md", "content": "hi"}',
},
}
],
}
),
_response(
{
"role": "assistant",
"tool_calls": [
{
"id": "call_2",
"function": {
"name": "git_add_commit",
"arguments": '{"message": "add notes"}',
},
}
],
}
),
_response({"role": "assistant", "content": "All set. DONE"}),
]
result = run_loop(client, repo, "t", "d", budget=BudgetTracker(total=10_000))
assert result.finished is True
assert result.turns == 3
assert (repo / "NOTES.md").read_text() == "hi"
log = subprocess.run(
["git", "-C", str(repo), "log", "--oneline"], capture_output=True, text=True
).stdout
assert "add notes" in log
def test_run_loop_stops_when_budget_exceeded(tmp_path):
repo = _init_repo(tmp_path)
client = MagicMock()
client.chat.return_value = _response({"role": "assistant", "content": "still going"}, total_tokens=100)
result = run_loop(client, repo, "t", "d", budget=BudgetTracker(total=50))
assert result.error is not None
assert "budget" in result.error.lower()
def test_run_loop_stops_at_max_turns(tmp_path):
repo = _init_repo(tmp_path)
client = MagicMock()
client.chat.return_value = _response(
{
"role": "assistant",
"tool_calls": [
{"id": "c", "function": {"name": "git_status", "arguments": "{}"}}
],
}
)
result = run_loop(client, repo, "t", "d", max_turns=2)
assert result.turns == 2
assert result.error == "max_turns exceeded"
def test_run_loop_reports_tool_error_without_crashing(tmp_path):
repo = _init_repo(tmp_path)
client = MagicMock()
client.chat.side_effect = [
_response(
{
"role": "assistant",
"tool_calls": [
{"id": "c", "function": {"name": "read_file", "arguments": '{"path": "missing.txt"}'}}
],
}
),
_response({"role": "assistant", "content": "done anyway DONE"}),
]
result = run_loop(client, repo, "t", "d")
assert result.finished is True
tool_messages = [m for m in result.transcript if m.get("role") == "tool"]
assert "error" in tool_messages[0]["content"]

View file

@ -0,0 +1,43 @@
from unittest.mock import MagicMock, patch
import pytest
from rein_openweights.openrouter_client import OpenRouterClient, OpenRouterError
def test_chat_sends_bearer_auth_and_model():
client = OpenRouterClient(api_key="sk-test", model="meta-llama/llama-3.1-70b-instruct")
fake_response = MagicMock(status_code=200)
fake_response.json.return_value = {"choices": [{"message": {"content": "hi"}}]}
with patch("rein_openweights.openrouter_client.httpx.post", return_value=fake_response) as post:
data = client.chat([{"role": "user", "content": "hello"}])
assert data["choices"][0]["message"]["content"] == "hi"
_, kwargs = post.call_args
assert kwargs["headers"]["Authorization"] == "Bearer sk-test"
assert kwargs["json"]["model"] == "meta-llama/llama-3.1-70b-instruct"
assert "tools" not in kwargs["json"]
def test_chat_includes_tools_when_provided():
client = OpenRouterClient(api_key="sk-test", model="m")
fake_response = MagicMock(status_code=200)
fake_response.json.return_value = {"choices": [{"message": {}}]}
tools = [{"type": "function", "function": {"name": "read_file"}}]
with patch("rein_openweights.openrouter_client.httpx.post", return_value=fake_response) as post:
client.chat([{"role": "user", "content": "x"}], tools=tools)
_, kwargs = post.call_args
assert kwargs["json"]["tools"] == tools
assert kwargs["json"]["tool_choice"] == "auto"
def test_chat_raises_on_http_error():
client = OpenRouterClient(api_key="sk-test", model="m")
fake_response = MagicMock(status_code=401, text="unauthorized")
with patch("rein_openweights.openrouter_client.httpx.post", return_value=fake_response):
with pytest.raises(OpenRouterError, match="401"):
client.chat([{"role": "user", "content": "x"}])

75
tests/test_runner.py Normal file
View file

@ -0,0 +1,75 @@
import json
import subprocess
from unittest.mock import patch
from rein_openweights.loop import LoopResult
from rein_openweights.runner import run_task
def _init_repo(tmp_path):
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
subprocess.run(["git", "config", "user.email", "t@example.com"], cwd=tmp_path, check=True)
subprocess.run(["git", "config", "user.name", "t"], cwd=tmp_path, check=True)
subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "init"], cwd=tmp_path, check=True)
return tmp_path
def _task_file(tmp_path, repo):
task = tmp_path / "task.json"
task.write_text(
json.dumps({"title": "t", "description": "d", "target_repo": str(repo)})
)
return str(task)
def test_run_task_ok_when_loop_produces_a_commit(tmp_path):
repo = _init_repo(tmp_path)
task_file = _task_file(tmp_path, repo)
def fake_run_loop(client, repo_root, title, description, *, max_turns, budget):
subprocess.run(["git", "-C", str(repo_root), "commit", "-q", "--allow-empty", "-m", "task"])
budget.consume(500)
return LoopResult(turns=2, finished=True)
with (
patch("rein_openweights.runner.resolve_openrouter_api_key", return_value="sk-test"),
patch("rein_openweights.runner.run_loop", side_effect=fake_run_loop),
patch("rein_openweights.runner.hub.post_progress_event", return_value=True),
patch("rein_openweights.runner.hub.post_token_event", return_value=True),
):
result = run_task(task_file)
assert result.ok is True
assert result.committed is True
assert result.tokens_spent == 500
def test_run_task_fails_when_no_commit_produced(tmp_path):
repo = _init_repo(tmp_path)
task_file = _task_file(tmp_path, repo)
with (
patch("rein_openweights.runner.resolve_openrouter_api_key", return_value="sk-test"),
patch(
"rein_openweights.runner.run_loop",
return_value=LoopResult(turns=1, finished=True),
),
patch("rein_openweights.runner.hub.post_progress_event", return_value=True),
patch("rein_openweights.runner.hub.post_token_event", return_value=True),
):
result = run_task(task_file)
assert result.ok is False
assert result.committed is False
assert result.reason == "no commit produced"
def test_run_task_fails_fast_without_credential(tmp_path):
repo = _init_repo(tmp_path)
task_file = _task_file(tmp_path, repo)
with patch("rein_openweights.runner.resolve_openrouter_api_key", return_value=None):
result = run_task(task_file, report_to_hub=False)
assert result.ok is False
assert "credential" in result.reason

121
tests/test_tools.py Normal file
View file

@ -0,0 +1,121 @@
import subprocess
import pytest
from rein_openweights.tools import (
ToolPolicyError,
call_tool,
edit_file,
git_add_commit,
git_diff,
git_log,
git_status,
glob_files,
grep_files,
openai_tool_schemas,
read_file,
write_file,
)
def _init_repo(tmp_path):
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
subprocess.run(["git", "config", "user.email", "t@example.com"], cwd=tmp_path, check=True)
subprocess.run(["git", "config", "user.name", "t"], cwd=tmp_path, check=True)
subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "init"], cwd=tmp_path, check=True)
return tmp_path
def test_write_then_read_file(tmp_path):
repo = _init_repo(tmp_path)
write_file(repo, "a.txt", "hello")
assert read_file(repo, "a.txt") == "hello"
def test_edit_file_replaces_first_occurrence(tmp_path):
repo = _init_repo(tmp_path)
write_file(repo, "a.txt", "foo foo")
edit_file(repo, "a.txt", "foo", "bar")
assert read_file(repo, "a.txt") == "bar foo"
def test_edit_file_missing_old_raises(tmp_path):
repo = _init_repo(tmp_path)
write_file(repo, "a.txt", "hello")
with pytest.raises(ToolPolicyError, match="old string not found"):
edit_file(repo, "a.txt", "nope", "x")
def test_path_traversal_is_blocked(tmp_path):
repo = _init_repo(tmp_path)
with pytest.raises(ToolPolicyError, match="escapes repo root"):
read_file(repo, "../outside.txt")
def test_glob_files(tmp_path):
repo = _init_repo(tmp_path)
write_file(repo, "src/a.py", "1")
write_file(repo, "src/b.py", "2")
write_file(repo, "src/c.txt", "3")
result = glob_files(repo, "**/*.py")
assert "src/a.py" in result
assert "src/b.py" in result
assert "src/c.txt" not in result
def test_grep_files(tmp_path):
repo = _init_repo(tmp_path)
write_file(repo, "a.txt", "hello world\nneedle here\n")
result = grep_files(repo, "needle")
assert "a.txt:2:needle here" in result
def test_grep_files_no_matches(tmp_path):
repo = _init_repo(tmp_path)
write_file(repo, "a.txt", "hello")
assert grep_files(repo, "zzz") == "(no matches)"
def test_git_status_diff_log_and_commit(tmp_path):
repo = _init_repo(tmp_path)
write_file(repo, "a.txt", "hello")
assert "a.txt" in git_status(repo)
git_add_commit(repo, "add a.txt")
assert git_status(repo) == "(clean)"
assert "add a.txt" in git_log(repo)
def test_git_diff_after_edit(tmp_path):
repo = _init_repo(tmp_path)
write_file(repo, "a.txt", "hello")
git_add_commit(repo, "add a.txt")
edit_file(repo, "a.txt", "hello", "goodbye")
assert "goodbye" in git_diff(repo)
def test_call_tool_dispatches_by_name(tmp_path):
repo = _init_repo(tmp_path)
call_tool(repo, "write_file", {"path": "a.txt", "content": "x"})
assert read_file(repo, "a.txt") == "x"
def test_call_tool_unknown_name_raises(tmp_path):
repo = _init_repo(tmp_path)
with pytest.raises(ToolPolicyError, match="unknown tool"):
call_tool(repo, "delete_everything", {})
def test_openai_tool_schemas_cover_all_tools():
schemas = openai_tool_schemas()
names = {s["function"]["name"] for s in schemas}
assert names == {
"read_file",
"write_file",
"edit_file",
"glob_files",
"grep_files",
"git_status",
"git_diff",
"git_log",
"git_add_commit",
}