Files
hack-house/cmd_chat/operator/harness.py
leetcrypt 0798db7941 feat(operator): harness-mode operator — drive a room with any function-calling model (P2)
The operator brain was Claude-only: spawn launched the claude CLI and let its
own loop drive the room. This adds the other first-class brain — a native
tool-calling loop (cmd_chat/operator/harness.py) that runs ANY function-calling
Provider (cmd_chat.ai) against an OPERATOR_TOOLS schema wired to the existing
bridge control verbs (say/exec/write/get/keys/screen/watch/manifest). No CLI
install, no creds carry.

The harness is a driver, not a new side-effect surface: every tool handler sends
the same control-socket request the hh-bridge CLI already sends, so the bridge
keeps enforcing grant-before-drive, the sandbox blast radius, and the recursion
budget. Layer-1 capabilities come from CAPABILITIES.md (the same portable
contract), and a per-agent token ceiling is enforced as a hard stop. Providers
that can't function-call are refused with a clear message rather than degrading
to prose.

New `operate` verb joins via the existing daemon socket and runs the loop with a
--profile or --provider/--model brain. Chat's native loop is left untouched
(self-contained harness over the shared Provider core, not a risky extraction).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-28 23:23:17 -07:00

450 lines
20 KiB
Python

"""Harness-mode operator (Phase 2) — run *any* function-calling model as a
hack-house operator.
The CLI-subprocess operator (``bootstrap`` runner registry) spawns a vendor
agent CLI and lets *its* own loop drive the room. This module is the other
first-class brain: it runs the native tool-calling loop ourselves, against the
shared Provider core (``cmd_chat.ai``), with the **operator toolset** wired to
the bridge daemon's control verbs. So a raw Ollama / OpenAI-compatible model —
no CLI install, no creds carry — becomes a first-class operator.
It is deliberately a *driver*, not a new side-effect surface: every tool handler
just sends the same control-socket request the `hh-bridge` CLI already sends
(``say``/``exec``/``write``/``get``/``keys``/``screen``/``watch``/``manifest``/
``spawn``). The bridge keeps enforcing grant-before-drive, the sandbox blast
radius, and the recursion budget — the harness only chooses *which* verb to call.
The read→think→act loop owns the **read**: it polls room events between turns and
feeds them to the model as observations, so the model spends its turns deciding
and acting rather than remembering to poll. Capabilities (Layer-1) come straight
from ``CAPABILITIES.md`` via :func:`bootstrap.load_capabilities` — the same
portable contract the Claude skill loads — so harness and CLI operators share one
source of truth.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from typing import Callable
from cmd_chat.ai.providers import ToolsUnsupported
from . import bootstrap as boot
from .cli_client import request as _socket_request
# The operator toolset — each maps 1:1 to a bridge control verb the daemon
# already implements. Schema is the Ollama/OpenAI `tools` shape (same as the chat
# agent's NATIVE_TOOLS), so any provider that does function calling can drive it.
OPERATOR_TOOLS = [
{
"type": "function",
"function": {
"name": "say",
"description": "Speak one line into the room. Report what you are doing "
"and what you found — the room is the coordination bus.",
"parameters": {
"type": "object",
"properties": {
"text": {"type": "string", "description": "The chat line to send."},
},
"required": ["text"],
},
},
},
{
"type": "function",
"function": {
"name": "exec",
"description": "Run a shell command in the shared sandbox out-of-band "
"(does not disturb the live terminal). Returns combined "
"stdout+stderr and the exit code.",
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string", "description": "The shell command to run."},
},
"required": ["command"],
},
},
},
{
"type": "function",
"function": {
"name": "write_file",
"description": "Create or overwrite a file in the sandbox with exact content.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Destination file path."},
"content": {"type": "string", "description": "Full file content."},
},
"required": ["path", "content"],
},
},
},
{
"type": "function",
"function": {
"name": "get_file",
"description": "Read and return the contents of a file in the sandbox.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path to read."},
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "keys",
"description": "Inject keystrokes into the room's shared live terminal "
"(drive the PTY). Use for interactive programs; prefer "
"exec for one-shot commands.",
"parameters": {
"type": "object",
"properties": {
"text": {"type": "string", "description": "Literal text to type."},
"enter": {"type": "boolean",
"description": "Press Enter after the text (default true)."},
},
"required": ["text"],
},
},
},
{
"type": "function",
"function": {
"name": "screen",
"description": "Print the relayed shared-sandbox terminal buffer "
"(what everyone watching the PTY sees).",
"parameters": {
"type": "object",
"properties": {
"tail": {"type": "integer",
"description": "Last N bytes only (optional)."},
},
},
},
},
{
"type": "function",
"function": {
"name": "watch",
"description": "Block until a regex fires in room events or the terminal "
"screen — how you wait on a teammate's report or a build "
"result instead of sleeping.",
"parameters": {
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "Regex to match."},
"source": {"type": "string", "enum": ["events", "screen"],
"description": "Where to match (default: events)."},
"timeout": {"type": "number",
"description": "Give up after this many seconds."},
},
"required": ["pattern"],
},
},
},
{
"type": "function",
"function": {
"name": "manifest",
"description": "Stamp the sandbox's .hh-agent handoff record so work and "
"memory hand off to the next agent or human.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["push", "update"],
"description": "push a fresh record or update the state."},
"status": {"type": "string", "description": "Current status line."},
"progress": {"type": "string", "description": "Progress note."},
"done": {"type": "string", "description": "A completed item."},
"todo": {"type": "string", "description": "A next item."},
},
"required": ["action"],
},
},
},
]
# Tool names that drive the sandbox/room mutably — used only for messaging; the
# bridge is the real authority on grant-before-drive.
_TOOL_NAMES = {(t["function"]["name"]) for t in OPERATOR_TOOLS}
# DONE marker (same convention as the chat native loop): a text-only turn whose
# first line starts with this means the model considers the objective met.
_DONE_RE = re.compile(r"^\s*DONE:", re.I)
DEFAULT_MAX_TURNS = 12
DEFAULT_TOKEN_CEILING = 20000 # cumulative prompt+completion tokens (hard stop)
DEFAULT_MAX_NUDGES = 2
_OBS_CAP = 4096 # bytes of any single observation fed back to the model
HARNESS_SYSTEM_TAIL = (
"\n\nYou act ONLY by calling the provided tools (say/exec/write_file/get_file/"
"keys/screen/watch/manifest) — never by describing an action in prose. Work in "
"small steps and read each tool result before the next call. You may read and "
"`say` immediately, but exec/write_file/get_file/keys only work once the room "
"owner has granted you drive (the bridge will return an error until then — wait "
"and watch for the grant). When the objective is fully met, reply with a single "
"line starting 'DONE:' and a one-sentence summary, and do NOT call another tool."
)
def compose_system(objective: str, *, stop: list[str] | None = None,
budget: "boot.Budget | None" = None,
role_overlay: str | None = None) -> str:
"""The harness operator's system prompt: portable Layer-1 capabilities +
objective + stop conditions + (optional) role overlay + recursion budget +
the tool-only operating rule. One screen of capabilities, behaviour overlaid —
no Claude-isms (the 'use the skill' line lives only in the Claude path)."""
stop = stop or ["objective met", "owner says stop/leave", "idle past remit"]
parts = [boot.load_capabilities()]
if role_overlay:
parts.append(role_overlay.strip())
parts.append(f"OBJECTIVE: {objective}")
parts.append("Stop conditions (say a short sign-off, then stop when any holds): "
+ "; ".join(stop) + ".")
if budget is not None:
parts.append(
f"Recursion budget you inherit: depth={budget.depth}, "
f"fanout={budget.fanout}, cost≈${budget.cost_usd:.2f}. Spawn at most "
"`fanout` children and only while depth>0; pass each a decremented budget.")
return "\n\n".join(parts) + HARNESS_SYSTEM_TAIL
def _clip(s: str, cap: int = _OBS_CAP) -> str:
if len(s) <= cap:
return s
return s[:cap] + f"\n…[clipped {len(s) - cap} bytes]"
def _format_events(events: list[dict], me: str) -> str:
"""Turn raw bridge inbox events into a compact observation block the model
reads as room state. Drops our own echoes and high-volume noise."""
lines: list[str] = []
for ev in events:
kind = ev.get("kind")
if kind == "message":
who = ev.get("from", "?")
if who == me:
continue
mark = " (@you)" if ev.get("addressed") else ""
lines.append(f"[{who}{mark}] {ev.get('text', '')}")
elif kind == "acl":
state = "GRANTED drive" if ev.get("granted") else "drive NOT granted"
lines.append(f"[room] you are now {state}.")
elif kind == "sandbox":
lines.append(f"[room] sandbox {ev.get('state')} "
f"({ev.get('engine') or '—'}/{ev.get('name') or '—'}).")
elif kind == "roster":
lines.append(f"[room] members: {', '.join(ev.get('users', []))}.")
return "\n".join(lines)
@dataclass
class HarnessResult:
final: str = ""
turns: int = 0
tokens: int = 0
reason: str = "" # why the loop ended
tool_calls: int = 0
@dataclass
class OperatorHarness:
"""Drives a room with a function-calling Provider over the bridge control
socket. Side-effect-free to construct; :meth:`run` does the work. ``request_fn``
is injectable so unit tests can drive the whole loop with no daemon/network."""
provider: object
sock_path: str
objective: str
stop: list[str] | None = None
budget: "boot.Budget | None" = None
role_overlay: str | None = None
max_turns: int = DEFAULT_MAX_TURNS
token_ceiling: int = DEFAULT_TOKEN_CEILING
max_nudges: int = DEFAULT_MAX_NUDGES
me: str = "operator"
request_fn: Callable[[str, dict, float], dict] | None = None
poll_timeout: float = 20.0
_cursor: int = field(default=0, init=False)
# ── control-socket plumbing ──────────────────────────────────────────
def _call(self, obj: dict, read_timeout: float = 35.0) -> dict:
fn = self.request_fn or _socket_request
return fn(self.sock_path, obj, read_timeout)
def _drain_events(self, *, wait: bool, timeout: float) -> list[dict]:
resp = self._call({"op": "read", "since": self._cursor,
"wait": wait, "timeout": timeout},
read_timeout=timeout + 5.0)
events = resp.get("events", []) or []
if events:
self._cursor = events[-1].get("seq", self._cursor)
elif resp.get("seq") is not None:
self._cursor = max(self._cursor, int(resp["seq"]))
return events
# ── tool dispatch (each maps to a bridge verb) ───────────────────────
def _run_tool(self, name: str, args: dict) -> str:
if name == "say":
r = self._call({"op": "say", "text": str(args.get("text", ""))})
return "(said)" if r.get("ok") else f"error: {r.get('error')}"
if name == "exec":
r = self._call({"op": "exec", "cmd": str(args.get("command", ""))})
out = _clip(r.get("output", ""))
return f"exit={r.get('rc')}\n{out}" if not r.get("error") else f"error: {r['error']}"
if name == "write_file":
r = self._call({"op": "write", "path": str(args.get("path", "")),
"content": args.get("content", "")})
return (f"wrote {r.get('path')} ({r.get('bytes')} bytes)"
if r.get("ok") else f"error: {r.get('error')}")
if name == "get_file":
import base64
r = self._call({"op": "get", "path": str(args.get("path", ""))})
if not r.get("ok"):
return f"error: {r.get('error')}"
try:
raw = base64.b64decode(r.get("b64", "")).decode(errors="replace")
except Exception: # noqa: BLE001
raw = "[binary]"
return _clip(raw)
if name == "keys":
toks = [f"text:{args.get('text', '')}"]
if args.get("enter", True):
toks.append("enter")
r = self._call({"op": "keys", "keys": toks})
return "(keys sent)" if r.get("ok") else f"error: {r.get('error')}"
if name == "screen":
r = self._call({"op": "screen", "tail": args.get("tail"), "ansi": False})
return _clip(r.get("text", "")) if r.get("ok") else f"error: {r.get('error')}"
if name == "watch":
timeout = float(args.get("timeout", 30.0))
r = self._call({"op": "watch", "for": args.get("pattern"),
"in": args.get("source", "events"), "idle": None,
"timeout": timeout}, read_timeout=timeout + 5.0)
return _clip(json.dumps(r))
if name == "manifest":
req = {"op": "manifest", "action": args.get("action", "update")}
for k in ("status", "progress"):
if args.get(k) is not None:
req[k] = args[k]
for k in ("done", "todo"):
if args.get(k):
req[k] = [args[k]]
r = self._call(req)
return _clip(json.dumps(r))
return f"error: unknown tool '{name}'"
@staticmethod
def _calls_to_wire(calls: list[dict]) -> list[dict]:
"""Round-trip the model's tool calls back into the wire `tool_calls` shape
so the next turn sees its own request alongside the result."""
return [{"type": "function",
"function": {"name": c.get("name", ""),
"arguments": c.get("arguments", {})}}
for c in calls]
# ── the loop ──────────────────────────────────────────────────────────
def run(self) -> HarnessResult:
cwt = getattr(self.provider, "complete_with_tools", None)
if cwt is None:
return HarnessResult(
reason="provider-no-tools",
final="[refused — this provider cannot do function calling; the "
"harness operator needs a tool-capable model]")
system = compose_system(self.objective, stop=self.stop, budget=self.budget,
role_overlay=self.role_overlay)
# Seed: snapshot current room state (full ring) as the first observation.
try:
seed = self._drain_events(wait=False, timeout=0.0)
except Exception as e: # noqa: BLE001
return HarnessResult(reason="bridge-unreachable",
final=f"[bridge unreachable: {e}]")
obs = _format_events(seed, self.me) or "(room is quiet)"
messages: list[dict] = [
{"role": "user",
"content": f"You have joined the room. Current state:\n{obs}\n\n"
f"Begin working toward the objective."}
]
res = HarnessResult()
nudges = 0
while res.turns < self.max_turns + self.max_nudges:
res.turns += 1
try:
text, calls, usage = cwt(system, messages, OPERATOR_TOOLS)
except ToolsUnsupported as e:
return HarnessResult(
turns=res.turns, tokens=res.tokens, reason="tools-unsupported",
final=f"[refused — model rejected tool calling: {e}]")
except Exception as e: # noqa: BLE001
res.reason, res.final = "provider-error", f"[ai error: {e}]"
return res
res.tokens += int((usage or {}).get("prompt_eval_count", 0) or 0)
res.tokens += int((usage or {}).get("eval_count", 0) or 0)
if not calls:
if _DONE_RE.match(text or ""):
res.final = re.sub(_DONE_RE, "", text, count=1).strip()
res.reason = "done"
return res
if nudges >= self.max_nudges:
res.final = (text or "").strip() or "[stopped — model stalled]"
res.reason = "stalled"
return res
nudges += 1
messages.append({"role": "assistant", "content": text or ""})
# Poll for any room activity to give the nudge fresh context.
fresh = []
try:
fresh = self._drain_events(wait=True, timeout=self.poll_timeout)
except Exception: # noqa: BLE001
pass
nudge_obs = _format_events(fresh, self.me)
messages.append({
"role": "user",
"content": (("Room update:\n" + nudge_obs + "\n\n") if nudge_obs else "")
+ "If the objective is met, reply with a single 'DONE: <summary>' "
"line. Otherwise call the next tool to make progress."})
continue
if res.tokens >= self.token_ceiling:
res.final = ("[stopped — per-agent token ceiling "
f"{self.token_ceiling} reached]")
res.reason = "token-ceiling"
return res
# Echo the assistant's tool-call turn, then run each call and feed the
# results back as tool messages so the next turn sees the outcome.
messages.append({"role": "assistant", "content": text or "",
"tool_calls": self._calls_to_wire(calls)})
for c in calls:
name, cargs = c.get("name", ""), c.get("arguments") or {}
result = self._run_tool(name, cargs)
res.tool_calls += 1
messages.append({"role": "tool", "content": _clip(result)})
# After acting, fold any new room events into the next observation.
try:
fresh = self._drain_events(wait=False, timeout=0.0)
except Exception: # noqa: BLE001
fresh = []
obs = _format_events(fresh, self.me)
if obs:
messages.append({"role": "user", "content": "Room update:\n" + obs})
res.final = res.final or "[stopped — reached turn cap]"
res.reason = res.reason or "turn-cap"
return res