Files
hack-house/docs/findings-native-harness-2026-06-10.md
T
leetcrypt df45303f5c test(ai): ground-truth benchmark for the native harness + first baseline
Add bench/ — a 4-category × easy/medium/hard task matrix (shell, code, git,
multi) and a runner that drives the live TUI over tmux and grades each task by
a `podman exec` verify snippet (exit 0 == PASS), never by the model's
self-reported summary (which the weak CPU model fabricates). Tasks run in the
agent's real cwd with bare filenames so the suite measures task completion, not
the model's absolute-path discipline. Completion is detected off the viewport-
independent `is thinking…` footer (the TUI is full-screen, so capture-pane
scrollback is not chat history).

First baseline (qwen2.5:3b): 2/12 PASS, high variance. Surfaces the next
harness-addressable improvements — `<native>` tag leakage and bare
tool-call-as-text — now measurable against this suite. Findings doc updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-10 01:30:09 -07:00

197 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Native harness — live command-entry findings + nudge-loop design
**Date:** 2026-06-10
**Context:** Evaluating the `native` tool-calling harness (`cmd_chat/agent/bridge.py`,
`_run_native`) on its ability to drive shell commands into the shared podman/Kali
sandbox, on a CPU-only box (no GPU). Two prior fixes were live-validated here and
committed in `a0aa14d`: §3 PTY-sentinel (`_run_shell_in_pty`) and `NATIVE_CONTEXT=4`.
## Test setup
- Fresh room, podman sandbox (`hack-house` container), `/grant ai` drive.
- Models compared: `qwen2.5:3b` (1.9 GB, prior default) and `qwen2.5-coder:7b`
(4.7 GB, pulled for this test). `deepseek-r1:latest` and `westenfelder/NL2SH`
were ruled out — both **reject Ollama's `tools` field** (`HTTP 400 "does not
support tools"`), so they degrade to the simple injector and can't exercise the
native loop.
- Each task verified against **ground truth** (podman exec into the container),
not just the chat/PTY surface.
## Results: qwen2.5:3b vs qwen2.5-coder:7b
| Behaviour | qwen2.5:3b | qwen2.5-coder:7b |
|---|---|---|
| Single command (`whoami`) | PASS | PASS |
| write+run (`hello.sh` date+hostname) | PASS, accurate summary | PASS |
| **Multi-step** (mkdir→write→list) | **FAIL** — stalled after step 1 ("ok, let's proceed to the next step") | PASS — chained make_dir→write→read, completed |
| **Recover from mid-task error** | FAIL — gives up | INCONSISTENT — recovered from `[unknown tool make_dir]`, but on `greet.sh` skipped chmod, hit exit 126, then **stopped** |
| Tool-schema discipline | clean | hallucinated a `make_dir` tool (not in schema) — wasted a turn (`[unknown tool make_dir]`, bridge.py:597) |
| Final summary quality | vague ("I will proceed…") | empty → `(done)` fallback, or raw advice text |
| Latency (CPU, no GPU) | ~2040 s/task | ~24× slower, 3090 s/turn |
### Failure modes observed (both sizes)
1. **Early termination on multi-step** (3B dominant): the model emits filler text
with no tool call mid-task; the loop reads "text + no tool call = done" and stops.
The remaining steps never run.
2. **Give-up on unresolved non-zero exit** (both sizes): after a failing command
(exit 126/127), the model emits a summary/advice instead of fixing the cause.
greet.sh left at mode 644 (never chmod'd) despite the task saying "make it
executable".
3. **Tool hallucination** (7B): invented `make_dir`; the schema only has
run_shell/write_file/read_file.
### Root cause in code
`_run_native`, bridge.py:739741:
```python
if not calls:
final = (text or "").strip()
break # ANY text-without-toolcall ends the task
```
NATIVE_SYSTEM (line 111113) *also* tells the model to signal completion this exact
way. So one signal — "text, no tool call" — is overloaded to mean BOTH "done" and
"stalling/thinking". Disambiguating it is the fix.
**Takeaway:** a bigger model buys multi-step persistence (the 3B's main flaw) but
does NOT eliminate the give-up-on-error mode, and costs heavy latency. A nudge loop
that keys off **exit codes** (not just filler text) earns its keep at both sizes.
## Research: how Goose & opencode structure loop termination
(Source read directly from clones; key files cited.)
- **Goose** (`crates/goose/src/agents/agent.rs`): tracks `no_tools_called`
(line 1801). When true it does NOT just stop (lines 22322316) — in
structured/goal modes it injects a continuation nudge
(`FINAL_OUTPUT_CONTINUATION_MESSAGE` = "You MUST call the final_output tool
NOW…") and loops. Completion is an **explicit terminal tool**
(`recipe__final_output`, `final_output_tool.rs`). Vanilla chat with no
recipe/goal does fall through to text-as-done, but every structured path
overrides that. Bounded by `max_turns`.
- **opencode** (`packages/opencode/src/session/prompt.ts:11561183`): exits only
when finish is a real stop reason **AND** it independently re-derives that there
are no pending tool calls from the parsed message parts — comment: *"Some
providers return 'stop' even when the assistant message contains tool calls."*
Hard step cap (`maxSteps`) with a wind-down message (`MAX_STEPS`,
`prompt/max-steps.txt`); structured mode forces `toolChoice: "required"`.
- **Canonical / Cline-Roo**: weak-model harnesses favour an explicit completion
action (`attempt_completion`/`submit`) over trusting "empty tool_calls = done".
- **Recommendation from research:** go structural, not filler-heuristic ("a losing
arms race"); nudge on text-without-completion; a hard cap is the only
unconditional exit; derive "did a tool get called" from parsed parts, not
`finish_reason` (qwen on CPU is unreliable there — it leaks tool calls as
`<tool_call>` text in `content`; already handled by
`OllamaProvider._extract_text_tool_calls`).
## Design: dynamic nudge loop (output-aware, structural terminator)
Structural termination via a **`DONE:` text sentinel** rather than a 4th tool.
Divergence from the research's "add a done tool", justified for THIS model:
qwen-on-CPU already mis-emits structured tool calls (leaks as `<tool_call>` text;
the 7B even hallucinated `make_dir`). A 4th tool invites the same malformation and
competes for attention; a `DONE:` prefix disambiguates the existing text channel at
zero schema cost. This is opencode's "require an explicit marker, don't trust the
implicit stop", applied to the text channel.
Changes in `_run_native` (bridge.py):
1. **NATIVE_SYSTEM** (line 111113): replace "reply with a summary and DO NOT call
a tool" with → *"When and ONLY when the task is fully done, reply with one line
starting `DONE:` and a one-sentence result. Otherwise you MUST call a tool."*
2. **Track loop state:** `did_action` (any tool ran), `last_rc` (parse the `exit=`
already formatted by `_run_shell_in_pty`), `nudges=0`.
3. **Replace the bare `break` (739741) with a verdict gate:**
- text starts with `DONE:`**done** (fast path, no nudge)
- `last_rc` ∉ {0, None} and no `DONE:`**stalled** (unresolved error)
- no `did_action`**stalled** (pure talk)
- filler regex (`let's proceed`, `next step`, `i will`, trailing colon) and no
`DONE:`**stalled**
- else (substantive text, action happened, no error) → **accept as done** — the
single heuristic, on the SAFE side, so finished tasks aren't nudged every time
(protects CPU latency, the cost a pure-structural form would incur here).
4. **On `stalled`:** append an output-aware, Goose-style nudge and `continue`,
bounded by `MAX_NUDGES = 2` (separate from the productive `max_turns` so real
multi-step work isn't starved):
> *[if last_rc≠0:]* "The last command exited {rc} (failure) — fix that first
> (e.g. `chmod +x` before running a script)." + "You haven't signalled
> completion. If `{task}` is fully done reply `DONE: …`; otherwise call the next
> tool now — act, don't describe."
5. **Wind-down:** on the final turn, inject "reply `DONE:` with your result now"
(opencode's `MAX_STEPS` pattern).
6. **Keep** `_extract_text_tool_calls` (already present) — opencode's "derive tool
calls from parsed parts, not finish_reason" lesson, which this model needs.
Fixes mapped to observed failures: 3B early-stall → nudged to continue; give-up on
error (both sizes) → nudged with the exit-code-aware message; over-nudging finished
tasks → avoided by the `DONE:` fast path + safe-side accept.
## Live validation (qwen2.5:3b, post-implementation)
Ran the two prior failure cases against the freshly-built loop; ground truth via
`podman exec hack-house`.
- **Multi-step (`mkdir proj3 → write notes.txt → list`): PASS — fixed.** Previously
stalled after step 1; now chained write_file → `ls -1 proj3` to completion.
Ground truth: `proj3/notes.txt` exists, contents `hello world`. The 3B's dominant
failure mode is resolved by the loop alone (no model upgrade needed).
- **Nudge fires on non-zero exit: confirmed.** On the `greet.sh` case the PTY mirror
showed `▸ (nudge: finish the task or reply DONE:)` after a 126, i.e. the
output-aware re-prompt triggered structurally as designed.
- **Give-up-on-error is model-bound, not loop-bound.** The 3B still can't *recover*
the greet.sh task even when nudged: across runs it wrote self-referential content
(`echo "Hello, world!" > greet.sh`), its `chmod +x` didn't stick (file left 644),
and in one run it leaked a bare `write_file proj3/greet.sh …` tool call **as
summary text** (not the `<tool_call>` JSON form `_extract_text_tool_calls`
handles). These are 3B capability limits, consistent with the 3B-vs-7B table —
the loop's job is to detect and report them honestly, not to make a weak model
competent.
### Honesty hardening added after live test
The weak 3B routinely *claims* success it didn't achieve ("written, made executable,
and run successfully" after a 126; "Created greet.sh… ran it" with nothing on disk).
Echoing that prose as the final summary is actively misleading, so the nudge-budget
exhaustion path no longer trusts it blindly:
- never ran a tool → `[stopped — model described the task but never ran a tool]`
- left a non-zero exit → `[stopped — last command exited {rc}; task likely
incomplete] …`
- clean, action-backed turn → the model's summary (unchanged).
Offline unit coverage: 23 assertions on `_completion_verdict` / `_parse_exit` /
`_strip_done` / `_nudge_message`, all pass.
## Benchmark suite + first baseline (`bench/`)
To compare harness/model changes **systematically** instead of eyeballing one-off
runs, added a ground-truth-graded benchmark: `bench/tasks.py` (a 4-category ×
easy/medium/hard matrix — shell, code, git, multi) and `bench/run.py` (drives the
live TUI via tmux, grades each task by a `podman exec` verify snippet, exit 0 ==
PASS). Tasks run in the agent's real cwd (`/root`) with bare filenames; pinning an
absolute path instead just measured the weak model's absolute-path discipline and
drowned out every other signal.
**Runner gotcha (cost real time):** the TUI is a full-screen (alt-screen) app, so
`tmux capture-pane -S` does NOT yield chat history — only the current viewport.
The first detector diffed a `@user` reply count and hung once old replies scrolled
out of view. Fixed by keying completion off the **`is thinking…` footer** (engage →
sustained-absence), which is viewport-independent.
**Baseline — qwen2.5:3b, 2/12 PASS** (`bench/results/baseline-qwen2.5_3b.json`).
Pass: shell-medium (nested mkdir+write), multi-easy (mkdir→write→list). The run is
**high-variance** — across two runs git-easy and shell-medium each flipped PASS↔FAIL
— so treat a single run as a smoke signal, not a precise score; average several (or
move to the 7B) for a stable number. Dominant failure modes the summaries exposed,
and where they point next:
| failure mode | example | harness-addressable? |
|---|---|---|
| `<native>` tag leak in summary | `<native> Cloned repository…` | yes — strip the tag |
| bare tool-call-as-text | `write_file ./wc.py`, `writeFile "…"` | yes — extend `_extract_text_tool_calls` beyond `<tool_call>` JSON |
| path hallucination | `/home/qwen253b/conf_count.txt`, `~/` unexpanded | partly (model) |
| content fabrication | wrote literal `dell` instead of running whoami | no (model capability) |
The first two are the clear next harness improvements; the benchmark now exists to
prove whether they move the number.