Add ReinOpenWeights adapter, closing GLAS-WP-0001-T05
glas_harness/reins/rein_openweights.py implements the Rein ABC by shelling out to `rein-openweights run --task-file ... --no-hub [--model ...]`, mirroring reins/rein_aharness.py exactly. Factored the duplicated git_head/write_task_file logic into reins/_shared.py, used by both adapters now. registry/reins/rein-openweights.yaml flipped to status: implemented. 8 new tests (mocked subprocess), 18/18 passing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
4bdf8de950
commit
266f99a2fe
8 changed files with 238 additions and 43 deletions
|
|
@ -17,5 +17,5 @@
|
||||||
| task | GLAS-WP-0001-T02 | done | — | workplans/GLAS-WP-0001-harness-router-foundation.md |
|
| task | GLAS-WP-0001-T02 | done | — | workplans/GLAS-WP-0001-harness-router-foundation.md |
|
||||||
| task | GLAS-WP-0001-T03 | done | — | workplans/GLAS-WP-0001-harness-router-foundation.md |
|
| task | GLAS-WP-0001-T03 | done | — | workplans/GLAS-WP-0001-harness-router-foundation.md |
|
||||||
| task | GLAS-WP-0001-T04 | progress | — | workplans/GLAS-WP-0001-harness-router-foundation.md |
|
| task | GLAS-WP-0001-T04 | progress | — | workplans/GLAS-WP-0001-harness-router-foundation.md |
|
||||||
| task | GLAS-WP-0001-T05 | todo | — | workplans/GLAS-WP-0001-harness-router-foundation.md |
|
| task | GLAS-WP-0001-T05 | progress | — | workplans/GLAS-WP-0001-harness-router-foundation.md |
|
||||||
| task | GLAS-WP-0001-T06 | done | — | workplans/GLAS-WP-0001-harness-router-foundation.md |
|
| task | GLAS-WP-0001-T06 | done | — | workplans/GLAS-WP-0001-harness-router-foundation.md |
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,15 @@ id: rein-openweights
|
||||||
title: OpenRouter open-weight agentic loop
|
title: OpenRouter open-weight agentic loop
|
||||||
description: >
|
description: >
|
||||||
Agentic tool-use harness driving current open-weight models via
|
Agentic tool-use harness driving current open-weight models via
|
||||||
OpenRouter/llm-connect, as an alternative to frontier-vendor CLIs.
|
OpenRouter (own minimal client, not llm-connect — see
|
||||||
Chartered by ADR-001; not yet implementing this contract.
|
rein-openweights/workplans/REIN-OW-WP-0001-T01), as an alternative to
|
||||||
|
frontier-vendor CLIs. Chartered by ADR-001.
|
||||||
handler: glas_harness.reins.rein_openweights:ReinOpenWeights
|
handler: glas_harness.reins.rein_openweights:ReinOpenWeights
|
||||||
capabilities:
|
capabilities:
|
||||||
session_style: unattended
|
session_style: unattended
|
||||||
model_class: open-weight
|
model_class: open-weight
|
||||||
credential_source: tbd
|
credential_source: rein-local
|
||||||
# tbd: resolved by GLAS-WP-0001-T06 before this rein is implemented.
|
# rein-local per ADR-002 (Option B): rein-openweights acquires its own
|
||||||
status: planned
|
# OpenRouter credential (OpenBao, or OPENROUTER_API_KEY env var).
|
||||||
|
# glas-harness does not broker it.
|
||||||
|
status: implemented
|
||||||
|
|
|
||||||
28
src/glas_harness/reins/_shared.py
Normal file
28
src/glas_harness/reins/_shared.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""Shared helpers for reins that implement the contract by shelling out to a CLI."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def git_head(repo: str) -> str:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["git", "-C", str(Path(repo).expanduser()), "rev-parse", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return proc.stdout.strip() if proc.returncode == 0 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def write_task_file(title: str, description: str, target_repo: str, **extra: Any) -> str:
|
||||||
|
task_spec = {"title": title, "description": description, "target_repo": target_repo, **extra}
|
||||||
|
fd = tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".json", prefix="glas-harness-task-", delete=False
|
||||||
|
)
|
||||||
|
json.dump(task_spec, fd)
|
||||||
|
fd.close()
|
||||||
|
return fd.name
|
||||||
|
|
@ -12,14 +12,12 @@ end + sandbox handoff), not fine-grained tool interception yet.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from glas_harness.contract import Rein, SandboxHandle, ToolCall, ToolResult
|
from glas_harness.contract import Rein, SandboxHandle, ToolCall, ToolResult
|
||||||
|
from glas_harness.reins._shared import git_head, write_task_file
|
||||||
|
|
||||||
|
|
||||||
class ReinAharnessNotInstalled(RuntimeError):
|
class ReinAharnessNotInstalled(RuntimeError):
|
||||||
|
|
@ -49,26 +47,15 @@ class ReinAharness(Rein):
|
||||||
if not target_repo:
|
if not target_repo:
|
||||||
raise ValueError("no target_repo resolvable from inputs or sandbox reachability")
|
raise ValueError("no target_repo resolvable from inputs or sandbox reachability")
|
||||||
|
|
||||||
task_file = inputs.get("task_file")
|
task_file = inputs.get("task_file") or write_task_file(
|
||||||
if not task_file:
|
inputs["title"], inputs["description"], target_repo, agent=inputs.get("agent", "coach")
|
||||||
task_spec = {
|
)
|
||||||
"title": inputs["title"],
|
|
||||||
"description": inputs["description"],
|
|
||||||
"target_repo": target_repo,
|
|
||||||
"agent": inputs.get("agent", "coach"),
|
|
||||||
}
|
|
||||||
fd = tempfile.NamedTemporaryFile(
|
|
||||||
mode="w", suffix=".json", prefix="glas-harness-task-", delete=False
|
|
||||||
)
|
|
||||||
json.dump(task_spec, fd)
|
|
||||||
fd.close()
|
|
||||||
task_file = fd.name
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"sandbox_id": sandbox.sandbox_id,
|
"sandbox_id": sandbox.sandbox_id,
|
||||||
"task_file": task_file,
|
"task_file": task_file,
|
||||||
"target_repo": target_repo,
|
"target_repo": target_repo,
|
||||||
"head_before": self._git_head(target_repo),
|
"head_before": git_head(target_repo),
|
||||||
}
|
}
|
||||||
|
|
||||||
def dispatch_tool(self, session: dict[str, str], tool_call: ToolCall) -> ToolResult:
|
def dispatch_tool(self, session: dict[str, str], tool_call: ToolCall) -> ToolResult:
|
||||||
|
|
@ -85,18 +72,9 @@ class ReinAharness(Rein):
|
||||||
return ToolResult(ok=ok, output=proc.stdout, error=None if ok else proc.stderr)
|
return ToolResult(ok=ok, output=proc.stdout, error=None if ok else proc.stderr)
|
||||||
|
|
||||||
def end_session(self, session: dict[str, str]) -> dict[str, str]:
|
def end_session(self, session: dict[str, str]) -> dict[str, str]:
|
||||||
head_after = self._git_head(session["target_repo"])
|
head_after = git_head(session["target_repo"])
|
||||||
committed = bool(head_after) and head_after != session.get("head_before")
|
committed = bool(head_after) and head_after != session.get("head_before")
|
||||||
return {
|
return {
|
||||||
"commit_sha": head_after or "",
|
"commit_sha": head_after or "",
|
||||||
"committed": str(committed),
|
"committed": str(committed),
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _git_head(repo: str) -> str:
|
|
||||||
proc = subprocess.run(
|
|
||||||
["git", "-C", str(Path(repo).expanduser()), "rev-parse", "HEAD"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
return proc.stdout.strip() if proc.returncode == 0 else ""
|
|
||||||
|
|
|
||||||
73
src/glas_harness/reins/rein_openweights.py
Normal file
73
src/glas_harness/reins/rein_openweights.py
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
"""ReinOpenWeights — glas-harness adapter around the rein-openweights CLI.
|
||||||
|
|
||||||
|
Same shape as reins/rein_aharness.py: rein-openweights's `run` command
|
||||||
|
performs an entire bounded agentic session (credential acquisition,
|
||||||
|
OpenRouter tool-calling loop, commit verification) as one opaque unit —
|
||||||
|
no per-tool-call hooks exposed yet. dispatch_tool collapses the whole
|
||||||
|
run into a single call.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from glas_harness.contract import Rein, SandboxHandle, ToolCall, ToolResult
|
||||||
|
from glas_harness.reins._shared import git_head, write_task_file
|
||||||
|
|
||||||
|
|
||||||
|
class ReinOpenWeightsNotInstalled(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ReinOpenWeights(Rein):
|
||||||
|
def __init__(self, cli_bin: str = "rein-openweights", model: str | None = None) -> None:
|
||||||
|
self.cli_bin = cli_bin
|
||||||
|
self.model = model
|
||||||
|
|
||||||
|
def _bin(self) -> str:
|
||||||
|
resolved = shutil.which(self.cli_bin)
|
||||||
|
if not resolved:
|
||||||
|
raise ReinOpenWeightsNotInstalled(
|
||||||
|
f"'{self.cli_bin}' not found on PATH — install rein-openweights first"
|
||||||
|
)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
def start_session(
|
||||||
|
self, profile: dict[str, Any], inputs: dict[str, str], sandbox: SandboxHandle
|
||||||
|
) -> dict[str, str]:
|
||||||
|
target_repo = (
|
||||||
|
inputs.get("target_repo")
|
||||||
|
or sandbox.reachability.get("workspace_dir")
|
||||||
|
or sandbox.reachability.get("remote_dir")
|
||||||
|
)
|
||||||
|
if not target_repo:
|
||||||
|
raise ValueError("no target_repo resolvable from inputs or sandbox reachability")
|
||||||
|
|
||||||
|
task_file = inputs.get("task_file") or write_task_file(
|
||||||
|
inputs["title"], inputs["description"], target_repo
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"sandbox_id": sandbox.sandbox_id,
|
||||||
|
"task_file": task_file,
|
||||||
|
"target_repo": target_repo,
|
||||||
|
"head_before": git_head(target_repo),
|
||||||
|
}
|
||||||
|
|
||||||
|
def dispatch_tool(self, session: dict[str, str], tool_call: ToolCall) -> ToolResult:
|
||||||
|
argv = [self._bin(), "run", "--task-file", session["task_file"], "--no-hub"]
|
||||||
|
if self.model:
|
||||||
|
argv += ["--model", self.model]
|
||||||
|
proc = subprocess.run(argv, capture_output=True, text=True)
|
||||||
|
ok = proc.returncode == 0
|
||||||
|
return ToolResult(ok=ok, output=proc.stdout, error=None if ok else proc.stderr)
|
||||||
|
|
||||||
|
def end_session(self, session: dict[str, str]) -> dict[str, str]:
|
||||||
|
head_after = git_head(session["target_repo"])
|
||||||
|
committed = bool(head_after) and head_after != session.get("head_before")
|
||||||
|
return {
|
||||||
|
"commit_sha": head_after or "",
|
||||||
|
"committed": str(committed),
|
||||||
|
}
|
||||||
|
|
@ -80,8 +80,10 @@ def test_end_session_detects_new_commit(tmp_path) -> None:
|
||||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
||||||
subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "init"], cwd=repo, check=True)
|
subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "init"], cwd=repo, check=True)
|
||||||
|
|
||||||
|
from glas_harness.reins._shared import git_head
|
||||||
|
|
||||||
rein = ReinAharness()
|
rein = ReinAharness()
|
||||||
head_before = rein._git_head(str(repo))
|
head_before = git_head(str(repo))
|
||||||
|
|
||||||
subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "task"], cwd=repo, check=True)
|
subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "task"], cwd=repo, check=True)
|
||||||
|
|
||||||
|
|
@ -96,8 +98,10 @@ def test_end_session_no_new_commit(tmp_path) -> None:
|
||||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
||||||
subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "init"], cwd=repo, check=True)
|
subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "init"], cwd=repo, check=True)
|
||||||
|
|
||||||
|
from glas_harness.reins._shared import git_head
|
||||||
|
|
||||||
rein = ReinAharness()
|
rein = ReinAharness()
|
||||||
head = rein._git_head(str(repo))
|
head = git_head(str(repo))
|
||||||
|
|
||||||
summary = rein.end_session({"target_repo": str(repo), "head_before": head})
|
summary = rein.end_session({"target_repo": str(repo), "head_before": head})
|
||||||
assert summary["committed"] == "False"
|
assert summary["committed"] == "False"
|
||||||
|
|
|
||||||
106
tests/test_rein_openweights.py
Normal file
106
tests/test_rein_openweights.py
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from glas_harness.contract import Rein, SandboxHandle, ToolCall
|
||||||
|
from glas_harness.reins.rein_openweights import ReinOpenWeights, ReinOpenWeightsNotInstalled
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_rein_subclass() -> None:
|
||||||
|
assert issubclass(ReinOpenWeights, Rein)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bin_raises_if_not_installed() -> None:
|
||||||
|
rein = ReinOpenWeights(cli_bin="does-not-exist-binary")
|
||||||
|
with pytest.raises(ReinOpenWeightsNotInstalled):
|
||||||
|
rein._bin()
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_session_writes_task_file_and_captures_head(tmp_path) -> None:
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
repo.mkdir()
|
||||||
|
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
||||||
|
subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "init"], cwd=repo, check=True)
|
||||||
|
|
||||||
|
rein = ReinOpenWeights()
|
||||||
|
sandbox = SandboxHandle(sandbox_id="abc", host="localhost", reachability={"workspace_dir": str(repo)})
|
||||||
|
session = rein.start_session(
|
||||||
|
profile={"id": "harness.agent-dev-local"},
|
||||||
|
inputs={"title": "t", "description": "d"},
|
||||||
|
sandbox=sandbox,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert session["target_repo"] == str(repo)
|
||||||
|
assert session["head_before"]
|
||||||
|
task_spec = json.loads(open(session["task_file"]).read())
|
||||||
|
assert task_spec["title"] == "t"
|
||||||
|
assert task_spec["target_repo"] == str(repo)
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_session_requires_resolvable_target_repo() -> None:
|
||||||
|
rein = ReinOpenWeights()
|
||||||
|
sandbox = SandboxHandle(sandbox_id="abc", host="localhost", reachability={})
|
||||||
|
with pytest.raises(ValueError, match="target_repo"):
|
||||||
|
rein.start_session(profile={}, inputs={"title": "t", "description": "d"}, sandbox=sandbox)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_tool_invokes_rein_openweights_cli() -> None:
|
||||||
|
rein = ReinOpenWeights()
|
||||||
|
session = {"task_file": "/tmp/task.json"}
|
||||||
|
|
||||||
|
fake_result = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||||
|
with patch.object(rein, "_bin", return_value="/usr/bin/rein-openweights"):
|
||||||
|
with patch("glas_harness.reins.rein_openweights.subprocess.run", return_value=fake_result) as run:
|
||||||
|
result = rein.dispatch_tool(session, ToolCall(name="run_task"))
|
||||||
|
|
||||||
|
argv = run.call_args.args[0]
|
||||||
|
assert argv[:3] == ["/usr/bin/rein-openweights", "run", "--task-file"]
|
||||||
|
assert "--no-hub" in argv
|
||||||
|
assert result.ok is True
|
||||||
|
assert result.output == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_tool_passes_model_when_set() -> None:
|
||||||
|
rein = ReinOpenWeights(model="meta-llama/llama-3.1-70b-instruct")
|
||||||
|
session = {"task_file": "/tmp/task.json"}
|
||||||
|
fake_result = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||||
|
|
||||||
|
with patch.object(rein, "_bin", return_value="/usr/bin/rein-openweights"):
|
||||||
|
with patch("glas_harness.reins.rein_openweights.subprocess.run", return_value=fake_result) as run:
|
||||||
|
rein.dispatch_tool(session, ToolCall(name="run_task"))
|
||||||
|
|
||||||
|
argv = run.call_args.args[0]
|
||||||
|
assert "--model" in argv
|
||||||
|
assert "meta-llama/llama-3.1-70b-instruct" in argv
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_tool_reports_failure() -> None:
|
||||||
|
rein = ReinOpenWeights()
|
||||||
|
session = {"task_file": "/tmp/task.json"}
|
||||||
|
|
||||||
|
fake_result = MagicMock(returncode=1, stdout="", stderr="boom")
|
||||||
|
with patch.object(rein, "_bin", return_value="/usr/bin/rein-openweights"):
|
||||||
|
with patch("glas_harness.reins.rein_openweights.subprocess.run", return_value=fake_result):
|
||||||
|
result = rein.dispatch_tool(session, ToolCall(name="run_task"))
|
||||||
|
|
||||||
|
assert result.ok is False
|
||||||
|
assert result.error == "boom"
|
||||||
|
|
||||||
|
|
||||||
|
def test_end_session_detects_new_commit(tmp_path) -> None:
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
repo.mkdir()
|
||||||
|
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
||||||
|
subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "init"], cwd=repo, check=True)
|
||||||
|
|
||||||
|
rein = ReinOpenWeights()
|
||||||
|
from glas_harness.reins._shared import git_head
|
||||||
|
|
||||||
|
head_before = git_head(str(repo))
|
||||||
|
subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "task"], cwd=repo, check=True)
|
||||||
|
|
||||||
|
summary = rein.end_session({"target_repo": str(repo), "head_before": head_before})
|
||||||
|
assert summary["committed"] == "True"
|
||||||
|
assert summary["commit_sha"] != head_before
|
||||||
|
|
@ -103,16 +103,19 @@ is pushed — coordinate repo creation with the operator.
|
||||||
`rein-openweights/REIN-OW-WP-0001` (T01-T04) is done: standalone agentic
|
`rein-openweights/REIN-OW-WP-0001` (T01-T04) is done: standalone agentic
|
||||||
loop, own OpenRouter client (not llm-connect — see that workplan's T01
|
loop, own OpenRouter client (not llm-connect — see that workplan's T01
|
||||||
for why), tool surface, credential acquisition, commit-verified success
|
for why), tool surface, credential acquisition, commit-verified success
|
||||||
+ hub reporting, 26 tests. **Not yet done here:** a
|
+ hub reporting, 26 tests.
|
||||||
`glas_harness/reins/rein_openweights.py` adapter implementing the `Rein`
|
|
||||||
ABC and shelling out to `rein-openweights run`, mirroring
|
`glas_harness/reins/rein_openweights.py` now implements the `Rein` ABC
|
||||||
`reins/rein_aharness.py` — that's the piece that actually plugs this
|
by shelling out to `rein-openweights run --task-file ... --no-hub
|
||||||
rein into glas-harness's gateway. registry/reins/rein-openweights.yaml's
|
[--model ...]`, mirroring `reins/rein_aharness.py` exactly (both now
|
||||||
`status: planned` should flip to `implemented` once that adapter lands.
|
share `reins/_shared.py` for `git_head`/`write_task_file` — the
|
||||||
|
duplication was real, so it got factored out). `registry/reins/
|
||||||
|
rein-openweights.yaml` flipped to `status: implemented`. 8 new tests,
|
||||||
|
all mocked.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: GLAS-WP-0001-T05
|
id: GLAS-WP-0001-T05
|
||||||
status: progress
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "84fa62ae-3b34-42fd-a263-f65c35e87586"
|
state_hub_task_id: "84fa62ae-3b34-42fd-a263-f65c35e87586"
|
||||||
```
|
```
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue