diff --git a/.gitignore b/.gitignore index 71ee8eb..ff028cd 100644 --- a/.gitignore +++ b/.gitignore @@ -180,3 +180,4 @@ cython_debug/ .claude/* !.claude/rules/ !.claude/rules/*.md +.venv/ diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index f791089..d12daa8 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -13,7 +13,7 @@ | task | GLAS-0001-T01 | todo | — | workplans/GLAS-0001-statehub-bootstrap.md | | task | GLAS-0001-T02 | todo | — | workplans/GLAS-0001-statehub-bootstrap.md | | task | GLAS-0001-T03 | todo | — | workplans/GLAS-0001-statehub-bootstrap.md | -| task | GLAS-WP-0001-T01 | todo | — | workplans/GLAS-WP-0001-harness-router-foundation.md | +| task | GLAS-WP-0001-T01 | done | — | workplans/GLAS-WP-0001-harness-router-foundation.md | | task | GLAS-WP-0001-T02 | todo | — | workplans/GLAS-WP-0001-harness-router-foundation.md | | task | GLAS-WP-0001-T03 | todo | — | workplans/GLAS-WP-0001-harness-router-foundation.md | | task | GLAS-WP-0001-T04 | todo | — | workplans/GLAS-WP-0001-harness-router-foundation.md | diff --git a/profiles/harness.agent-dev-local.yaml b/profiles/harness.agent-dev-local.yaml new file mode 100644 index 0000000..597aaeb --- /dev/null +++ b/profiles/harness.agent-dev-local.yaml @@ -0,0 +1,10 @@ +id: harness.agent-dev-local +version: "1.0.0" +rein: rein-aharness +sandbox_profile: profile.bwrap-local +tool_profile: green-commit-only +metadata: + latency_class: fast + # Pairs with sand-boxer's profile.bwrap-local (ext.bwrap, same-host, + # no SSH hop) — for CI/local dev where the compose-ssh remote-host + # round trip is unnecessary overhead. diff --git a/profiles/harness.agent-dev.yaml b/profiles/harness.agent-dev.yaml new file mode 100644 index 0000000..053335a --- /dev/null +++ b/profiles/harness.agent-dev.yaml @@ -0,0 +1,10 @@ +id: harness.agent-dev +version: "1.0.0" +rein: rein-aharness +sandbox_profile: profile.agent-dev +tool_profile: green-commit-only +metadata: + latency_class: standard + # Pairs with sand-boxer's profile.agent-dev (ext.compose-ssh, remote + # host over SSH). Use harness.agent-dev-local for fast local iteration + # without the SSH hop. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..854d4db --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "glas-harness" +version = "0.1.0" +description = "Meta-framework routing between concrete agent-harness backends (reins)" +requires-python = ">=3.11" +dependencies = [ + "pydantic>=2.0", + "pyyaml>=6.0", +] + +[project.scripts] +glas-harness = "glas_harness.cli:main" + +[tool.uv.sources] +sandboxer = { path = "../sand-boxer", editable = true } + +[project.optional-dependencies] +sandbox = ["sandboxer"] +dev = ["pytest>=8"] + +[tool.hatch.build.targets.wheel] +packages = ["src/glas_harness"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] diff --git a/registry/reins/rein-aharness.yaml b/registry/reins/rein-aharness.yaml new file mode 100644 index 0000000..46942c3 --- /dev/null +++ b/registry/reins/rein-aharness.yaml @@ -0,0 +1,16 @@ +id: rein-aharness +title: Claude Code CLI rein +description: > + Governed, unattended/scheduled harness driving Claude Code CLI sessions. + Renamed from agent-harness (ADR-001). Deployed on Railiance for tenant + binky-control. +handler: glas_harness.reins.rein_aharness:ReinAharness +capabilities: + session_style: unattended + model_class: frontier + credential_source: rein-local + # rein-local: kaizen-agentic (blueprints), activity-core/issue-core + # (intake), OpenBao/ops-warden (credentials) — see + # rein-aharness/workplans/HARNESS-WP-0002-T04 for whether this stays + # rein-local or moves into glas-harness. +status: implemented diff --git a/registry/reins/rein-openweights.yaml b/registry/reins/rein-openweights.yaml new file mode 100644 index 0000000..f26cccd --- /dev/null +++ b/registry/reins/rein-openweights.yaml @@ -0,0 +1,13 @@ +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. +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 diff --git a/src/glas_harness/__init__.py b/src/glas_harness/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/glas_harness/cli.py b/src/glas_harness/cli.py new file mode 100644 index 0000000..52a6fd0 --- /dev/null +++ b/src/glas_harness/cli.py @@ -0,0 +1,42 @@ +"""glas-harness CLI — minimal gateway invocation (GLAS-WP-0001-T04).""" + +from __future__ import annotations + +import argparse +import json +import sys + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="glas-harness") + sub = parser.add_subparsers(dest="command", required=True) + + run = sub.add_parser("run", help="Run one task through a rein inside a sand-boxer sandbox") + run.add_argument("--sandbox-profile", required=True, help="e.g. profile.bwrap-local") + run.add_argument("--repo", required=True, help="Local repo path to mirror into the sandbox") + run.add_argument("--title", required=True) + run.add_argument("--description", required=True) + run.add_argument("--actor", default="agt") + run.add_argument("--project", default="glas-harness") + + args = parser.parse_args(argv) + + if args.command == "run": + from glas_harness.gateway import run_task_through_rein + + result = run_task_through_rein( + sandbox_profile=args.sandbox_profile, + repo=args.repo, + title=args.title, + description=args.description, + actor=args.actor, + project=args.project, + ) + print(json.dumps(result, indent=2)) + return 0 if result["tool_ok"] else 1 + + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/glas_harness/contract.py b/src/glas_harness/contract.py new file mode 100644 index 0000000..2dcf98b --- /dev/null +++ b/src/glas_harness/contract.py @@ -0,0 +1,54 @@ +"""The harness contract a concrete rein implements. + +See docs/harness-contract.md for the full session-lifecycle diagram and +GLAS-WP-0001-T01. glas-harness owns the outer loop (profile resolution, +sandbox request/teardown via sand-boxer, State Hub reporting, actor +attribution); a rein owns the inner agentic loop. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class SandboxHandle: + """What glas-harness got back from sand-boxer's create().""" + + sandbox_id: str + host: str + reachability: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class ToolCall: + name: str + args: dict[str, Any] = field(default_factory=dict) + actor: str = "agt" + + +@dataclass +class ToolResult: + ok: bool + output: str = "" + error: str | None = None + + +class Rein(ABC): + """Base class for concrete harness backends (reins).""" + + @abstractmethod + def start_session( + self, profile: dict[str, Any], inputs: dict[str, str], sandbox: SandboxHandle + ) -> dict[str, str]: + """Begin an agent session bound to a sandbox. Returns a session handle.""" + + @abstractmethod + def dispatch_tool(self, session: dict[str, str], tool_call: ToolCall) -> ToolResult: + """Run one tool call under the session's policy.""" + + @abstractmethod + def end_session(self, session: dict[str, str]) -> dict[str, str]: + """Close the session. Returns a summary (commit sha, outcome, ...).""" diff --git a/src/glas_harness/gateway.py b/src/glas_harness/gateway.py new file mode 100644 index 0000000..5da5ce4 --- /dev/null +++ b/src/glas_harness/gateway.py @@ -0,0 +1,72 @@ +"""Minimal gateway proving the harness contract against rein-aharness. + +GLAS-WP-0001-T04: resolve a sand-boxer profile, request a sandbox, +dispatch one rein-aharness task through the Rein contract, verify a +commit landed, tear the sandbox down. This is the parity proof gating +any later "retire rein-aharness as a standalone concern" conversation — +it is not itself that conversation. + +Requires the `sandbox` extra (sand-boxer installed as a sibling +editable dependency). +""" + +from __future__ import annotations + +from typing import Any + +from sandboxer.core.manager import SandboxManager +from sandboxer.models import Consumer, SandboxCreateRequest + +from glas_harness.contract import Rein, SandboxHandle, ToolCall +from glas_harness.reins.rein_aharness import ReinAharness + + +def run_task_through_rein( + *, + sandbox_profile: str, + repo: str, + title: str, + description: str, + rein: Rein | None = None, + actor: str = "agt", + project: str = "glas-harness", + manager: SandboxManager | None = None, +) -> dict[str, Any]: + """Resolve `sandbox_profile`, run one task inside it via `rein`, verify, tear down. + + Defaults to `ReinAharness` when no rein is supplied — the only + implemented rein as of GLAS-WP-0001-T04. `rein-openweights` plugs in + the same way once REIN-OW-WP-0001 lands. + """ + manager = manager or SandboxManager() + rein = rein or ReinAharness() + + request = SandboxCreateRequest( + profile=sandbox_profile, + inputs={"repo": repo}, + consumer=Consumer(actor=actor, project=project), + ) + status = manager.create(request) + try: + reachability = status.reachability.model_dump(mode="json") if status.reachability else {} + sandbox = SandboxHandle( + sandbox_id=status.sandbox_id, host=status.host or "", reachability=reachability + ) + + session = rein.start_session( + profile={"id": sandbox_profile}, + inputs={"title": title, "description": description}, + sandbox=sandbox, + ) + tool_result = rein.dispatch_tool(session, ToolCall(name="run_task", actor=actor)) + summary = rein.end_session(session) + + return { + "sandbox_id": status.sandbox_id, + "tool_ok": tool_result.ok, + "tool_output": tool_result.output, + "tool_error": tool_result.error, + "summary": summary, + } + finally: + manager.destroy(status.sandbox_id) diff --git a/src/glas_harness/reins/__init__.py b/src/glas_harness/reins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/glas_harness/reins/rein_aharness.py b/src/glas_harness/reins/rein_aharness.py new file mode 100644 index 0000000..f5f4f64 --- /dev/null +++ b/src/glas_harness/reins/rein_aharness.py @@ -0,0 +1,102 @@ +"""ReinAharness — glas-harness adapter around the rein-aharness CLI. + +rein-aharness's `run` command performs an entire bounded agentic session +(persona load, Claude Code CLI subprocess, commit verification) as one +opaque unit — it does not yet expose per-tool-call hooks from outside +(that refactor is tracked in +rein-aharness/workplans/HARNESS-WP-0002-T03). Until then, dispatch_tool +collapses the whole run into a single call rather than true per-tool +granularity; this adapter proves the contract's *shape* (start/dispatch/ +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 + + +class ReinAharnessNotInstalled(RuntimeError): + pass + + +class ReinAharness(Rein): + def __init__(self, cli_bin: str = "agent-harness") -> None: + self.cli_bin = cli_bin + + def _bin(self) -> str: + resolved = shutil.which(self.cli_bin) + if not resolved: + raise ReinAharnessNotInstalled( + f"'{self.cli_bin}' not found on PATH — install rein-aharness 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") + 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 + + return { + "sandbox_id": sandbox.sandbox_id, + "task_file": task_file, + "target_repo": target_repo, + "head_before": self._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", + "--no-metrics", + ] + 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 = self._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/tests/test_gateway.py b/tests/test_gateway.py new file mode 100644 index 0000000..ec152ae --- /dev/null +++ b/tests/test_gateway.py @@ -0,0 +1,88 @@ +from datetime import UTC, datetime +from unittest.mock import MagicMock + +from sandboxer.models import Reachability, SandboxState, SandboxStatus + +from glas_harness.contract import Rein, SandboxHandle, ToolCall, ToolResult +from glas_harness.gateway import run_task_through_rein + + +class _FakeRein(Rein): + def __init__(self) -> None: + self.calls: list[str] = [] + + def start_session(self, profile, inputs, sandbox: SandboxHandle): + self.calls.append("start_session") + assert sandbox.sandbox_id == "sbx1" + assert sandbox.reachability.get("workspace_dir") == "/tmp/ws" + return {"session": "s1"} + + def dispatch_tool(self, session, tool_call: ToolCall) -> ToolResult: + self.calls.append("dispatch_tool") + assert tool_call.name == "run_task" + return ToolResult(ok=True, output="done") + + def end_session(self, session): + self.calls.append("end_session") + return {"commit_sha": "deadbeef", "committed": "True"} + + +def _fake_status(sandbox_id: str = "sbx1") -> SandboxStatus: + now = datetime.now(UTC) + return SandboxStatus( + sandbox_id=sandbox_id, + profile_id="profile.bwrap-local", + extension_id="ext.bwrap", + state=SandboxState.READY, + consumer={"actor": "agt", "project": "glas-harness"}, + host="localhost", + reachability=Reachability(host="localhost", pid="123", workspace_dir="/tmp/ws"), + created_at=now, + updated_at=now, + ) + + +def test_run_task_through_rein_creates_and_destroys_sandbox() -> None: + manager = MagicMock() + manager.create.return_value = _fake_status() + rein = _FakeRein() + + result = run_task_through_rein( + sandbox_profile="profile.bwrap-local", + repo="/tmp/repo", + title="t", + description="d", + rein=rein, + manager=manager, + ) + + assert rein.calls == ["start_session", "dispatch_tool", "end_session"] + manager.create.assert_called_once() + manager.destroy.assert_called_once_with("sbx1") + assert result["tool_ok"] is True + assert result["summary"]["committed"] == "True" + + +def test_run_task_through_rein_destroys_sandbox_even_on_failure() -> None: + manager = MagicMock() + manager.create.return_value = _fake_status() + + class _FailingRein(_FakeRein): + def dispatch_tool(self, session, tool_call): + raise RuntimeError("boom") + + rein = _FailingRein() + + try: + run_task_through_rein( + sandbox_profile="profile.bwrap-local", + repo="/tmp/repo", + title="t", + description="d", + rein=rein, + manager=manager, + ) + except RuntimeError: + pass + + manager.destroy.assert_called_once_with("sbx1") diff --git a/tests/test_rein_aharness.py b/tests/test_rein_aharness.py new file mode 100644 index 0000000..7f2e2ef --- /dev/null +++ b/tests/test_rein_aharness.py @@ -0,0 +1,103 @@ +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_aharness import ReinAharness, ReinAharnessNotInstalled + + +def test_is_rein_subclass() -> None: + assert issubclass(ReinAharness, Rein) + + +def test_bin_raises_if_not_installed() -> None: + rein = ReinAharness(cli_bin="does-not-exist-binary") + with pytest.raises(ReinAharnessNotInstalled): + 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 = ReinAharness() + 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 = ReinAharness() + 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_agent_harness_cli() -> None: + rein = ReinAharness() + session = {"task_file": "/tmp/task.json"} + + fake_result = MagicMock(returncode=0, stdout="ok", stderr="") + with patch.object(rein, "_bin", return_value="/usr/bin/agent-harness"): + with patch("glas_harness.reins.rein_aharness.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/agent-harness", "run", "--task-file"] + assert result.ok is True + assert result.output == "ok" + + +def test_dispatch_tool_reports_failure() -> None: + rein = ReinAharness() + session = {"task_file": "/tmp/task.json"} + + fake_result = MagicMock(returncode=1, stdout="", stderr="boom") + with patch.object(rein, "_bin", return_value="/usr/bin/agent-harness"): + with patch("glas_harness.reins.rein_aharness.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 = ReinAharness() + head_before = rein._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 + + +def test_end_session_no_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 = ReinAharness() + head = rein._git_head(str(repo)) + + summary = rein.end_session({"target_repo": str(repo), "head_before": head}) + assert summary["committed"] == "False" diff --git a/workplans/GLAS-WP-0001-harness-router-foundation.md b/workplans/GLAS-WP-0001-harness-router-foundation.md index 5a27f65..900449e 100644 --- a/workplans/GLAS-WP-0001-harness-router-foundation.md +++ b/workplans/GLAS-WP-0001-harness-router-foundation.md @@ -42,7 +42,7 @@ glas-harness` entry per the registry-first-reuse principle. ```task id: GLAS-WP-0001-T02 -status: todo +status: done priority: medium state_hub_task_id: "c09e617b-a2d1-42c4-8c90-47ab0056c09c" ``` @@ -58,7 +58,7 @@ fast local iteration without an SSH hop. ```task id: GLAS-WP-0001-T03 -status: todo +status: done priority: medium state_hub_task_id: "e599f276-a010-4284-bddd-a5a121b57de9" ``` @@ -72,9 +72,20 @@ rein-aharness's adapter, verify commit, report to State Hub. This is the parity proof gating any later "retire agent-harness as a standalone concern" conversation — deliberately not attempted until this exists. +Wiring is built and unit-tested: `src/glas_harness/gateway.py` + +`reins/rein_aharness.py` implement start_session/dispatch_tool/ +end_session, tested against a real local git repo and a mocked +`SandboxManager`/`agent-harness` subprocess (`tests/test_gateway.py`, +`tests/test_rein_aharness.py`). **Not yet done:** an actual live run +(`glas-harness run --sandbox-profile profile.bwrap-local ...`) against +the real Claude Code CLI — that spends real API credits/credentials and +was deliberately left for a human-triggered run rather than executed +autonomously. State Hub reporting from the gateway itself is also not +wired yet (only rein-aharness's own `--no-hub` path was exercised). + ```task id: GLAS-WP-0001-T04 -status: todo +status: progress priority: high state_hub_task_id: "cdcacf98-62a1-47ae-92b9-e0320968ac1f" ```