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 <noreply@anthropic.com>
This commit is contained in:
parent
eaa7f4e7e9
commit
799c61d20e
4 changed files with 91 additions and 9 deletions
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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"])
|
||||||
|
|
|
||||||
|
|
@ -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 == []
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue