Generalise OllamaProvider._extract_text_tool_calls to recover a tool-call
JSON object regardless of how a small/quantized model wraps it — qwen's
<tool_call> tags, bare JSON, ```json fences, alternate tags (<tools>,
<function_call>), OpenAI {"function":{…}} nesting, and parameters-vs-arguments.
A new _coerce_call gates recovery on the known tool-name set from the tools
schema, so a stray JSON blob in prose (or a hallucinated make_dir) can never
be coerced into an action. 11-case unit check: 8 leak shapes recover, 3
negatives (prose / unknown tool / random config JSON) ignored.
Benchmark verdict (honest): this does NOT move the weak-CPU-model pass rate
— 3b went 2/1/0 of 12 across three passes (baseline 1/12, noise), 0.5b went
0/0 (baseline 1/12). A direct /api/chat probe shows the hypothesis was wrong
about the FORM of the leak: the weak models emit either malformed structured
tool_calls (write_file content:null) or a fenced bash block in prose with no
tool call at all — not JSON-as-text. The structured-JSON recovery is still a
correct, safe hardening for any model that does leak JSON; the real
weak-model lever (parse ```bash fences -> run_shell) is documented as an
explicit safety decision, not folded in here.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the overloaded "text + no tool call = done" terminator that made the
weak CPU model stall mid-task or give up after a failing command. Termination
is now a structural `DONE:` text sentinel; a text-only turn is resolved by an
output-aware verdict (DONE: marker / unresolved non-zero exit / no action /
filler language) and re-prompted with an exit-code-aware nudge, bounded by
MAX_NUDGES on top of max_turns. On exhaustion the summary is honest rather than
echoing the model's false "run successfully" — it reports when no tool ran or a
command exited non-zero. Live-validated on qwen2.5:3b: the multi-step stall is
fixed (proj3 completes end-to-end, ground-truth confirmed); the nudge fires on
a 126; residual give-up is model-bound. 23 offline unit assertions pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two native-harness improvements, both live-validated against qwen2.5:3b
and qwen2.5-coder:7b on a podman/Kali sandbox:
- §3 PTY-sentinel: run_shell now runs in the REAL shared terminal via
_run_shell_in_pty (stage cmd out-of-band to a hex-token temp file, type
a `{ sh CMDF; echo $? >RCF; } 2>&1 | tee OUTF` wrapper into the live PTY,
poll the rc sentinel out-of-band, then read OUTF). The whole room now
watches commands execute live instead of an inert `# ▸` comment, while
output + exit code are still captured for the loop. tee+poll (not
stream-sentinel) avoids deadlocking the serve loop; the wrapper line
carries only our own temp paths so room text never reaches the shell
parser. _exec_tool takes ws to reach the PTY.
- NATIVE_CONTEXT=4: action tasks now get a tight, RAG-free window (last few
transcript turns only, no semantic recall). A weak model fed prior chat
chatter latched onto nearby noise (wrote a "grant permissions" script for
"write a bash script"); feeding just the instruction fixes it.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Optimize the native tool-calling loop for qwen2.5:3b on CPU, where it
previously invented paths (/ai/bin/bash), ran scripts it never wrote, and
silently dropped valid actions. Three changes:
- NATIVE_SYSTEM rewritten directive: explicit write→chmod→run workflow,
relative paths only, never run an uncreated file, never guess interpreter
paths, fix the cause on non-zero exit.
- New _sandbox_facts() probe injects LIVE SANDBOX STATE (real cwd, bash
path, current files) into the system prompt so the model anchors to
ground truth instead of guessing.
- OllamaProvider recovers tool calls qwen emits as <tool_call>{json}</…>
TEXT in content (brace-balanced JSON scan), so a correct action isn't lost.
- Bump Ollama timeout 120→240s: the tool turn is non-streaming and a long
write_file can exceed a tighter cap on a contended CPU box.
Live-validated (podman/Kali): 0/3 incoherent → reliable write/run with
self-correction on exit=126 for both single- and multi-script tasks.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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>
Implement the bounded native harness from docs/spec-native-harness.md §1.3 and
make it the default granted-!task path. The model runs host-side (no container→
host Ollama hop); only its tool calls exec in the sandbox.
providers.py:
- OllamaProvider.complete_with_tools(system, messages, tools) -> (text, calls):
one non-streaming /api/chat turn with a `tools` schema; parses message.tool_calls
(dict or JSON-string arguments). Caches tool capability (_tools_ok / supports_tools).
- ToolsUnsupported raised when the model rejects `tools` ("does not support tools").
bridge.py:
- NATIVE_SYSTEM + a 3-tool schema (run_shell / write_file / read_file), turn/byte caps.
- _run_native: seed transcript window + task → loop up to max_turns; exec each tool
call in the sandbox, feed captured output back as a `tool` message; stop on a plain
answer or the cap; stream per-call progress to chat. Degrades to _run_simple when the
provider has no complete_with_tools or the model rejects tools.
- _exec_prefix/_exec_capture/_exec_tool: <engine> exec into docker/podman/multipass/local;
paths passed as positional args + content via stdin (no shell interpolation); combined
stdout+stderr byte-capped + time-bounded. run_shell is the only intentional shell.
- Guards: DESTRUCTIVE run_shell commands are blocked (not run — no human in the loop;
use simple + /ai confirm for destructive intent); MAX_COMMANDS budget per task.
- _run_in_sandbox dispatches native|simple; default harness flipped to native.
__main__.py: default harness native (self-degrades to simple, so safe).
Offline-tested: full write/run/read loop on the local backend; destructive block
(rm -rf never executed); ToolsUnsupported → simple fallback. Live Ollama wire
validation deferred to Phase 3 bench (daemon was down). py_compile clean.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove the Goose agentic harness across the codebase per
docs/spec-native-harness.md §3. Goose made N sequential model calls inside the
sandbox (slow on CPU-only hardware) and forced an in-container→host Ollama
gateway that tripped the rootless-Podman slirp4netns loopback bug.
- bridge.py: delete _run_goose/_goose_argv/_goose_present + GOOSE_* consts and
the present-cache; __init__ now takes harness="simple"/max_turns=5; granted
!task runs _run_simple until the native loop lands (Phase 2).
- __main__.py: --harness {native,simple} (was {goose,simple}); drop
--goose-max-turns, add --max-turns; default harness simple.
- app.rs: /ai start accepts native|simple (plain aliases simple) instead of a
bare plain flag; refresh harness comments.
- sbx.rs: remove the in-container Ollama gateway (Docker host-gateway / Podman
slirp4netns host-loopback) and the dk_bootstrap OLLAMA_HOST env — kills the
slirp4netns loopback bug; drop Goose comments.
- bootstrap.sh: drop goose from the prereq probe.
- bootstrap-ai.sh: remove the entire Goose install block, --no-goose flag,
GOOSE_INSTALLER_URL, host config writer, and goose_bin helper.
- sandbox-bootstrap.sh: remove the in-sandbox Goose binary install + config.
- spec-goose-harness.md: banner — harness portion superseded; Podman stays.
cargo check + py_compile clean. No Goose refs remain (headroom/ untouched).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The /ai agent held a single, un-shielded websocket with no retry. Any
close — server restart, ping/idle reap, laptop sleep, a transient blip —
ended the serve loop, so run_async returned and the process exited
silently: the agent dropped from the roster with no /ai stop and no
goodbye.
- run_async now wraps the connection in a backoff-reconnect loop (1s→30s,
resets after a healthy ≥30s session). The server frees our session+name
on drop, so each attempt re-runs SRP to mint a fresh token. Only Ctrl-C
/ process kill (KeyboardInterrupt / CancelledError, how /ai stop ends
us) breaks the loop.
- _serve shields each frame via _handle_frame so one malformed/poisoned
frame — or a handler error — can't unwind the loop; ConnectionClosed
and cancellation propagate up to the reconnect loop.
- Forgiving keepalive (ping_interval=20, ping_timeout=60) so a heavy
CPU-only Ollama generation doesn't trip a false drop.
Also adds docs/spec-native-harness.md: replace the heavyweight Goose
harness with a lightweight host-side Ollama-native tool-calling loop
(model runs host-side, only commands exec in the sandbox — the
slirp4netns→host-Ollama bug disappears), and a file-by-file plan to strip
all Goose integration. Supersedes the harness portion of
spec-goose-harness.md (Podman backend stays).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add Podman as a rootless/daemonless sandbox backend alongside Docker,
Multipass and Local, and wire Goose in as the default agentic harness
for the granted `!task` path (bridge execs `<engine> exec <name> goose
run` and streams output to chat; auto-degrades to the simple one-shot
injector when goose is absent).
Add an optional GUI sandbox track (XFCE + TigerVNC + websockify/noVNC on
:6080) summoned via `/sbx <engine> gui`, plus container-side provisioning
in sandbox-bootstrap.sh and a host-side ensure-podman.sh prereq helper.
Refresh the in-app command help to the backend-led `/sbx <engine> [gui]`
grammar and minor ui tweaks.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Agents now join under their model tag (e.g. "qwen2.5:3b" — model name +
parameter size) or profile label, derived at /ai start and tracked on
App.agent_name, so the roster shows what's actually answering. Updates the
broker re-grant + /ai stop revoke paths, the Python --name default
(falls back to provider.model), and the demo/bootstrap/README/docs refs.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- /sbx save [--local] and new /sbx vmsave/vmsnaps: snapshot Docker,
Multipass, and VirtualBox; --local also writes a portable artifact
(docker .tar / VBox .ova) under hh-snapshots/ that survives pruning.
- sandbox reappears for anyone who leaves and rejoins (host replays
status + screen snapshot + ACL on Joined); SbxStatus ready handler is
now idempotent so it never wipes scrollback.
- received files auto-bridge into the hosted sandbox (ft::tar_path).
- AI agent: translate Ollama's cryptic 404 into model/host/fix guidance.
- bootstrap installs the AI layer (Ollama + default model) by default,
with consent gates; --no-ai opts out, --yes skips prompts.
- help menu lists the new save/vm flags.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- /sbx save|load|snaps: docker commit → hh-snap:<label> image that
survives /sbx stop; load relaunches a fresh sandbox from it; multipass
delegates to `multipass snapshot`. Local backend unsupported.
- Help overlay redesigned into topical clusters (SANDBOX, AI AGENTS,
PERMISSIONS, FILES, APPEARANCE, KEYS, ROSTER GLYPHS), collapsed by
default; up/down highlight a cluster, left/right/Enter expand-collapse
it (tmux-style), PgUp/PgDn scroll overflow, Esc closes.
- docstring: example uses --model qwen2.5:3b (the locally-pulled model),
not llama3.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Closes the cross-language half of token streaming (perf-plan A3). On the
CPU-only box perceived latency is time-to-first-token, so showing the reply
as it generates makes a slow model feel live.
- Agent: OllamaProvider.stream() runs on a worker thread; bridge relays
cumulative previews as throttled (~5/sec) `_ai:"stream"` control frames,
then a `done` frame clears the preview as the final persisted chat message
is posted. Providers without stream() fall back to blocking complete().
- Rust client: new Net::AiStream variant + parse_ai branch; App.ai_stream
map holds the in-progress text per agent; draw_chat renders it as a dim,
italic preview bubble below history. Cleared on done and on agent leave.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tier A/B/C wins for the CPU-only Ollama box (no GPU → optimize TTFT and
tokens/sec, not VRAM):
- Separate qwen2.5-coder provider for the sandbox `!task` path; chat keeps
the general model. Auto-selected when chat is Ollama and a coder build is
present, override with --code-model.
- OllamaProvider num_ctx default 8192→4096 (8192 was a GPU-mindset default
that inflates prefill/TTFT on CPU); expose num_thread; add --num-ctx,
--num-thread, --num-predict. token_budget default 3000→2000 to fit.
- OllamaProvider.stream() generator over Ollama's stream=True chat endpoint
(provider half of token streaming; agent/Rust rendering is a follow-up).
- Few-shot request→shell exemplars in SANDBOX_SYSTEM to anchor the small
model's fenced-command output.
- Matryoshka embedding truncation: OllamaEmbedder truncate_dim=256 (--embed-dim)
for faster pure-Python cosine and less RAM; query+stored share the dim.
- docs/ai-perf-plan.md records all 8 items with status and the server-side
env (OLLAMA_NUM_PARALLEL=1, keep_alive) that must be set where ollama serve runs.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Give the agent recall of things said beyond the verbatim window, without
breaking the RAM-only philosophy — nothing is persisted to disk.
- MemoryIndex: a capped, in-memory pool of embedded messages with pure-Python
cosine search (no numpy). Retains far more than the rolling transcript so old
lines can be surfaced on demand; oldest evicted past the cap to bound RAM.
- OllamaEmbedder: local embeddings via nomic-embed-text, on by default and
independent of the chat provider (reuses the Ollama host when chat is Ollama).
- Bridge: captured room messages (live + backfilled) are embedded on a
background worker so a slow embedder can't stall frame draining. On a /ai
question the agent retrieves top-k relevant lines, drops weak (<min_score) and
windowed-duplicate hits, and prepends them as a clearly-fenced "recalled
context" preamble — kept at user role, never elevated to system, so untrusted
room text informs without instructing. Falls back to recency-only if the
embedder is unreachable.
- CLI: --no-rag, --embed-model, --embed-host, --rag-top-k.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
OllamaProvider now sends keep_alive (default 30m) so the model stays resident
in VRAM between /ai calls instead of cold-reloading, and sets explicit options
(num_ctx 8192, num_predict 512) — Ollama otherwise caps context at 2048, which
would silently truncate the larger backfilled window.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The server already ships the full RAM message backlog in the init frame; the
agent was discarding it. _seed_transcript now decrypts that history with the
room key (skipping our own lines, control frames, and undecryptable blobs) so
the agent has context the moment it joins instead of starting amnesiac.
_window() replaces the fixed last-12 slice on both the answer and sandbox
paths: it walks newest-to-oldest and keeps messages up to --token-budget
(approx, ~4 chars/token), still capped at --context-window count. Keeps small
local models inside their effective context. Nothing touches disk.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Agents can now run commands and build files in the shared sandbox, but
only when explicitly invoked with the `!` verb and only while the owner
has granted drive. Reuses the existing driver ACL + `_sbx:input` frames:
the Python agent emits the same input frames a human driver does, gated
by the broker's `app.drivers` check — no new transport.
Guardrails: a regex gate holds destructive commands until `/ai <name>
confirm`; blast-radius caps (20 cmds / 8KB); the agent echoes its plan to
the room before running (audit trail). Owner controls: `/grant`, `/ai
start <model> allow` to pre-grant on spawn, and a Ctrl-X panic kill
switch (revoke all non-owner drive + Ctrl-C the shell). The broker now
re-broadcasts the ACL on join so a freshly-summoned agent actually
receives its grant.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Make connecting any model a config step, not a code change:
- models.toml named profiles (api_key_env names an env var, never the key)
- providers gain available_models(); add preflight + --list-models/--check
- /ai list and /ai models in-room; client probes local Ollama for
/ai models when no agent is running, and /ai list hints to summon one
- docs/providers.md provider guide + examples/echo_provider.py
- README: command table, AI section, layout updated
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Owner of the spawning client can summon/dismiss a local AI agent from inside
the room (default ollama/qwen2.5:3b); the agent emits encrypted typing frames
that drive a "thinking" spinner in the client.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add cmd_chat/agent: a headless client that joins a room via SRP, decrypts
broadcasts, and answers /ai <question> through a pluggable model provider
(ollama default + anthropic + openai-compatible + module:Class). Server and
zero-knowledge guarantees unchanged; the agent is just another encrypted client.
Also pin the lets-hack demo to a detached worktree of main (default) so running
it from dev still demos stable main without touching the working checkout.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>