feat(ai): visible native harness — PTY mirror + readable chat

Display-mirror hybrid (docs/plan-harness-visibility.md §2): native tool
calls now show up in the shared sandbox terminal again via inert `# `-
prefixed comment lines (comment-prefix = anti-double-run/anti-escape),
mirroring only each command. Chat de-flooded to opener + final summary.
write_file mkdir -p parent dir so relative/absolute paths both work
(fixes the regression where script creation silently failed). ui.rs
fmt_line returns Vec<Line> splitting on \n so multi-line agent output
renders as an indented block instead of one garbled row.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-06-09 15:44:40 -07:00
parent 946df65b72
commit 0b1d09f0b5
4 changed files with 522 additions and 28 deletions
+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""Live bench/smoke for the native `!task` harness (docs/spec-native-harness.md, Phase 3).
Drives the real `AgentBridge._run_native` against a live Ollama model and the
`local` sandbox backend (host shell, scoped to a fresh temp workdir), with no chat
server or TUI in the loop. For each canonical task it reports: wall-clock latency,
model turns, tool calls, whether the expected artifact landed, and the final
answer. Also records a single chat-completion latency as the `simple`-harness
model-cost baseline (simple's execution needs the broker PTY, so only its model
call is comparable headlessly).
Usage: .venv/bin/python hh/scripts/bench-native-harness.py [--model qwen2.5:3b]
[--max-turns 5] [--threads 4]
Env: OLLAMA_HOST (default http://localhost:11434)
"""
from __future__ import annotations
import argparse
import asyncio
import os
import sys
import tempfile
import time
from pathlib import Path
# Make the repo importable when run from anywhere.
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from cmd_chat.agent.bridge import AgentBridge # noqa: E402
from cmd_chat.agent.providers import ( # noqa: E402
Msg,
OllamaProvider,
ToolsUnsupported,
)
PER_TASK_TIMEOUT = 300.0 # hard ceiling so a runaway loop can't hang the bench
# (label, task text, check(workdir) -> bool)
TASKS = [
(
"write+read",
"create a file hello.txt containing exactly the text 'hello world', "
"then show its contents",
lambda d: (d / "hello.txt").is_file()
and "hello world" in (d / "hello.txt").read_text(),
),
(
"script+run",
"write a python script add.py that prints the sum of 2 and 3, then run it",
lambda d: (d / "add.py").is_file(),
),
(
"mkdir+list",
"make a directory named data and then list the files in the current directory",
lambda d: (d / "data").is_dir(),
),
]
class CountingOllama(OllamaProvider):
"""OllamaProvider that tallies tool-calling turns for the report."""
def __init__(self, *a, **k):
super().__init__(*a, **k)
self.tool_turns = 0
def complete_with_tools(self, system, messages, tools):
self.tool_turns += 1
return super().complete_with_tools(system, messages, tools)
def make_bridge(provider, workdir: str):
"""A headless AgentBridge wired to the local backend, with all network sends
stubbed to capture room output instead of encrypting to a websocket."""
b = AgentBridge(
"localhost", 0, name="bench", provider=provider,
no_tls=True, code_provider=provider, harness="native",
max_turns=provider_max_turns,
)
b.granted = True
b.sbx_engine = "local" # exec on the host shell (this process's CWD = workdir)
b.sbx_name = ""
b._chat: list[str] = []
async def cap_chat(ws, text):
b._chat.append(text)
async def noop(*a, **k):
pass
b._send_chat = cap_chat
b._send_typing = noop
b._send_stream = noop
async def cap_inject(ws, cmds): # simple-harness path (unused here, kept honest)
b._chat.append("[inject] " + " ; ".join(cmds))
b._inject = cap_inject
return b
provider_max_turns = 5 # set in main()
async def run_task(provider, label, task, check) -> dict:
workdir = Path(tempfile.mkdtemp(prefix=f"hh-bench-{label}-"))
cwd = os.getcwd()
os.chdir(workdir)
provider.tool_turns = 0
bridge = make_bridge(provider, str(workdir))
t0 = time.monotonic()
timed_out = False
err = None
try:
await asyncio.wait_for(
bridge._run_native(None, task, "andre"), timeout=PER_TASK_TIMEOUT)
except asyncio.TimeoutError:
timed_out = True
except Exception as e: # noqa: BLE001 — record, don't abort the suite
err = f"{type(e).__name__}: {e}"
finally:
os.chdir(cwd)
elapsed = time.monotonic() - t0
ok = False
try:
ok = bool(check(workdir)) and not timed_out and err is None
except Exception: # noqa: BLE001
ok = False
final = next((c for c in reversed(bridge._chat) if "(native) for" in c), "")
return {
"label": label, "ok": ok, "elapsed": elapsed, "turns": provider.tool_turns,
"timed_out": timed_out, "err": err, "workdir": str(workdir),
"chat": bridge._chat, "final": final,
}
async def main() -> int:
global provider_max_turns
ap = argparse.ArgumentParser()
ap.add_argument("--model", default="qwen2.5:3b")
ap.add_argument("--max-turns", type=int, default=5)
ap.add_argument("--threads", type=int, default=4)
ap.add_argument("--num-ctx", type=int, default=4096)
ap.add_argument("--verbose", action="store_true", help="print full chat per task")
args = ap.parse_args()
provider_max_turns = args.max_turns
host = os.environ.get("OLLAMA_HOST", "http://localhost:11434")
print(f"== native harness bench == model={args.model} host={host}")
print(f" max_turns={args.max_turns} threads={args.threads} num_ctx={args.num_ctx}\n")
provider = CountingOllama(
model=args.model, num_ctx=args.num_ctx, num_thread=args.threads, num_predict=512)
# 0. Wire preflight: does the model accept the `tools` field at all?
print("[preflight] probing tool support…", flush=True)
t0 = time.monotonic()
try:
text, calls = await asyncio.to_thread(
provider.complete_with_tools,
"You are a test.",
[{"role": "user", "content": "Call run_shell to echo hi."}],
__import__("cmd_chat.agent.bridge", fromlist=["NATIVE_TOOLS"]).NATIVE_TOOLS,
)
except ToolsUnsupported as e:
print(f" ✖ model rejects tools: {e}\n → native would degrade to simple. Stopping.")
return 1
except Exception as e: # noqa: BLE001
print(f" ✖ preflight error: {type(e).__name__}: {e}")
return 2
print(f" ✓ tools accepted in {time.monotonic()-t0:.1f}s "
f"(calls={len(calls)}, supports_tools={provider.supports_tools()})\n")
# Baseline: one plain chat completion (the only model cost simple pays).
t0 = time.monotonic()
try:
await asyncio.to_thread(provider.complete, "You are concise.",
[Msg("user", "say ok")])
base = time.monotonic() - t0
print(f"[baseline] one chat completion (≈ simple's model cost): {base:.1f}s\n")
except Exception as e: # noqa: BLE001
print(f"[baseline] chat completion failed: {e}\n")
results = []
for label, task, check in TASKS:
print(f"[task:{label}] {task}", flush=True)
r = await run_task(provider, label, task, check)
tag = "PASS" if r["ok"] else ("TIMEOUT" if r["timed_out"] else "FAIL")
print(f"{tag} {r['elapsed']:.1f}s turns={r['turns']}"
+ (f" err={r['err']}" if r["err"] else ""))
if r["final"]:
print(f" final: {r['final'].splitlines()[-1][:160]}")
if args.verbose:
for c in r["chat"]:
print(" | " + c.replace("\n", " ")[:160])
print(flush=True)
results.append(r)
# Summary table.
print("== summary ==")
print(f"{'task':<14}{'result':<9}{'secs':>7}{'turns':>7}")
for r in results:
tag = "PASS" if r["ok"] else ("TIMEOUT" if r["timed_out"] else "FAIL")
print(f"{r['label']:<14}{tag:<9}{r['elapsed']:>7.1f}{r['turns']:>7}")
passes = sum(1 for r in results if r["ok"])
print(f"\n{passes}/{len(results)} passed")
return 0 if passes == len(results) else 3
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
+62 -18
View File
@@ -670,18 +670,42 @@ fn draw_top(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme
f.render_widget(Paragraph::new(bar), area);
}
fn fmt_line<'a>(l: &'a ChatLine, app: &App, theme: &Theme) -> Line<'a> {
if l.system {
// System lines carry the canonical ⛧ as a placeholder for "the house
// sigil"; swap it for the active theme's sigil so e.g. crypt shows ✝,
// never a pentagram. User messages (l.system == false) are left as typed.
let text = l.text.replace('⛧', &theme.sigil);
return Line::from(Span::styled(
format!(" {} {}", theme.sigil, text),
Style::default()
.fg(theme.system)
.add_modifier(Modifier::ITALIC),
));
/// Render one chat record into one-or-more visual lines. A record may carry
/// embedded newlines (e.g. an AI agent's multi-line answer or injected plan);
/// ratatui treats a `Line` as a single visual row, so we split on `\n` ourselves
/// and indent the continuations to align under the first line's text.
fn fmt_line<'a>(l: &'a ChatLine, app: &App, theme: &Theme) -> Vec<Line<'a>> {
// "Action" lines — client system notices and AI agent output (which marks
// itself with a leading †) — read better as dim, italic, sigil-marked blocks
// than as ordinary chatter, so the eye can skip them or zero in on them.
let is_action = l.system || l.text.starts_with('†');
if is_action {
// Swap the canonical † placeholder for the active theme's sigil so each
// vestment renders its own glyph (e.g. crypt shows ✝).
let body = l
.text
.strip_prefix('†')
.unwrap_or(&l.text)
.trim_start()
.replace('†', &theme.sigil);
let style = Style::default()
.fg(theme.system)
.add_modifier(Modifier::ITALIC);
// Attribute an agent's action to it (system notices stay anonymous).
let head = if l.system || l.username.is_empty() {
format!(" {} ", theme.sigil)
} else {
format!(" {} {}: ", theme.sigil, l.username)
};
let indent = " ".repeat(head.chars().count());
return body
.split('\n')
.enumerate()
.map(|(i, seg)| {
let prefix = if i == 0 { head.clone() } else { indent.clone() };
Line::from(Span::styled(format!("{prefix}{seg}"), style))
})
.collect();
}
let name_color = if l.username == app.me {
theme.me
@@ -691,7 +715,7 @@ fn fmt_line<'a>(l: &'a ChatLine, app: &App, theme: &Theme) -> Line<'a> {
// Author's current badge inline, so a message's authority is legible right
// in the transcript — not only in the clergy panel.
let badges = role_badges(app, &l.username, theme);
Line::from(vec![
let head: Vec<Span> = vec![
Span::styled(format!("{} ", l.ts), Style::default().fg(theme.dim)),
Span::styled(format!("{badges} "), Style::default().fg(theme.dim)),
Span::styled(
@@ -699,14 +723,34 @@ fn fmt_line<'a>(l: &'a ChatLine, app: &App, theme: &Theme) -> Line<'a> {
Style::default().fg(name_color).add_modifier(Modifier::BOLD),
),
Span::styled(": ", Style::default().fg(theme.dim)),
Span::styled(l.text.as_str(), Style::default().fg(theme.title)),
])
];
let mut segs = l.text.split('\n');
let first = segs.next().unwrap_or("");
let mut spans = head;
spans.push(Span::styled(first.to_string(), Style::default().fg(theme.title)));
let mut out = vec![Line::from(spans)];
// Continuation rows indent under the message body so a multi-line message
// reads as one coherent block under its author.
let indent = " ".repeat(
l.ts.chars().count() + 1 + badges.chars().count() + 1 + l.username.chars().count() + 2,
);
for seg in segs {
out.push(Line::from(Span::styled(
format!("{indent}{seg}"),
Style::default().fg(theme.title),
)));
}
out
}
fn draw_chat(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) {
let inner_h = area.height.saturating_sub(2) as usize; // rows inside the border
let text_w = area.width.saturating_sub(2).max(1); // wrap width inside the border
let mut lines: Vec<Line> = app.lines.iter().map(|l| fmt_line(l, app, theme)).collect();
let mut lines: Vec<Line> = app
.lines
.iter()
.flat_map(|l| fmt_line(l, app, theme))
.collect();
// Live preview bubbles for agents currently streaming a reply, rendered
// below the committed history. Dim + italic so they read as in-progress;
@@ -758,7 +802,7 @@ fn draw_chat(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Them
f.render_widget(chat, area);
}
/// Glyph for a single role. The host sigil is theme-dependent (✝ for crypt,
/// Glyph for a single role. The host sigil is theme-dependent (✝ for crypt,
/// for the default), the rest are fixed.
fn role_glyph(role: Role, theme: &Theme) -> &str {
match role {
@@ -769,7 +813,7 @@ fn role_glyph(role: Role, theme: &Theme) -> &str {
}
}
/// The stacked badge string for `name` — e.g. `⚡◆` for a host who summoned a
/// The stacked badge string for `name` — e.g. `⚡◆` for a host who summoned a
/// sandbox and can drive, or a lone `•` for a plain member. Single source of
/// truth shared by the roster and the chat author prefix so both always agree
/// with each other and with what the broker enforces.