From 799c61d20ea82cdc1b3f66b858389d64dc0accfc Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 26 Jul 2026 14:50:14 +0200 Subject: [PATCH] Consume rein-aharness's per-tool-call audit stream (HARNESS-WP-0002-T03) ToolResult gains an events field (additive, defaults empty) carrying per-tool audit records when a rein can observe its own inner loop's individual tool calls -- populated after dispatch_tool returns, not via a live callback (the contract has no per-event hook). ReinAharness gains a stream_tool_events flag: when set, passes --stream-tool-events and parses the tagged {"stream_event": ...} lines back out of captured stdout into ToolResult.events. Co-Authored-By: Claude Sonnet 5 --- WORK-RECORDS.md | 2 +- src/glas_harness/contract.py | 8 ++++ src/glas_harness/reins/rein_aharness.py | 50 +++++++++++++++++++++---- tests/test_rein_aharness.py | 40 ++++++++++++++++++++ 4 files changed, 91 insertions(+), 9 deletions(-) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 382808e..f4ced99 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -16,6 +16,6 @@ | task | GLAS-WP-0001-T01 | 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-T04 | progress | — | workplans/GLAS-WP-0001-harness-router-foundation.md | +| task | GLAS-WP-0001-T04 | done | — | workplans/GLAS-WP-0001-harness-router-foundation.md | | task | GLAS-WP-0001-T05 | done | — | 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/src/glas_harness/contract.py b/src/glas_harness/contract.py index 2dcf98b..6a82192 100644 --- a/src/glas_harness/contract.py +++ b/src/glas_harness/contract.py @@ -34,6 +34,14 @@ class ToolResult: ok: bool output: str = "" error: str | None = None + # Per-tool-call audit trail, populated when the rein can observe its own + # inner loop's individual tool invocations in real time (e.g. parsing + # `claude --output-format stream-json`). Empty when a rein only exposes + # its whole task as one opaque call — that's a real limit of CLI-wrapped + # agents, not a shortcut: neither Claude Code nor rein-openweights's own + # loop lets glas-harness externally execute individual tool calls, only + # observe them. See HARNESS-WP-0002-T03. + events: list[dict[str, Any]] = field(default_factory=list) class Rein(ABC): diff --git a/src/glas_harness/reins/rein_aharness.py b/src/glas_harness/reins/rein_aharness.py index fd72054..e968246 100644 --- a/src/glas_harness/reins/rein_aharness.py +++ b/src/glas_harness/reins/rein_aharness.py @@ -2,16 +2,25 @@ 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. +opaque unit — glas-harness cannot externally dispatch its individual +tool calls (Claude Code executes its own tools internally; that's not a +shortcut, it's how Claude Code's `--print` mode works). dispatch_tool +still collapses the whole run into a single call. + +What HARNESS-WP-0002-T03 does add: when `stream_tool_events=True`, +rein-aharness is invoked with `--stream-tool-events`, which makes it +print each Claude Code tool_use/tool_result/hook event as its own +`{"stream_event": ...}` JSON line while running. This adapter parses +those lines out of the captured stdout and populates +`ToolResult.events` — real per-tool audit visibility, gained after the +fact from the captured output rather than a live callback (the `Rein` +contract's `dispatch_tool` returns once; there's no per-event hook on +the contract itself). """ from __future__ import annotations +import json import shutil import subprocess from typing import Any @@ -25,8 +34,9 @@ class ReinAharnessNotInstalled(RuntimeError): class ReinAharness(Rein): - def __init__(self, cli_bin: str = "agent-harness") -> None: + def __init__(self, cli_bin: str = "agent-harness", stream_tool_events: bool = False) -> None: self.cli_bin = cli_bin + self.stream_tool_events = stream_tool_events def _bin(self) -> str: resolved = shutil.which(self.cli_bin) @@ -67,9 +77,33 @@ class ReinAharness(Rein): "--no-hub", "--no-metrics", ] + if self.stream_tool_events: + argv.append("--stream-tool-events") 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) + output, events = self._split_stream_events(proc.stdout) + return ToolResult(ok=ok, output=output, error=None if ok else proc.stderr, events=events) + + @staticmethod + def _split_stream_events(stdout: str) -> tuple[str, list[dict[str, Any]]]: + """Pull tagged {"stream_event": ...} lines out of captured stdout. + + Returns (remaining_output, events) — remaining_output is stdout + with event lines removed, so it still matches the final result + block a non-streaming caller would see. + """ + events: list[dict[str, Any]] = [] + remaining_lines: list[str] = [] + for line in stdout.splitlines(): + stripped = line.strip() + if stripped.startswith('{"stream_event"'): + try: + events.append(json.loads(stripped)["stream_event"]) + continue + except (json.JSONDecodeError, KeyError): + pass + remaining_lines.append(line) + return "\n".join(remaining_lines), events def end_session(self, session: dict[str, str]) -> dict[str, str]: head_after = git_head(session["target_repo"]) diff --git a/tests/test_rein_aharness.py b/tests/test_rein_aharness.py index c0176c3..0d5b66c 100644 --- a/tests/test_rein_aharness.py +++ b/tests/test_rein_aharness.py @@ -105,3 +105,43 @@ def test_end_session_no_new_commit(tmp_path) -> None: summary = rein.end_session({"target_repo": str(repo), "head_before": head}) assert summary["committed"] == "False" + + +def test_dispatch_tool_streams_events_when_enabled() -> None: + import json + + rein = ReinAharness(stream_tool_events=True) + session = {"task_file": "/tmp/task.json"} + + stdout_lines = [ + json.dumps({"stream_event": {"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Read"}]}}}), + json.dumps({"stream_event": {"type": "system", "subtype": "hook_started"}}), + json.dumps({"ok": True, "committed": True}, indent=2), + ] + fake_result = MagicMock(returncode=0, stdout="\n".join(stdout_lines), 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 "--stream-tool-events" in argv + assert len(result.events) == 2 + assert result.events[0]["message"]["content"][0]["name"] == "Read" + assert result.events[1]["subtype"] == "hook_started" + assert '"ok": true' in result.output.lower() + assert "stream_event" not in result.output + + +def test_dispatch_tool_no_stream_flag_by_default() -> 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 "--stream-tool-events" not in argv + assert result.events == []