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:
parent
ac5407a83e
commit
4ee612140b
21 changed files with 1207 additions and 36 deletions
54
tests/test_credentials.py
Normal file
54
tests/test_credentials.py
Normal 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
118
tests/test_loop.py
Normal 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"]
|
||||
43
tests/test_openrouter_client.py
Normal file
43
tests/test_openrouter_client.py
Normal 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
75
tests/test_runner.py
Normal 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
121
tests/test_tools.py
Normal 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",
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue