Consume rein-aharness's per-tool-call audit stream (HARNESS-WP-0002-T03)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 4s

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 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-07-26 14:50:14 +02:00
parent eaa7f4e7e9
commit 799c61d20e
4 changed files with 91 additions and 9 deletions

View file

@ -16,6 +16,6 @@
| task | GLAS-WP-0001-T01 | done | — | 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 | 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 | 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-T05 | done | — | 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 |

View file

@ -34,6 +34,14 @@ class ToolResult:
ok: bool ok: bool
output: str = "" output: str = ""
error: str | None = None 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): class Rein(ABC):

View file

@ -2,16 +2,25 @@
rein-aharness's `run` command performs an entire bounded agentic session rein-aharness's `run` command performs an entire bounded agentic session
(persona load, Claude Code CLI subprocess, commit verification) as one (persona load, Claude Code CLI subprocess, commit verification) as one
opaque unit it does not yet expose per-tool-call hooks from outside opaque unit glas-harness cannot externally dispatch its individual
(that refactor is tracked in tool calls (Claude Code executes its own tools internally; that's not a
rein-aharness/workplans/HARNESS-WP-0002-T03). Until then, dispatch_tool shortcut, it's how Claude Code's `--print` mode works). dispatch_tool
collapses the whole run into a single call rather than true per-tool still collapses the whole run into a single call.
granularity; this adapter proves the contract's *shape* (start/dispatch/
end + sandbox handoff), not fine-grained tool interception yet. 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 from __future__ import annotations
import json
import shutil import shutil
import subprocess import subprocess
from typing import Any from typing import Any
@ -25,8 +34,9 @@ class ReinAharnessNotInstalled(RuntimeError):
class ReinAharness(Rein): 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.cli_bin = cli_bin
self.stream_tool_events = stream_tool_events
def _bin(self) -> str: def _bin(self) -> str:
resolved = shutil.which(self.cli_bin) resolved = shutil.which(self.cli_bin)
@ -67,9 +77,33 @@ class ReinAharness(Rein):
"--no-hub", "--no-hub",
"--no-metrics", "--no-metrics",
] ]
if self.stream_tool_events:
argv.append("--stream-tool-events")
proc = subprocess.run(argv, capture_output=True, text=True) proc = subprocess.run(argv, capture_output=True, text=True)
ok = proc.returncode == 0 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]: def end_session(self, session: dict[str, str]) -> dict[str, str]:
head_after = git_head(session["target_repo"]) head_after = git_head(session["target_repo"])

View file

@ -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}) summary = rein.end_session({"target_repo": str(repo), "head_before": head})
assert summary["committed"] == "False" 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 == []