mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-07 08:26:11 +00:00
861 lines
36 KiB
Python
861 lines
36 KiB
Python
"""Headless session execution and transcript evidence for workflow benchmarks."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import stat
|
|
import sys
|
|
import threading
|
|
import time
|
|
from collections import deque
|
|
from collections.abc import Sequence
|
|
from pathlib import Path, PurePosixPath
|
|
from typing import Any
|
|
|
|
from .process_control import run_managed
|
|
from .proposer_sandbox import (
|
|
SANDBOX_GITNEXUS,
|
|
SANDBOX_GITNEXUS_REGISTRY,
|
|
SANDBOX_HOME,
|
|
SANDBOX_NODE,
|
|
SANDBOX_TMP,
|
|
SANDBOX_WORKSPACE,
|
|
SandboxError,
|
|
redact_text,
|
|
)
|
|
|
|
USAGE_FIELDS = (
|
|
"input_tokens",
|
|
"cache_creation_input_tokens",
|
|
"cache_read_input_tokens",
|
|
"output_tokens",
|
|
)
|
|
MAX_TRANSCRIPT_BYTES = 8 * 1024 * 1024
|
|
# Wall-clock ceiling for one headless session, shared by the runner and the
|
|
# evolution loop so both CLIs kill a session at the same point. 3600s was too
|
|
# tight for the `workflow` arm: run 29907431284 lost two investigation-task
|
|
# incumbent runs to SIGTERM at the ceiling while a Bash verification step was
|
|
# still going, and the promotion gate demands zero excluded runs in both paired
|
|
# arms — so a single ceiling hit costs the whole generation. Successful
|
|
# `workflow` rows in that run finished in ~1600-2600s across both sessions, so
|
|
# this leaves real headroom over observed work rather than over the timeout.
|
|
SESSION_TIMEOUT_SECONDS = 5400
|
|
# Provenance tag stamped on every parent-captured transcript artifact. The
|
|
# evidence preflight (evolve._transcript_artifact_metadata) validates against
|
|
# this exact value, so producer and consumer stay pinned to one schema.
|
|
PARENT_EVENT_STREAM_SOURCE = "parent-captured-stream-json"
|
|
# Progress reporting only. A session can work quietly for many minutes, so the
|
|
# reporter also speaks up on its own to distinguish "thinking" from "wedged".
|
|
PROGRESS_HEARTBEAT_SECONDS = 60.0
|
|
MAX_PROGRESS_LINE_BYTES = 1024 * 1024
|
|
MAX_PROGRESS_PENDING = 256
|
|
MAX_PROGRESS_TOOL_ID_CHARS = 256
|
|
MAX_TOOL_PREVIEW_CHARS = 800
|
|
_SAFE_TOOL_NAME = re.compile(r"[A-Za-z0-9._:-]{1,64}")
|
|
|
|
|
|
def _safe_tool_name(value: Any) -> str:
|
|
"""A tool name is an identifier; anything else is treated as content."""
|
|
|
|
match = _SAFE_TOOL_NAME.fullmatch(value.strip()) if isinstance(value, str) else None
|
|
return match.group(0) if match else "tool"
|
|
|
|
|
|
def _debuggable_tool(name: str) -> bool:
|
|
return name in {"Read", "Bash", "Grep", "Glob"} or name.startswith("mcp__")
|
|
|
|
|
|
def _tool_preview(value: Any, secrets: Sequence[str]) -> str:
|
|
"""Render one bounded, redacted, single-line tool payload preview."""
|
|
|
|
try:
|
|
raw = json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str)
|
|
except (TypeError, ValueError):
|
|
raw = json.dumps(str(value), ensure_ascii=False)
|
|
redacted = redact_text(raw, secrets)
|
|
if len(redacted) <= MAX_TOOL_PREVIEW_CHARS:
|
|
return redacted
|
|
omitted = len(redacted) - MAX_TOOL_PREVIEW_CHARS
|
|
return f"{redacted[:MAX_TOOL_PREVIEW_CHARS]}…[truncated {omitted} chars]"
|
|
|
|
|
|
def _tool_result_text(content: Any) -> str:
|
|
if isinstance(content, str):
|
|
return content
|
|
if isinstance(content, list):
|
|
return "\n".join(
|
|
block["text"]
|
|
for block in content
|
|
if isinstance(block, dict) and block.get("type") == "text" and isinstance(block.get("text"), str)
|
|
)
|
|
return ""
|
|
|
|
|
|
def _mcp_result_has_semantic_error(content: Any) -> bool:
|
|
"""Recognize GitNexus error envelopes that MCP transported successfully."""
|
|
|
|
text = _tool_result_text(content).strip()
|
|
if not text:
|
|
return False
|
|
payload = text.split("\n\n---", 1)[0].strip()
|
|
try:
|
|
decoded = json.loads(payload)
|
|
except (json.JSONDecodeError, ValueError):
|
|
return bool(re.match(r"^error\s*:", payload, re.IGNORECASE))
|
|
return isinstance(decoded, dict) and isinstance(decoded.get("error"), str)
|
|
|
|
|
|
def _tool_result_log_status(name: str, block: dict[str, Any]) -> str:
|
|
if block.get("is_error") is True:
|
|
return "error"
|
|
if name.startswith("mcp__") and _mcp_result_has_semantic_error(block.get("content")):
|
|
return "semantic-error"
|
|
return "ok"
|
|
|
|
|
|
class SessionProgress:
|
|
"""Report session metadata and bounded, redacted tool I/O previews.
|
|
|
|
A session's stdout is evidence: it is redacted before anything is written
|
|
out, so raw events and model prose are never streamed to the log. Turn
|
|
counts, tool activity, API retries, and quiet time distinguish work from
|
|
a wedged session. Tool arguments and results are content, not metadata.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
label: str,
|
|
*,
|
|
stream: Any = None,
|
|
heartbeat_s: float = PROGRESS_HEARTBEAT_SECONDS,
|
|
secrets: Sequence[str] = (),
|
|
) -> None:
|
|
self.label = label
|
|
self.heartbeat_s = heartbeat_s
|
|
self._secrets = tuple(secret for secret in secrets if secret)
|
|
# stdout, not stderr: the benchmark sweep runs as a child of the
|
|
# evolution loop, which echoes only the child's stdout as it arrives
|
|
# (run_managed(echo_stdout=True)). Its stderr surfaces as a bounded
|
|
# tail after the fact, which is exactly the blind spot this closes.
|
|
self._stream = stream if stream is not None else sys.stdout
|
|
self._lock = threading.Lock()
|
|
self._buffer = bytearray()
|
|
self._started = time.monotonic()
|
|
self._last_spoke = self._started
|
|
self._events = 0
|
|
self._turns = 0
|
|
self._tools = 0
|
|
self._pending_tools: dict[str, str] = {}
|
|
self._last_activity = "starting"
|
|
self._pending_messages: deque[str] = deque(maxlen=MAX_PROGRESS_PENDING)
|
|
self._timer: threading.Thread | None = None
|
|
self._done = threading.Event()
|
|
|
|
def __enter__(self) -> SessionProgress:
|
|
self._say(f"started (heartbeat every {self.heartbeat_s:g}s)")
|
|
self._emit_pending()
|
|
self._timer = threading.Thread(target=self._heartbeat, daemon=True)
|
|
self._timer.start()
|
|
return self
|
|
|
|
def __exit__(self, *exc: object) -> None:
|
|
self._done.set()
|
|
timer, self._timer = self._timer, None
|
|
if timer is not None:
|
|
timer.join(timeout=2)
|
|
self._emit_pending()
|
|
|
|
def _elapsed(self) -> str:
|
|
seconds = int(time.monotonic() - self._started)
|
|
return f"{seconds // 60}m{seconds % 60:02d}s"
|
|
|
|
def _say(self, message: str) -> None:
|
|
# Queue only: the stdout drain thread calls observe() and must not
|
|
# block on a full log pipe (process_control.stdout_observer contract).
|
|
self._pending_messages.append(f"[{self.label} {self._elapsed()}] {message}")
|
|
self._last_spoke = time.monotonic()
|
|
|
|
def _emit_pending(self) -> None:
|
|
with self._lock:
|
|
messages = list(self._pending_messages)
|
|
self._pending_messages.clear()
|
|
for message in messages:
|
|
try:
|
|
print(message, file=self._stream, flush=True)
|
|
except (OSError, ValueError):
|
|
return
|
|
|
|
def _heartbeat(self) -> None:
|
|
tick = min(1.0, max(self.heartbeat_s / 2, 0.01))
|
|
while not self._done.wait(tick):
|
|
with self._lock:
|
|
quiet = time.monotonic() - self._last_spoke
|
|
if quiet >= self.heartbeat_s:
|
|
self._say(
|
|
f"still running · {self._events} events · {self._turns} turns · "
|
|
f"{self._tools} tool calls · last: {self._last_activity}"
|
|
)
|
|
self._emit_pending()
|
|
|
|
def observe(self, chunk: bytes) -> None:
|
|
"""Consume one stdout chunk. Never raises; never blocks on I/O."""
|
|
|
|
with self._lock:
|
|
self._buffer.extend(chunk)
|
|
# Bound the partial line: a single enormous event must not grow the
|
|
# buffer without limit just because it has no newline yet.
|
|
if len(self._buffer) > MAX_PROGRESS_LINE_BYTES:
|
|
del self._buffer[:-MAX_PROGRESS_LINE_BYTES]
|
|
while (newline := self._buffer.find(b"\n")) >= 0:
|
|
line = bytes(self._buffer[:newline])
|
|
del self._buffer[: newline + 1]
|
|
self._observe_line(line)
|
|
|
|
def _observe_line(self, line: bytes) -> None:
|
|
if not line.strip():
|
|
return
|
|
try:
|
|
event = json.loads(line.decode("utf-8", errors="replace"))
|
|
except (json.JSONDecodeError, ValueError):
|
|
return
|
|
if not isinstance(event, dict):
|
|
return
|
|
self._events += 1
|
|
kind = event.get("type")
|
|
if kind == "assistant":
|
|
self._turns += 1
|
|
uses = [
|
|
block for block in _event_content(event) if isinstance(block, dict) and block.get("type") == "tool_use"
|
|
]
|
|
names = [_safe_tool_name(block.get("name")) for block in uses]
|
|
if names:
|
|
self._tools += len(names)
|
|
self._last_activity = ", ".join(names[:4])
|
|
self._say(f"turn {self._turns} · {self._last_activity}")
|
|
for block, name in zip(uses, names, strict=True):
|
|
tool_id = block.get("id")
|
|
if isinstance(tool_id, str) and len(tool_id) <= MAX_PROGRESS_TOOL_ID_CHARS:
|
|
self._pending_tools.pop(tool_id, None)
|
|
self._pending_tools[tool_id] = name
|
|
if len(self._pending_tools) > MAX_PROGRESS_PENDING:
|
|
del self._pending_tools[next(iter(self._pending_tools))]
|
|
if _debuggable_tool(name):
|
|
self._say(f"tool {name} input={_tool_preview(block.get('input'), self._secrets)}")
|
|
else:
|
|
self._last_activity = "model reply"
|
|
elif kind == "user":
|
|
for block in _event_content(event):
|
|
if not isinstance(block, dict) or block.get("type") != "tool_result":
|
|
continue
|
|
tool_id = block.get("tool_use_id")
|
|
name = self._pending_tools.pop(tool_id, "tool") if isinstance(tool_id, str) else "tool"
|
|
if not _debuggable_tool(name):
|
|
continue
|
|
status = _tool_result_log_status(name, block)
|
|
self._last_activity = f"{name} result {status}"
|
|
self._say(f"tool {name} result={status} output={_tool_preview(block.get('content'), self._secrets)}")
|
|
elif kind == "system" and event.get("subtype") == "api_retry":
|
|
# The signature of the gateway wedging: say it loudly and at once.
|
|
attempt = event.get("attempt")
|
|
limit = event.get("max_retries")
|
|
delay = event.get("retry_delay_ms")
|
|
wait = f" in {float(delay) / 1000:.0f}s" if isinstance(delay, (int, float)) else ""
|
|
self._last_activity = f"API retry {attempt}/{limit}"
|
|
self._say(f"API retry {attempt}/{limit}{wait} — no response from the model endpoint")
|
|
elif kind == "system" and event.get("subtype") == "init":
|
|
self._last_activity = "session init"
|
|
self._say("session initialized")
|
|
elif kind == "result":
|
|
cost = event.get("total_cost_usd")
|
|
self._last_activity = "result"
|
|
self._say(
|
|
f"finished · {event.get('num_turns', 0)} turns · "
|
|
f"{'error' if event.get('is_error') else 'ok'}"
|
|
+ (f" · ${float(cost):.2f}" if isinstance(cost, (int, float)) else "")
|
|
)
|
|
|
|
|
|
def measured_cost(raw: Any) -> float | None:
|
|
"""Session cost as a finite non-negative float, or None when unmeasured.
|
|
|
|
``cost_usd`` is a promotion metric (lower wins), so an absent/garbage
|
|
``total_cost_usd`` must NOT collapse to a real measured $0 that a candidate
|
|
could win on — it stays None and the gate refuses to rank on it. A genuine
|
|
measured 0.0 is preserved distinctly.
|
|
"""
|
|
if isinstance(raw, bool) or not isinstance(raw, (int, float)):
|
|
return None
|
|
if not math.isfinite(raw) or raw < 0:
|
|
return None
|
|
return float(raw)
|
|
|
|
|
|
def _na(value: Any) -> Any:
|
|
"""Render an unmeasured metric as ``n/a`` instead of a misleading number.
|
|
|
|
Lives next to ``measured_cost`` because it renders exactly what that
|
|
returns: every caller reporting a cost has to distinguish "never measured"
|
|
from a real 0.0.
|
|
"""
|
|
return "n/a" if value is None else value
|
|
|
|
|
|
SANDBOX_GITNEXUS_ENTRYPOINT = f"{SANDBOX_GITNEXUS}/dist/cli/index.js"
|
|
SENSITIVE_EVENT_KEYS = frozenset(
|
|
{
|
|
"authorization",
|
|
"proxy-authorization",
|
|
"x-api-key",
|
|
"api-key",
|
|
"api_key",
|
|
"anthropic-api-key",
|
|
"anthropic_api_key",
|
|
"token",
|
|
"access_token",
|
|
"refresh_token",
|
|
"secret",
|
|
"client_secret",
|
|
"cookie",
|
|
"set-cookie",
|
|
"password",
|
|
}
|
|
)
|
|
|
|
GITNEXUS_READ_ONLY_TOOLS = (
|
|
"mcp__gitnexus__list_repos",
|
|
"mcp__gitnexus__query",
|
|
"mcp__gitnexus__context",
|
|
"mcp__gitnexus__check",
|
|
"mcp__gitnexus__impact",
|
|
"mcp__gitnexus__explain",
|
|
"mcp__gitnexus__pdg_query",
|
|
"mcp__gitnexus__route_map",
|
|
"mcp__gitnexus__tool_map",
|
|
"mcp__gitnexus__shape_check",
|
|
"mcp__gitnexus__api_impact",
|
|
"mcp__gitnexus__trace",
|
|
"mcp__gitnexus__detect_changes",
|
|
)
|
|
GITNEXUS_MUTATING_TOOLS = ("mcp__gitnexus__rename",)
|
|
BUILTIN_AGENT_TOOLS = ("Read", "Grep", "Glob", "Edit", "Write", "Bash", "Skill")
|
|
|
|
|
|
def sandbox_mcp_config() -> str:
|
|
"""Credential-free MCP configuration using only the pinned harness runtime."""
|
|
|
|
entrypoint = PurePosixPath(SANDBOX_GITNEXUS_ENTRYPOINT)
|
|
workspace = PurePosixPath(SANDBOX_WORKSPACE)
|
|
if not entrypoint.is_absolute() or entrypoint == workspace or workspace in entrypoint.parents:
|
|
raise SandboxError(f"GitNexus MCP executable must stay outside {SANDBOX_WORKSPACE}")
|
|
|
|
config = {
|
|
"mcpServers": {
|
|
"gitnexus": {
|
|
"type": "stdio",
|
|
"command": "/usr/bin/env",
|
|
"args": [
|
|
"-i",
|
|
f"HOME={SANDBOX_HOME}",
|
|
f"TMPDIR={SANDBOX_TMP}",
|
|
f"GITNEXUS_HOME={SANDBOX_GITNEXUS_REGISTRY}",
|
|
f"GITNEXUS_MCP_ALLOWED_REPOS={SANDBOX_WORKSPACE}",
|
|
f"GITNEXUS_MCP_DEFAULT_REPO={SANDBOX_WORKSPACE}",
|
|
"PATH=/usr/local/bin:/usr/bin:/bin",
|
|
"LANG=C.UTF-8",
|
|
"GIT_TERMINAL_PROMPT=0",
|
|
SANDBOX_NODE,
|
|
SANDBOX_GITNEXUS_ENTRYPOINT,
|
|
"mcp",
|
|
],
|
|
}
|
|
}
|
|
}
|
|
return json.dumps(config, sort_keys=True, separators=(",", ":"))
|
|
|
|
|
|
def allowed_agent_tools(
|
|
*,
|
|
implementation: bool,
|
|
include_mcp: bool = True,
|
|
allow_edit: bool = True,
|
|
) -> list[str]:
|
|
tools = [tool for tool in BUILTIN_AGENT_TOOLS if allow_edit or tool != "Edit"]
|
|
if include_mcp:
|
|
tools.extend(GITNEXUS_READ_ONLY_TOOLS)
|
|
if include_mcp and implementation:
|
|
tools.extend(GITNEXUS_MUTATING_TOOLS)
|
|
return tools
|
|
|
|
|
|
def _persist_parent_event_stream(
|
|
raw: bytes,
|
|
*,
|
|
output_dir: Path,
|
|
relative_path: str,
|
|
secrets: tuple[str, ...],
|
|
) -> dict[str, Any]:
|
|
"""Persist only the complete event stream captured by the trusted parent."""
|
|
|
|
# Parsing before persistence proves the artifact is complete structured
|
|
# evidence, rather than arbitrary output injected through a tool result.
|
|
events = _parse_parent_event_stream(raw)
|
|
relative = PurePosixPath(relative_path)
|
|
if relative.is_absolute() or len(relative.parts) != 2 or relative.parts[0] != "transcripts":
|
|
raise ValueError(f"event-stream artifact path must be transcripts/<file>: {relative_path!r}")
|
|
if any(part in {"", ".", ".."} for part in relative.parts):
|
|
raise ValueError(f"unsafe event-stream artifact path: {relative_path!r}")
|
|
|
|
root = output_dir.expanduser().absolute()
|
|
root_mode = root.lstat().st_mode
|
|
if stat.S_ISLNK(root_mode) or not stat.S_ISDIR(root_mode) or root.resolve(strict=True) != root:
|
|
raise ValueError(f"event-stream output root must be a real non-symlink directory: {root}")
|
|
transcript_dir = root / relative.parts[0]
|
|
try:
|
|
transcript_dir.mkdir(mode=0o700)
|
|
except FileExistsError:
|
|
mode = transcript_dir.lstat().st_mode
|
|
if stat.S_ISLNK(mode) or not stat.S_ISDIR(mode):
|
|
raise ValueError(f"event-stream artifact parent must be a real directory: {transcript_dir}")
|
|
transcript_dir.chmod(0o700)
|
|
|
|
def redact_value(value: Any) -> Any:
|
|
if isinstance(value, str):
|
|
return redact_text(value, secrets)
|
|
if isinstance(value, list):
|
|
return [redact_value(item) for item in value]
|
|
if isinstance(value, dict):
|
|
redacted: dict[str, Any] = {}
|
|
for key, item in value.items():
|
|
source_key = str(key)
|
|
redacted_key = redact_text(source_key, secrets)
|
|
if redacted_key in redacted:
|
|
raise ValueError("event-stream keys collide after structural redaction")
|
|
redacted[redacted_key] = (
|
|
"[REDACTED]" if source_key.strip().casefold() in SENSITIVE_EVENT_KEYS else redact_value(item)
|
|
)
|
|
return redacted
|
|
return value
|
|
|
|
payload = (
|
|
"".join(
|
|
json.dumps(
|
|
redact_value(event),
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
ensure_ascii=False,
|
|
allow_nan=False,
|
|
)
|
|
+ "\n"
|
|
for event in events
|
|
)
|
|
).encode("utf-8")
|
|
if len(payload) > MAX_TRANSCRIPT_BYTES:
|
|
raise ValueError("redacted parent event stream exceeds the bounded artifact limit")
|
|
_parse_parent_event_stream(payload)
|
|
destination = transcript_dir / relative.name
|
|
descriptor = os.open(
|
|
destination,
|
|
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
|
|
0o600,
|
|
)
|
|
try:
|
|
os.fchmod(descriptor, 0o600)
|
|
view = memoryview(payload)
|
|
while view:
|
|
written = os.write(descriptor, view)
|
|
if written <= 0:
|
|
raise OSError("short write while persisting parent event stream")
|
|
view = view[written:]
|
|
os.fsync(descriptor)
|
|
finally:
|
|
os.close(descriptor)
|
|
return {
|
|
"path": relative.as_posix(),
|
|
"sha256": hashlib.sha256(payload).hexdigest(),
|
|
"bytes": len(payload),
|
|
"source": PARENT_EVENT_STREAM_SOURCE,
|
|
}
|
|
|
|
|
|
def _normalized_skill_identifier(value: Any) -> str | None:
|
|
"""Return the exact identifier token accepted by the Skill tool.
|
|
|
|
Plugin skills are requested as ``plugin:skill`` (observed in review
|
|
evolution: ``compound-engineering:ce-code-review``). Compare on the
|
|
skill token after the last colon so a successful plugin-qualified
|
|
invocation still counts as the expected skill.
|
|
"""
|
|
|
|
if not isinstance(value, str):
|
|
return None
|
|
stripped = value.strip()
|
|
if not stripped:
|
|
return None
|
|
token = stripped.split(maxsplit=1)[0]
|
|
if token.startswith("/"):
|
|
token = token[1:]
|
|
if ":" in token:
|
|
token = token.rsplit(":", 1)[-1]
|
|
return token or None
|
|
|
|
|
|
def _event_content(event: dict[str, Any]) -> list[Any]:
|
|
message = event.get("message")
|
|
content = (message or {}).get("content") if isinstance(message, dict) else None
|
|
if content is None:
|
|
content = event.get("content")
|
|
return content if isinstance(content, list) else []
|
|
|
|
|
|
def _reject_json_constant(value: str) -> None:
|
|
raise ValueError(f"non-finite JSON constant: {value}")
|
|
|
|
|
|
def _parse_parent_event_stream(raw: bytes) -> list[dict[str, Any]]:
|
|
"""Strictly parse every CLI-emitted event through EOF."""
|
|
|
|
try:
|
|
text = raw.decode("utf-8", errors="strict")
|
|
except UnicodeDecodeError as exc:
|
|
raise ValueError("parent-captured Claude event stream is not UTF-8") from exc
|
|
events: list[dict[str, Any]] = []
|
|
for line_number, line in enumerate(text.splitlines(), start=1):
|
|
if not line.strip():
|
|
continue
|
|
try:
|
|
event = json.loads(line, parse_constant=_reject_json_constant)
|
|
except (json.JSONDecodeError, ValueError) as exc:
|
|
raise ValueError(f"malformed parent-captured event JSON at line {line_number}") from exc
|
|
if not isinstance(event, dict):
|
|
raise ValueError(f"parent-captured event {line_number} is not an object")
|
|
events.append(event)
|
|
if not events:
|
|
raise ValueError("parent-captured Claude event stream contains no events")
|
|
return events
|
|
|
|
|
|
def skill_was_invoked_events(events: Sequence[dict[str, Any]], skill_name: str) -> bool:
|
|
"""Prove an exact Skill request had a later successful tool result."""
|
|
|
|
expected_identifier = _normalized_skill_identifier(skill_name)
|
|
if expected_identifier is None:
|
|
raise ValueError("expected skill name must contain an identifier")
|
|
tool_uses: dict[str, tuple[int, bool]] = {}
|
|
tool_results: dict[str, tuple[int, bool]] = {}
|
|
for event_index, event in enumerate(events):
|
|
for block in _event_content(event):
|
|
if not isinstance(block, dict):
|
|
continue
|
|
block_type = block.get("type")
|
|
if block_type == "tool_use":
|
|
tool_id = block.get("id")
|
|
if not isinstance(tool_id, str) or not re.fullmatch(r"[A-Za-z0-9._:-]{1,256}", tool_id):
|
|
raise ValueError("tool request has no bounded tool-use id")
|
|
if tool_id in tool_uses:
|
|
raise ValueError(f"duplicate tool-use id in parent event stream: {tool_id}")
|
|
matched = False
|
|
if str(block.get("name", "")).casefold() == "skill":
|
|
skill_input = block.get("input")
|
|
if isinstance(skill_input, dict):
|
|
matched = any(
|
|
_normalized_skill_identifier(skill_input.get(field)) == expected_identifier
|
|
for field in ("skill", "command", "name")
|
|
)
|
|
tool_uses[tool_id] = (event_index, matched)
|
|
elif block_type == "tool_result":
|
|
tool_id = block.get("tool_use_id")
|
|
if not isinstance(tool_id, str) or not re.fullmatch(r"[A-Za-z0-9._:-]{1,256}", tool_id):
|
|
raise ValueError("tool result has no bounded tool-use id")
|
|
if tool_id in tool_results:
|
|
raise ValueError(f"duplicate tool result in parent event stream: {tool_id}")
|
|
is_error = block.get("is_error")
|
|
if is_error not in (None, False, True):
|
|
raise ValueError(f"tool result has malformed is_error for {tool_id}")
|
|
tool_results[tool_id] = (event_index, is_error is True)
|
|
|
|
matching = [(tool_id, request_index) for tool_id, (request_index, matched) in tool_uses.items() if matched]
|
|
if not matching:
|
|
return False
|
|
successful = False
|
|
for tool_id, request_index in matching:
|
|
result = tool_results.get(tool_id)
|
|
if result is None:
|
|
raise ValueError(f"matching Skill request has no tool result: {tool_id}")
|
|
result_index, is_error = result
|
|
if result_index <= request_index:
|
|
raise ValueError(f"matching Skill result does not follow its request: {tool_id}")
|
|
successful = successful or not is_error
|
|
return successful
|
|
|
|
|
|
def run_claude(
|
|
prompt: str,
|
|
cwd: Path,
|
|
*,
|
|
claude_bin: str,
|
|
timeout: int,
|
|
disallowed_tools: list[str] | None = None,
|
|
model: str | None = None,
|
|
effort: str | None = None,
|
|
env: dict[str, str] | None = None,
|
|
permission_mode: str | None = None,
|
|
expected_skill: str | None = None,
|
|
command_prefix: list[str] | None = None,
|
|
require_pid_namespace: bool = False,
|
|
bare: bool = False,
|
|
settings_json: str | None = None,
|
|
strict_mcp_config: bool = False,
|
|
allowed_tools: list[str] | None = None,
|
|
disable_slash_commands: bool = False,
|
|
mcp_config_json: str | None = None,
|
|
transcript_projects: Path | None = None,
|
|
transcript_cwd: Path | None = None,
|
|
transcript_wait_seconds: float = 0,
|
|
transcript_output_dir: Path | None = None,
|
|
transcript_output_prefix: str | None = None,
|
|
transcript_secrets: tuple[str, ...] = (),
|
|
plugin_dirs: Sequence[str] = (),
|
|
progress_label: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Run one headless session and return its usage record."""
|
|
|
|
# Kept as compatibility parameters for callers, but deliberately ignored:
|
|
# every file under the sandbox HOME is writable by agent tools and cannot
|
|
# serve as trusted evidence.
|
|
del transcript_projects, transcript_cwd, transcript_wait_seconds
|
|
|
|
cmd = [
|
|
claude_bin,
|
|
"-p",
|
|
"--input-format",
|
|
"text",
|
|
"--output-format",
|
|
"stream-json",
|
|
"--verbose",
|
|
]
|
|
if bare:
|
|
cmd.append("--bare")
|
|
for plugin_dir in plugin_dirs:
|
|
cmd += ["--plugin-dir", plugin_dir]
|
|
if settings_json is not None:
|
|
cmd += ["--settings", settings_json]
|
|
if strict_mcp_config:
|
|
cmd += ["--strict-mcp-config", "--mcp-config", mcp_config_json or '{"mcpServers":{}}']
|
|
if allowed_tools:
|
|
# --bare's own hard-coded Bash/Edit/Read ceiling already scopes bare
|
|
# sessions; outside --bare the built-in toolset defaults to
|
|
# everything (subagents, WebFetch, Task, ...), so --tools is needed
|
|
# to actually restrict it — --allowedTools only pre-approves within
|
|
# whatever set is available, it does not narrow that set.
|
|
if not bare:
|
|
cmd += ["--tools", *allowed_tools]
|
|
cmd += ["--allowedTools", *allowed_tools]
|
|
if disable_slash_commands:
|
|
cmd.append("--disable-slash-commands")
|
|
if permission_mode:
|
|
cmd += ["--permission-mode", permission_mode]
|
|
if model:
|
|
cmd += ["--model", model]
|
|
if effort:
|
|
cmd += ["--effort", effort]
|
|
for tool in disallowed_tools or []:
|
|
cmd += ["--disallowedTools", tool]
|
|
managed_cmd = [*(command_prefix or []), *cmd]
|
|
started = time.monotonic()
|
|
with contextlib.ExitStack() as progress_stack:
|
|
progress = (
|
|
progress_stack.enter_context(
|
|
SessionProgress(
|
|
progress_label,
|
|
secrets=transcript_secrets,
|
|
)
|
|
)
|
|
if progress_label
|
|
else None
|
|
)
|
|
proc = run_managed(
|
|
managed_cmd,
|
|
cwd=None if command_prefix else cwd,
|
|
timeout=timeout,
|
|
env=env,
|
|
require_pid_namespace=require_pid_namespace,
|
|
stdin_data=prompt.encode(),
|
|
capture_stdout_bytes=MAX_TRANSCRIPT_BYTES,
|
|
stdout_observer=progress.observe if progress is not None else None,
|
|
)
|
|
wall_s = time.monotonic() - started
|
|
event_stream_error: str | None = None
|
|
events: list[dict[str, Any]] = []
|
|
try:
|
|
if proc.stdout_capture is None:
|
|
raise ValueError("parent process did not capture Claude stdout")
|
|
if proc.stdout_capture_overflow:
|
|
raise ValueError(f"parent-captured event stream exceeds {MAX_TRANSCRIPT_BYTES} bytes")
|
|
events = _parse_parent_event_stream(proc.stdout_capture)
|
|
result_indexes = [index for index, event in enumerate(events) if event.get("type") == "result"]
|
|
if len(result_indexes) != 1:
|
|
raise ValueError(f"expected exactly one final result event, observed {len(result_indexes)}")
|
|
result_index = result_indexes[0]
|
|
data = events[result_index]
|
|
# A session that used a background task drains its bookkeeping after
|
|
# the final result event (`background_tasks_changed`, `task_updated`,
|
|
# `task_notification` — all `type: "system"`), so the result is last
|
|
# only among the events that carry evidence. `system` events hold no
|
|
# tool_use/tool_result/usage payload and so cannot forge skill or cost
|
|
# evidence; any other event after the result still fails closed.
|
|
if any(event.get("type") != "system" for event in events[result_index + 1 :]):
|
|
raise ValueError("final result event is not the last event in the captured stream")
|
|
# Nothing after the result is evidence. Cut the window here so that is
|
|
# a property of what the evidence readers below can see, rather than an
|
|
# assumption that a `system` event never carries a tool_use block.
|
|
events = events[: result_index + 1]
|
|
except (UnicodeError, ValueError) as exc:
|
|
event_stream_error = str(exc)
|
|
data = {}
|
|
usage = data.get("usage") or {}
|
|
subtype = data.get("subtype")
|
|
well_formed = all(field in usage for field in USAGE_FIELDS)
|
|
session_error = (
|
|
not proc.ok
|
|
or event_stream_error is not None
|
|
or data.get("is_error", False)
|
|
or str(subtype).startswith("error")
|
|
or not well_formed
|
|
)
|
|
tool_use_counts: dict[str, int] = {}
|
|
for event in events:
|
|
for block in _event_content(event):
|
|
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
name = _safe_tool_name(block.get("name"))
|
|
tool_use_counts[name] = tool_use_counts.get(name, 0) + 1
|
|
record = {
|
|
"ok": not session_error,
|
|
"error_kind": "cancelled" if proc.state == "cancelled" else ("session-error" if session_error else None),
|
|
"error_detail": (
|
|
{
|
|
"subtype": subtype,
|
|
"returncode": proc.returncode,
|
|
"process_state": proc.state,
|
|
"stderr_tail": proc.stderr_tail[-2000:],
|
|
# A session can exit non-zero with an empty stderr (e.g. a
|
|
# pre-flight sandbox failure before any model turn): the tail
|
|
# of raw stdout is the only place the actual event stream
|
|
# (permission_denials, tool_use/tool_result, is_error) shows
|
|
# up, so surface it here rather than leaving the failure
|
|
# opaque. Callers already redact this record before it is
|
|
# written to disk or an uploaded artifact.
|
|
"stdout_tail": proc.stdout_tail[-2000:],
|
|
"process_detail": proc.detail,
|
|
"event_stream_error": event_stream_error,
|
|
}
|
|
if session_error
|
|
else None
|
|
),
|
|
"session_id": data.get("session_id"),
|
|
"num_turns": data.get("num_turns", 0),
|
|
"cost_usd": measured_cost(data.get("total_cost_usd")),
|
|
"duration_s": round(data.get("duration_ms", wall_s * 1000) / 1000, 1),
|
|
"transcript_missing": False,
|
|
"tool_use_counts": tool_use_counts,
|
|
"gitnexus_tool_uses": sum(count for name, count in tool_use_counts.items() if name.startswith("mcp__gitnexus")),
|
|
**{field: usage.get(field, 0) for field in USAGE_FIELDS},
|
|
}
|
|
needs_evidence = expected_skill is not None or transcript_output_dir is not None
|
|
if needs_evidence:
|
|
# Only stdout captured by the trusted parent is admissible evidence.
|
|
# The session's HOME is writable by agent tools and is deliberately
|
|
# ignored, including when the process reports an error or times out.
|
|
evidence_diagnostics: list[str] = []
|
|
if event_stream_error is not None:
|
|
evidence_diagnostics.append(f"unverifiable parent event stream: {event_stream_error}")
|
|
elif proc.stdout_capture is None:
|
|
evidence_diagnostics.append("unverifiable parent event stream: capture is missing")
|
|
elif proc.stdout_capture_overflow:
|
|
evidence_diagnostics.append(
|
|
f"unverifiable parent event stream: capture exceeds {MAX_TRANSCRIPT_BYTES} bytes"
|
|
)
|
|
else:
|
|
if expected_skill is not None:
|
|
try:
|
|
record["skill_invoked"] = skill_was_invoked_events(events, expected_skill)
|
|
except ValueError as exc:
|
|
record["skill_invoked"] = None
|
|
evidence_diagnostics.append(f"unverifiable skill evidence: {exc}")
|
|
|
|
if transcript_output_dir is not None:
|
|
try:
|
|
prefix = transcript_output_prefix or "session"
|
|
if not re.fullmatch(r"[A-Za-z0-9._-]{1,200}", prefix):
|
|
raise ValueError(f"unsafe transcript artifact prefix: {prefix!r}")
|
|
session_id = data.get("session_id")
|
|
if not isinstance(session_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", session_id):
|
|
raise ValueError(f"unsafe transcript session id: {session_id!r}")
|
|
record["session_id"] = session_id
|
|
record["transcript_artifact"] = _persist_parent_event_stream(
|
|
proc.stdout_capture,
|
|
output_dir=transcript_output_dir,
|
|
relative_path=f"transcripts/{prefix}-{session_id}.jsonl",
|
|
secrets=transcript_secrets,
|
|
)
|
|
except (OSError, UnicodeError, ValueError) as exc:
|
|
evidence_diagnostics.append(f"unverifiable event-stream persistence: {exc}")
|
|
|
|
if expected_skill is not None and "skill_invoked" not in record:
|
|
record["skill_invoked"] = None
|
|
if expected_skill is not None and record.get("skill_invoked") is False:
|
|
detail = f"parent event stream shows no successful {expected_skill} invocation"
|
|
if session_error:
|
|
evidence_diagnostics.append(detail)
|
|
elif not evidence_diagnostics:
|
|
record["ok"] = False
|
|
record["error_kind"] = "skill-not-invoked"
|
|
record["error_detail"] = detail
|
|
|
|
if evidence_diagnostics:
|
|
record["transcript_missing"] = True
|
|
record["evidence_diagnostics"] = evidence_diagnostics
|
|
if not session_error:
|
|
record["ok"] = False
|
|
record["error_kind"] = "evidence-unverified"
|
|
record["error_detail"] = "; ".join(evidence_diagnostics)
|
|
return record
|
|
|
|
|
|
def sum_sessions(sessions: list[dict[str, Any]]) -> dict[str, Any]:
|
|
total: dict[str, Any] = {field: sum(session[field] for session in sessions) for field in USAGE_FIELDS}
|
|
session_costs = [session["cost_usd"] for session in sessions]
|
|
total["cost_usd"] = None if any(cost is None for cost in session_costs) else round(sum(session_costs), 4)
|
|
total["duration_s"] = round(sum(session["duration_s"] for session in sessions), 1)
|
|
total["num_turns"] = sum(session["num_turns"] for session in sessions)
|
|
total["ok"] = all(session["ok"] for session in sessions)
|
|
total["session_ids"] = [session["session_id"] for session in sessions]
|
|
kinds = [session.get("error_kind") for session in sessions if session.get("error_kind")]
|
|
total["error_kind"] = kinds[0] if kinds else None
|
|
details = [session.get("error_detail") for session in sessions if session.get("error_detail")]
|
|
total["error_detail"] = details[0] if details else None
|
|
invocations = [session["skill_invoked"] for session in sessions if "skill_invoked" in session]
|
|
if False in invocations:
|
|
total["skill_invoked"] = False
|
|
elif None in invocations or not invocations:
|
|
total["skill_invoked"] = None
|
|
else:
|
|
total["skill_invoked"] = True
|
|
total["transcript_missing"] = any(session.get("transcript_missing", False) for session in sessions)
|
|
total["transcript_artifacts"] = [
|
|
session["transcript_artifact"] for session in sessions if "transcript_artifact" in session
|
|
]
|
|
total["evidence_diagnostics"] = [
|
|
diagnostic for session in sessions for diagnostic in session.get("evidence_diagnostics", [])
|
|
]
|
|
total["gitnexus_tool_uses"] = sum(session.get("gitnexus_tool_uses", 0) for session in sessions)
|
|
tool_use_counts: dict[str, int] = {}
|
|
for session in sessions:
|
|
for name, count in session.get("tool_use_counts", {}).items():
|
|
tool_use_counts[name] = tool_use_counts.get(name, 0) + int(count)
|
|
total["tool_use_counts"] = tool_use_counts
|
|
return total
|