Files
2026-07-13 13:12:00 +08:00

833 lines
36 KiB
Python

"""Drive native-TUI harnesses through a live server and host daemon.
Native sessions use the standard session HTTP surface, but require host/runner
provisioning and report cancellation through ``session.interrupted``.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import shutil
import signal
import subprocess
import threading
import time
import uuid
from dataclasses import dataclass
from pathlib import Path
import httpx
from omnigent.harness_capabilities import AuthModel, IntegrationMode
from omnigent.harness_plugins import harness_capabilities
from omnigent.host.daemon_launch import (
launch_or_reuse_daemon_runner,
wait_for_host_online,
wait_for_runner_online,
)
from omnigent.native_terminal import bind_session_runner
from omnigent.runner.identity import OMNIGENT_INTERNAL_WS_ORIGIN
from tests._helpers.compat import apply_runner_env, compat_runner_cwd, runner_executable
from tests._helpers.live_server import find_free_port
from tests.e2e._harness_probes import cli_unavailable_reason
from tests.harness_bench.driver import ProvisioningError, TurnResult, fill_snapshot_cost
from tests.harness_bench.full_server import spawn_omnigent_server
from tests.harness_bench.profile import BenchProfile
from tests.harness_bench.runtime_env import (
BenchRuntimeEnv,
bench_creds_skip_reason,
resolve_bench_env,
)
from tests.harness_bench.session_items import assistant_text, function_calls, item_role, item_type
# Timeouts are "clearly stuck" ceilings, not expected durations: provisioning is
# local (server/runner/host/forwarder boot, no model call) and a healthy native
# turn streams within seconds. A run that blows these is a cold-start on a slow
# CLI or a connection/network problem, not normal latency — so keep them tight
# enough that a broken harness fails fast, with cold-start headroom.
_HEALTH_TIMEOUT_S = 45.0
_HOST_ONLINE_TIMEOUT_S = 30.0
_POLL_INTERVAL_S = 0.3
_TURN_TIMEOUT_S = 60.0
_FORWARDER_READY_TIMEOUT_S = 45.0
_STREAM_PROMPT = "Count from 1 to 20 in words, one per line."
_LONG_PROMPT = "Write a detailed 500-word essay about the history of computing."
# ``response.completed`` precedes native text deltas; output_item.done is terminal.
_DELTA_EVENT = "response.output_text.delta"
_OUTPUT_DONE_EVENT = "response.output_item.done"
_IN_PROGRESS_EVENT = "response.in_progress"
_FAILED_EVENT = "response.failed"
_INTERRUPTED_EVENT = "session.interrupted"
_POLICY_DENIED_EVENT = "response.policy_denied"
_ELICITATION_EVENT = "response.elicitation_request"
_CEL_POLICY_HANDLER = "omnigent.policies.builtins.cel.cel_policy"
_NATIVE_POLICY_REASON = "bench-native-tool-policy"
_TOOL_TURN_TIMEOUT_S = 60.0
# policy_denied may arrive after output_item.done.
_DENY_OBSERVE_S = 15.0
_INTERRUPT_HOLD_S = 2.0
_READER_TERMINAL = frozenset({_OUTPUT_DONE_EVENT, _FAILED_EVENT, _INTERRUPTED_EVENT})
@dataclass(frozen=True)
class NativeVendor:
"""Per-vendor facts a native-tui harness needs beyond the shared path.
Derived from the capability model (see :func:`native_vendor`), so a native
harness — in-repo or a community plugin — is probeable with no bench edit.
:param harness: The native harness id, e.g. ``"claude-native"``.
:param agent_name: The server's auto-registered UI agent, by convention
``"<harness>-ui"`` (e.g. ``"claude-native-ui"``).
:param terminal_name: The native terminal to ensure, by convention the
vendor CLI name (``"<harness>" minus "-native"``, e.g. ``"codex"``).
:param own_auth: ``True`` when the vendor logs in itself (auth is not
``OMNIGENT_CREDENTIAL``), so the bench cannot provision it — runnable
only on a host where the vendor CLI is already logged in.
:param lazy_chat: ``True`` when the vendor's ``external_session_id`` (its
chat/thread id) is created by the FIRST message rather than at TUI
launch (cursor writes its chat store lazily on the first message). For
such a vendor the driver must NOT gate provisioning on
``external_session_id`` — that id cannot exist until a turn is posted,
so waiting for it pre-turn deadlocks. Thread-at-launch vendors
(claude/codex) leave this ``False`` and are gated normally.
:param tool_name: The vendor's own shell/terminal tool, e.g. ``"Bash"`` for
claude, ``"shell"`` for codex/kiro, ``"run_shell_command"`` for qwen.
Used for documentation and as a non-empty gate: empty means the
tool/policy probes cannot run for this vendor and SKIP. The deny gates
on the tool_call phase (name-agnostic), so this need not be wire-exact.
Not derivable from the capability model, so it is an explicit per-vendor
fact (see :data:`_NATIVE_TOOL_PROVOCATION`).
:param tool_prompt: A prompt that reliably makes the vendor call its shell
tool. Empty when :attr:`tool_name` is.
"""
harness: str
agent_name: str
terminal_name: str
own_auth: bool = False
lazy_chat: bool = False
tool_name: str = ""
tool_prompt: str = ""
# These vendors create external_session_id only after the first message.
_LAZY_CHAT_HARNESSES: frozenset[str] = frozenset({"cursor-native"})
# Missing entries skip tool and policy probes.
_SHELL_PROMPT = "Use your shell/terminal tool to run this exact command: echo omnigent-bench-ok"
_NATIVE_TOOL_PROVOCATION: dict[str, tuple[str, str]] = {
"claude-native": (
"Bash",
"Use the Bash tool to run this exact command: echo omnigent-bench-ok",
),
"codex-native": ("shell", _SHELL_PROMPT),
"pi-native": ("Bash", "Use the Bash tool to run this exact command: echo omnigent-bench-ok"),
"kiro-native": ("shell", _SHELL_PROMPT),
"qwen-native": ("run_shell_command", _SHELL_PROMPT),
"goose-native": ("developer__shell", _SHELL_PROMPT),
"hermes-native": ("terminal", _SHELL_PROMPT),
"antigravity-native": ("run_command", _SHELL_PROMPT),
"kimi-native": ("Bash", "Use the Bash tool to run this exact command: echo omnigent-bench-ok"),
}
def native_vendor(harness: str) -> NativeVendor | None:
"""Derive the :class:`NativeVendor` for *harness* from its capabilities.
Returns ``None`` unless the harness declares ``integration_mode ==
NATIVE_TUI`` in :func:`omnigent.harness_plugins.harness_capabilities`
(which already discovers community plugins via entry points), so any
native-tui harness is drivable by name with no per-vendor table here.
``native-server`` harnesses (e.g. opencode-native) are a different
transport and return ``None``.
"""
caps = harness_capabilities().get(harness)
if caps is None or caps.integration_mode is not IntegrationMode.NATIVE_TUI:
return None
tool_name, tool_prompt = _NATIVE_TOOL_PROVOCATION.get(harness, ("", ""))
return NativeVendor(
harness=harness,
agent_name=f"{harness}-ui",
terminal_name=harness.removesuffix("-native"),
own_auth=caps.auth is not AuthModel.OMNIGENT_CREDENTIAL,
lazy_chat=harness in _LAZY_CHAT_HARNESSES,
tool_name=tool_name,
tool_prompt=tool_prompt,
)
class NativeTuiDriver:
"""Drive a native-tui harness through a live server + host daemon.
Async context manager: on enter it spawns a server, a host daemon (under
the real ``$HOME`` so the vendor login is inherited), waits for the host
to come online, and creates a native session bound to that host. The
``run_*`` methods drive turns over the same HTTP surface the full-server
driver uses; the runner mirrors them into the tmux vendor TUI.
"""
transport = "native-tui"
def __init__(self, profile: BenchProfile, *, databricks_profile: str | None) -> None:
self._profile = profile
self._db_profile = databricks_profile
self._resolved_env: BenchRuntimeEnv | None = None
self._vendor = native_vendor(profile.harness)
self._proc: subprocess.Popen[bytes] | None = None
self._daemon: subprocess.Popen[bytes] | None = None
self._client: httpx.Client | None = None
self._session_id: str | None = None
self._base_url = ""
self._tmp = Path("/tmp") / f"omni-bench-nt-{uuid.uuid4().hex[:8]}"
self._policy_hook_disabled_reason: str | None = None
@staticmethod
def unavailable(profile: BenchProfile, *, databricks_profile: str | None) -> str | None:
"""Return a skip reason if this driver cannot run *profile*, else None."""
vendor = native_vendor(profile.harness)
if vendor is None:
return f"{profile.harness!r} is not a native-tui harness"
creds_skip = bench_creds_skip_reason(databricks_profile)
if creds_skip is not None:
return creds_skip
binary = profile.cli_binary
if binary is not None:
reason = cli_unavailable_reason(binary)
if reason is not None:
return reason
return None
async def __aenter__(self) -> NativeTuiDriver:
try:
await asyncio.to_thread(self._provision)
except httpx.HTTPError as exc:
raise ProvisioningError(f"native provisioning HTTP error: {exc}") from exc
return self
async def __aexit__(self, *exc: object) -> None:
await asyncio.to_thread(self._teardown)
async def run_basic_turn(self, marker: str) -> TurnResult:
prompt = f"Reply with exactly the literal string {marker} and nothing else."
return await asyncio.to_thread(self._drive_turn, prompt)
async def run_streaming_turn(self) -> TurnResult:
return await asyncio.to_thread(self._drive_turn, _STREAM_PROMPT, count_deltas=True)
async def run_tool_turn(self, *, deny: bool) -> TurnResult:
return await asyncio.to_thread(self._drive_tool_turn, deny=deny)
async def run_policy_turn(self, *, action: str) -> TurnResult:
return await asyncio.to_thread(self._drive_policy_turn, action=action)
async def run_interrupt_turn(self) -> TurnResult:
return await asyncio.to_thread(self._drive_interrupt_turn)
def _provision(self) -> None:
self._tmp.mkdir(mode=0o700, parents=True, exist_ok=True)
assert self._vendor is not None
port = find_free_port()
self._base_url = f"http://localhost:{port}"
binding_token = uuid.uuid4().hex
self._resolved_env = resolve_bench_env(self._db_profile)
base_env = {
**self._resolved_env.base_env,
"OMNIGENT_RUNNER_TUNNEL_TOKEN": binding_token,
}
# Omnigent-credential natives resolve their provider from global config.
if not self._vendor.own_auth:
base_env["OMNIGENT_CONFIG_HOME"] = str(self._write_provider_config())
self._proc = spawn_omnigent_server(self._tmp, port, base_env, binding_token)
self._wait_health()
self._daemon = self._spawn_host_daemon(base_env)
self._client = httpx.Client(
base_url=self._base_url,
timeout=300.0,
headers={"Origin": OMNIGENT_INTERNAL_WS_ORIGIN},
)
host_id = self._wait_host_online()
agent_id = self._agent_id(self._vendor.agent_name)
workspace = self._tmp / "workspace"
workspace.mkdir(exist_ok=True)
created = self._client.post(
"/v1/sessions",
json={"agent_id": agent_id, "host_id": host_id, "workspace": str(workspace)},
timeout=60.0,
)
created.raise_for_status()
self._session_id = str(created.json()["id"])
self._wire_native_forwarder(host_id, workspace)
def _write_provider_config(self) -> Path:
"""Write config routing the native vendor through the resolved profile."""
config_home = self._tmp / "omnigent-config"
config_home.mkdir(exist_ok=True)
profile = self._resolved_env.db_profile if self._resolved_env is not None else None
body = f"auth:\n type: databricks\n profile: {profile}\n" if profile else "auth: {}\n"
(config_home / "config.yaml").write_text(body, encoding="utf-8")
return config_home
def _wire_native_forwarder(self, host_id: str, workspace: Path) -> None:
"""Launch the runner and wait for the native forwarder."""
assert self._client is not None and self._session_id is not None
assert self._vendor is not None
session_id = self._session_id
self._launch_and_bind_runner(host_id, workspace)
ensure = self._client.post(
f"/v1/sessions/{session_id}/resources/terminals",
json={
"terminal": self._vendor.terminal_name,
"session_key": "main",
"ensure_native_terminal": True,
},
timeout=_FORWARDER_READY_TIMEOUT_S,
)
ensure.raise_for_status()
with contextlib.suppress(Exception):
self._policy_hook_disabled_reason = ensure.json().get("policy_hook_disabled_reason")
# Waiting before the first message would deadlock lazy-chat vendors.
if self._vendor.lazy_chat:
return
deadline = time.monotonic() + _FORWARDER_READY_TIMEOUT_S
while time.monotonic() < deadline:
snap = self._client.get(f"/v1/sessions/{session_id}")
if snap.status_code == 200 and snap.json().get("external_session_id"):
return
time.sleep(_POLL_INTERVAL_S)
raise ProvisioningError(
f"native forwarder did not wire up within {_FORWARDER_READY_TIMEOUT_S}s "
f"(no external_session_id); logs in {self._tmp}"
)
def _launch_and_bind_runner(self, host_id: str, workspace: Path) -> str:
"""Launch (or reuse) a daemon runner for the session and bind it.
The daemon auto-spawns a runner, but the native terminal ensure needs
the session explicitly bound to an online runner first (an unbound
session 503s ``runner_unavailable``). Bridges the async daemon-launch
helpers into this sync provisioning path.
"""
assert self._client is not None and self._session_id is not None
session_id = self._session_id
async def _run() -> str:
async with httpx.AsyncClient(
base_url=self._base_url,
timeout=httpx.Timeout(30.0, read=120.0),
headers={"Origin": OMNIGENT_INTERNAL_WS_ORIGIN},
) as ac:
await wait_for_host_online(ac, host_id, timeout_s=_HOST_ONLINE_TIMEOUT_S)
runner_id = await launch_or_reuse_daemon_runner(
ac, host_id=host_id, session_id=session_id, workspace=str(workspace)
)
await wait_for_runner_online(ac, runner_id, timeout_s=_HOST_ONLINE_TIMEOUT_S)
await bind_session_runner(ac, session_id, runner_id)
return runner_id
return asyncio.run(_run())
def _spawn_host_daemon(self, base_env: dict[str, str]) -> subprocess.Popen[bytes]:
# Keep the real HOME so the vendor login remains available.
log = (self._tmp / "host-daemon.log").open("wb")
return subprocess.Popen(
[runner_executable(), "-m", "omnigent.host._daemon_entry", "--server", self._base_url],
env=apply_runner_env(base_env),
cwd=compat_runner_cwd(),
stdout=subprocess.DEVNULL,
stderr=log,
)
def _wait_health(self) -> None:
deadline = time.monotonic() + _HEALTH_TIMEOUT_S
while time.monotonic() < deadline:
try:
if httpx.get(f"{self._base_url}/health", timeout=2).status_code == 200:
return
except httpx.HTTPError:
pass
time.sleep(_POLL_INTERVAL_S)
raise ProvisioningError(
f"server not healthy within {_HEALTH_TIMEOUT_S}s; logs in {self._tmp}"
)
def _wait_host_online(self) -> str:
assert self._client is not None
deadline = time.monotonic() + _HOST_ONLINE_TIMEOUT_S
while time.monotonic() < deadline:
resp = self._client.get("/v1/hosts")
if resp.status_code == 200:
online = [h for h in resp.json().get("hosts", []) if h.get("status") == "online"]
if online:
return str(online[0]["host_id"])
time.sleep(_POLL_INTERVAL_S)
raise ProvisioningError(f"no host came online within {_HOST_ONLINE_TIMEOUT_S}s")
def _agent_id(self, agent_name: str) -> str:
assert self._client is not None
resp = self._client.get("/v1/agents")
resp.raise_for_status()
for agent in resp.json()["data"]:
if agent.get("name") == agent_name:
return str(agent["id"])
raise ProvisioningError(f"{agent_name!r} not auto-registered on the server")
def _teardown(self) -> None:
if self._client is not None:
self._client.close()
for proc in (self._daemon, self._proc):
if proc is not None and proc.poll() is None:
proc.send_signal(signal.SIGTERM)
try:
proc.wait(timeout=8)
except subprocess.TimeoutExpired:
proc.kill()
shutil.rmtree(self._tmp, ignore_errors=True)
def _post_message(self, prompt: str) -> None:
assert self._client is not None
self._client.post(
f"/v1/sessions/{self._session_id}/events",
json={
"type": "message",
"data": {"role": "user", "content": [{"type": "input_text", "text": prompt}]},
},
timeout=30.0,
).raise_for_status()
def _drive_turn(self, prompt: str, *, count_deltas: bool = False) -> TurnResult:
"""Send *prompt*; read the terminal + delta count from the stream and
the reply text from the *new* assistant item.
Two sources, each for what it reliably provides:
- **Stream** (subscribe-first, background thread): the delta count,
scoped to this turn. Subscribing before posting is required — the
stream is not replayed. The reader stops on
``response.output_item.done`` (the true end-of-output), NOT on
``response.completed`` — on native-tui that fires seconds early
(see ``_READER_TERMINAL``), so stopping there would count zero
deltas.
- **Item poll**: the assistant reply text. A short reply may arrive as
a single ``response.output_item.done`` with no text deltas, so
delta-accumulated text is unreliable for basic turns — the persisted
item is authoritative. The driver reuses one session across probes,
so the poll must ignore items that predate this turn: it records the
assistant-item count *before* posting and waits for a NEW one.
"""
assert self._client is not None
result = TurnResult()
events: list[str] = []
ready = threading.Event()
def _read() -> None:
assert self._client is not None
try:
with self._client.stream(
"GET", f"/v1/sessions/{self._session_id}/stream", timeout=_TURN_TIMEOUT_S
) as resp:
ready.set()
for line in resp.iter_lines():
if line.startswith("event:"):
events.append(line[len("event:") :].strip())
if events[-1] in _READER_TERMINAL:
return
except httpx.HTTPError as exc:
result.error = repr(exc)
baseline = self._assistant_item_count()
reader = threading.Thread(target=_read)
reader.start()
ready.wait(timeout=10.0) # subscribe before posting so no delta is lost
self._post_message(prompt)
text = self._poll_new_assistant_text(baseline)
reader.join(timeout=10.0)
result.text_delta_count = sum(1 for e in events if e == _DELTA_EVENT)
result.text = text or ""
if text is not None or _OUTPUT_DONE_EVENT in events:
result.completed = True
else:
result.timed_out = True
if result.completed:
self._fill_cost_from_snapshot(result)
return result
def _fill_cost_from_snapshot(self, result: TurnResult) -> None:
"""Read cumulative usage/cost off the session snapshot after a turn.
Native runtimes report usage via ``external_session_usage`` (published as
``session.usage``); the cumulative totals also land on the session
snapshot (``total_cost_usd`` / ``last_total_tokens``), which is the same
read point the full-server driver uses. A vendor that forwards no usage
leaves both ``None`` and the cost probe SKIPs with that reason.
"""
assert self._client is not None
try:
snap = self._client.get(f"/v1/sessions/{self._session_id}", timeout=15.0)
if snap.status_code == 200:
fill_snapshot_cost(result, snap.json())
except httpx.HTTPError:
pass # cost is best-effort; absence -> probe SKIPs, never a false verdict
def _drive_tool_turn(self, *, deny: bool) -> TurnResult:
"""Provoke the vendor's own tool, observe the call (and, with *deny*, the block).
The tool call surfaces as a persisted ``function_call`` item (the vendor
bridge mirrors ``tool_use`` into one), so ``result.tool_calls`` is filled
by scanning the session items past a pre-turn baseline. With *deny*, a
tool_call-phase DENY is attached to the session first (a CEL policy
targeting the provoked tool); the block is decided in the vendor hook and
surfaces on the stream as ``response.policy_denied`` — the positive signal
that sets ``result.tool_call_denied``.
Returns a capability-neutral SKIP (``error`` set, no verdict fields) when
the vendor has no known tool to provoke, or when *deny* is requested but
native policy enforcement is inactive (fail-open) — so the probes never
read an environment gap as UNSUPPORTED.
"""
assert self._client is not None and self._vendor is not None
result = TurnResult()
if not self._vendor.tool_name:
result.error = f"no tool-provocation mapping for {self._vendor.harness!r}; skipped"
return result
if deny:
if self._policy_hook_disabled_reason:
result.error = (
f"native policy enforcement inactive ({self._policy_hook_disabled_reason}); "
"cannot exercise tool-call DENY"
)
return result
policy_id, attach_error = self._attach_tool_policy("deny")
if attach_error is not None:
result.error = attach_error
return result
else:
policy_id = None
events: list[str] = []
ready = threading.Event()
stop = threading.Event()
def _read() -> None:
assert self._client is not None
# A deny reader stays open because policy_denied can arrive after completion.
try:
with self._client.stream(
"GET",
f"/v1/sessions/{self._session_id}/stream",
timeout=_TOOL_TURN_TIMEOUT_S,
) as resp:
ready.set()
for line in resp.iter_lines():
if line.startswith("event:"):
etype = line[len("event:") :].strip()
events.append(etype)
if etype == _POLICY_DENIED_EVENT:
result.tool_call_denied = True
return
if not deny and etype in _READER_TERMINAL:
return
if stop.is_set():
return
except httpx.HTTPError as exc:
result.error = repr(exc)
# Avoid satisfying the second probe from reused-session history.
token = "deny" if deny else "allow"
prompt = self._vendor.tool_prompt.replace("omnigent-bench-ok", f"omnigent-bench-{token}")
reader = threading.Thread(target=_read)
try:
baseline = self._tool_item_count()
reader.start()
ready.wait(timeout=10.0)
self._post_message(prompt)
self._poll_new_tool_calls(baseline, result)
if deny and not result.tool_call_denied:
deadline = time.monotonic() + _DENY_OBSERVE_S
while reader.is_alive() and time.monotonic() < deadline:
reader.join(timeout=0.5)
finally:
stop.set()
if reader.is_alive():
reader.join(timeout=10.0)
self._delete_tool_policy(policy_id)
result.completed = _OUTPUT_DONE_EVENT in events or bool(result.tool_calls)
return result
def _drive_policy_turn(self, *, action: str) -> TurnResult:
"""Provoke a native tool under an explicit ALLOW or ASK policy.
ALLOW proves that a tool proceeds while an explicit policy is attached;
the native hook emits no positive signal that distinguishes an evaluated
ALLOW from its default no-op behavior.
"""
if action not in {"allow", "ask"}:
raise ValueError(f"unsupported native policy action: {action!r}")
assert self._client is not None and self._vendor is not None
result = TurnResult()
if not self._vendor.tool_name:
result.error = f"no tool-provocation mapping for {self._vendor.harness!r}; skipped"
return result
if self._policy_hook_disabled_reason:
result.error = (
f"native policy enforcement inactive ({self._policy_hook_disabled_reason}); "
f"cannot exercise tool-call {action.upper()}"
)
return result
policy_id, attach_error = self._attach_tool_policy(action)
if attach_error is not None:
result.error = attach_error
return result
ready = threading.Event()
stop = threading.Event()
elicitation_id: list[str] = []
def _read() -> None:
assert self._client is not None
event_type: str | None = None
try:
with self._client.stream(
"GET",
f"/v1/sessions/{self._session_id}/stream",
timeout=_TOOL_TURN_TIMEOUT_S,
) as resp:
ready.set()
for line in resp.iter_lines():
if line.startswith("event:"):
event_type = line[len("event:") :].strip()
if action == "allow" and event_type in _READER_TERMINAL:
return
if line.startswith("data:"):
try:
frame = json.loads(line[len("data:") :].strip())
except (TypeError, ValueError):
continue
if frame.get("type") == _ELICITATION_EVENT or (
event_type == _ELICITATION_EVENT
):
value = frame.get("elicitation_id")
if isinstance(value, str):
elicitation_id.append(value)
result.elicitation_requested = True
return
if stop.is_set():
return
except httpx.HTTPError as exc:
result.error = repr(exc)
reader = threading.Thread(target=_read)
try:
baseline = self._tool_item_count()
reader.start()
ready.wait(timeout=10.0)
prompt = self._vendor.tool_prompt.replace(
"omnigent-bench-ok", f"omnigent-bench-{action}"
)
self._post_message(prompt)
deadline = time.monotonic() + _TOOL_TURN_TIMEOUT_S
while time.monotonic() < deadline:
if result.elicitation_requested:
if elicitation_id:
self._resolve_elicitation(elicitation_id[0])
break
self._poll_new_tool_calls(
baseline, result, timeout=min(1.0, deadline - time.monotonic())
)
if result.tool_calls:
break
else:
result.timed_out = True
finally:
stop.set()
reader.join(timeout=10.0)
self._delete_tool_policy(policy_id)
result.completed = bool(result.tool_calls)
result.tool_call_allowed = action == "allow" and bool(result.tool_calls)
return result
def _attach_tool_policy(self, action: str) -> tuple[str | None, str | None]:
"""Attach a temporary CEL policy for every native tool call."""
assert self._client is not None
verdict = action.upper()
expression = (
'event.type == "tool_call" '
f'? {{"result": "{verdict}", "reason": "{_NATIVE_POLICY_REASON}"}} '
': {"result": "ALLOW"}'
)
resp = self._client.post(
f"/v1/sessions/{self._session_id}/policies",
json={
"name": f"bench_tool_{action}_{uuid.uuid4().hex[:8]}",
"type": "python",
"handler": _CEL_POLICY_HANDLER,
"factory_params": {"expression": expression, "reason": _NATIVE_POLICY_REASON},
},
timeout=30.0,
)
if resp.status_code not in (200, 201):
return None, (
f"could not attach tool-call {action} policy (status {resp.status_code}); skipped"
)
policy_id = resp.json().get("id")
return (policy_id if isinstance(policy_id, str) else None), None
def _delete_tool_policy(self, policy_id: str | None) -> None:
if policy_id is None:
return
assert self._client is not None
with contextlib.suppress(httpx.HTTPError):
self._client.delete(
f"/v1/sessions/{self._session_id}/policies/{policy_id}", timeout=30.0
)
def _resolve_elicitation(self, elicitation_id: str) -> None:
assert self._client is not None
with contextlib.suppress(httpx.HTTPError):
self._client.post(
f"/v1/sessions/{self._session_id}/events",
json={
"type": "approval",
"data": {"elicitation_id": elicitation_id, "action": "accept"},
},
)
def _tool_item_count(self) -> int:
"""Current number of function_call items in the session (pre-turn baseline)."""
assert self._client is not None
resp = self._client.get(f"/v1/sessions/{self._session_id}/items", params={"order": "asc"})
if resp.status_code != 200:
return 0
return sum(1 for it in resp.json().get("data", []) if item_type(it) == "function_call")
def _poll_new_tool_calls(
self, baseline: int, result: TurnResult, timeout: float = _TOOL_TURN_TIMEOUT_S
) -> None:
"""Poll session items for NEW function_call items; append them to *result*.
Scoped past *baseline* so a reused session's prior tool calls don't
re-count. Stops as soon as at least one new call is seen (or on a deny,
as soon as the deny signal already landed on the stream).
"""
assert self._client is not None
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
resp = self._client.get(
f"/v1/sessions/{self._session_id}/items", params={"order": "asc"}
)
if resp.status_code == 200:
calls = function_calls(resp.json().get("data", []))
if len(calls) > baseline:
result.tool_calls.extend(calls[baseline:])
return
if result.tool_call_denied:
return
time.sleep(_POLL_INTERVAL_S)
def _assistant_item_count(self) -> int:
"""Current number of assistant items in the session (pre-turn baseline)."""
assert self._client is not None
resp = self._client.get(f"/v1/sessions/{self._session_id}/items", params={"order": "asc"})
if resp.status_code != 200:
return 0
return sum(1 for it in resp.json().get("data", []) if item_role(it) == "assistant")
def _poll_new_assistant_text(
self, baseline: int, timeout: float = _TURN_TIMEOUT_S
) -> str | None:
"""Poll until a NEW assistant item (beyond *baseline*) appears; return its text.
Scoping to items past the pre-turn baseline is what keeps a reused
session from returning a prior turn's stale reply.
"""
assert self._client is not None
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
resp = self._client.get(
f"/v1/sessions/{self._session_id}/items", params={"order": "asc"}
)
if resp.status_code == 200:
assistants = [
it for it in resp.json().get("data", []) if item_role(it) == "assistant"
]
if len(assistants) > baseline:
return assistant_text(assistants[-1], separator=" ")
time.sleep(_POLL_INTERVAL_S)
return None
def _drive_interrupt_turn(self) -> TurnResult:
"""Start a long turn, interrupt it mid-flight, detect the cancel.
Native cancellation surfaces as a ``session.interrupted`` SSE event
(no persisted "interrupted" user message, unlike full-server). The
reader subscribes **before** posting so no event is lost; the main
thread drives the interrupt timing.
Why the main thread fires the interrupt (not the reader on first
delta): on native-tui the text deltas arrive in a burst at the very
*end* of the turn, after seconds of the vendor CLI working. Waiting
for a delta to fire the interrupt would leave a fraction of a second
before the turn finishes — too late to land mid-turn. The turn is
in-flight from ``response.in_progress`` onward, so the reader signals
that, and the main thread interrupts after a short hold while the CLI
is still working.
"""
assert self._client is not None
result = TurnResult()
ready = threading.Event()
in_progress = threading.Event()
def _read() -> None:
assert self._client is not None
try:
with self._client.stream(
"GET", f"/v1/sessions/{self._session_id}/stream", timeout=_TURN_TIMEOUT_S
) as resp:
ready.set()
for line in resp.iter_lines():
if not line.startswith("event:"):
continue
etype = line[len("event:") :].strip()
if etype == _IN_PROGRESS_EVENT:
in_progress.set()
elif etype == _DELTA_EVENT:
result.text_delta_count += 1
elif etype == _INTERRUPTED_EVENT:
result.cancelled = True
return
elif etype in (_OUTPUT_DONE_EVENT, _FAILED_EVENT):
return
except httpx.HTTPError as exc:
result.error = repr(exc)
reader = threading.Thread(target=_read)
reader.start()
ready.wait(timeout=10.0)
self._post_message(_LONG_PROMPT)
# Native deltas arrive late, so interrupt from in-progress instead.
if in_progress.wait(timeout=_TURN_TIMEOUT_S):
time.sleep(_INTERRUPT_HOLD_S)
try:
self._client.post(
f"/v1/sessions/{self._session_id}/events",
json={"type": "interrupt"},
timeout=15.0,
)
except httpx.HTTPError as exc:
result.error = repr(exc)
reader.join(timeout=_TURN_TIMEOUT_S)
return result