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

@ -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):

View file

@ -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"])