diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index ddbca14..2338d79 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -17,5 +17,5 @@ | 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-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 | diff --git a/registry/reins/rein-openweights.yaml b/registry/reins/rein-openweights.yaml index f26cccd..93e0db7 100644 --- a/registry/reins/rein-openweights.yaml +++ b/registry/reins/rein-openweights.yaml @@ -2,12 +2,15 @@ id: rein-openweights title: OpenRouter open-weight agentic loop description: > Agentic tool-use harness driving current open-weight models via - OpenRouter/llm-connect, as an alternative to frontier-vendor CLIs. - Chartered by ADR-001; not yet implementing this contract. + OpenRouter (own minimal client, not llm-connect — see + 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 capabilities: session_style: unattended model_class: open-weight - credential_source: tbd - # tbd: resolved by GLAS-WP-0001-T06 before this rein is implemented. -status: planned + credential_source: rein-local + # rein-local per ADR-002 (Option B): rein-openweights acquires its own + # OpenRouter credential (OpenBao, or OPENROUTER_API_KEY env var). + # glas-harness does not broker it. +status: implemented diff --git a/src/glas_harness/reins/_shared.py b/src/glas_harness/reins/_shared.py new file mode 100644 index 0000000..a164496 --- /dev/null +++ b/src/glas_harness/reins/_shared.py @@ -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 diff --git a/src/glas_harness/reins/rein_aharness.py b/src/glas_harness/reins/rein_aharness.py index f5f4f64..fd72054 100644 --- a/src/glas_harness/reins/rein_aharness.py +++ b/src/glas_harness/reins/rein_aharness.py @@ -12,14 +12,12 @@ end + sandbox handoff), not fine-grained tool interception yet. from __future__ import annotations -import json import shutil import subprocess -import tempfile -from pathlib import Path 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 ReinAharnessNotInstalled(RuntimeError): @@ -49,26 +47,15 @@ class ReinAharness(Rein): if not target_repo: raise ValueError("no target_repo resolvable from inputs or sandbox reachability") - task_file = inputs.get("task_file") - if not task_file: - 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 + task_file = inputs.get("task_file") or write_task_file( + inputs["title"], inputs["description"], target_repo, agent=inputs.get("agent", "coach") + ) return { "sandbox_id": sandbox.sandbox_id, "task_file": task_file, "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: @@ -85,18 +72,9 @@ class ReinAharness(Rein): 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 = self._git_head(session["target_repo"]) + 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), } - - @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 "" diff --git a/src/glas_harness/reins/rein_openweights.py b/src/glas_harness/reins/rein_openweights.py new file mode 100644 index 0000000..bdbcc8a --- /dev/null +++ b/src/glas_harness/reins/rein_openweights.py @@ -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), + } diff --git a/tests/test_rein_aharness.py b/tests/test_rein_aharness.py index 7f2e2ef..c0176c3 100644 --- a/tests/test_rein_aharness.py +++ b/tests/test_rein_aharness.py @@ -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", "commit", "-q", "--allow-empty", "-m", "init"], cwd=repo, check=True) + from glas_harness.reins._shared import git_head + 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) @@ -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", "commit", "-q", "--allow-empty", "-m", "init"], cwd=repo, check=True) + from glas_harness.reins._shared import git_head + rein = ReinAharness() - head = rein._git_head(str(repo)) + head = git_head(str(repo)) summary = rein.end_session({"target_repo": str(repo), "head_before": head}) assert summary["committed"] == "False" diff --git a/tests/test_rein_openweights.py b/tests/test_rein_openweights.py new file mode 100644 index 0000000..8c4d927 --- /dev/null +++ b/tests/test_rein_openweights.py @@ -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 diff --git a/workplans/GLAS-WP-0001-harness-router-foundation.md b/workplans/GLAS-WP-0001-harness-router-foundation.md index ea185bc..d710a04 100644 --- a/workplans/GLAS-WP-0001-harness-router-foundation.md +++ b/workplans/GLAS-WP-0001-harness-router-foundation.md @@ -103,16 +103,19 @@ is pushed — coordinate repo creation with the operator. `rein-openweights/REIN-OW-WP-0001` (T01-T04) is done: standalone agentic loop, own OpenRouter client (not llm-connect — see that workplan's T01 for why), tool surface, credential acquisition, commit-verified success -+ hub reporting, 26 tests. **Not yet done here:** a -`glas_harness/reins/rein_openweights.py` adapter implementing the `Rein` -ABC and shelling out to `rein-openweights run`, mirroring -`reins/rein_aharness.py` — that's the piece that actually plugs this -rein into glas-harness's gateway. registry/reins/rein-openweights.yaml's -`status: planned` should flip to `implemented` once that adapter lands. ++ hub reporting, 26 tests. + +`glas_harness/reins/rein_openweights.py` now implements the `Rein` ABC +by shelling out to `rein-openweights run --task-file ... --no-hub +[--model ...]`, mirroring `reins/rein_aharness.py` exactly (both now +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 id: GLAS-WP-0001-T05 -status: progress +status: done priority: high state_hub_task_id: "84fa62ae-3b34-42fd-a263-f65c35e87586" ```