Skip to content

Agent harness: a bounded tool-calling loop with the WebContainer as control plane - #37

Merged
twilson63 merged 30 commits into
mainfrom
agent-harness
Jul 28, 2026
Merged

Agent harness: a bounded tool-calling loop with the WebContainer as control plane#37
twilson63 merged 30 commits into
mainfrom
agent-harness

Conversation

@twilson63

Copy link
Copy Markdown
Collaborator

Replaces Build's single-shot {reply, patches[]} agent with a bounded
tool-calling loop that uses the StackBlitz WebContainer as the agent's control
plane.

The decisive asymmetry: HyperChat rents an E2B microVM for run_code. Build
already owns a sandbox
, so exec is wc.spawn() — the agent can run
npx tsc --noEmit, read real diagnostics, fix itself, and only then hand back.

What it does now, live

Real model, real network, real container:

Read main.tsx → Wrote main.tsx → Checked the code — found problems
→ Read main.tsx → Built cleanly

Across a 3-turn session it built a working 16-file task app: a
Header/TaskList/App split written with one fs_batch_write, add-task and
strike-through interactions, and date-fns installed via the pkg_dirty path.
S1 verification rate 4/4 — every turn typechecked, found real errors, fixed
them, and rebuilt before replying.

Tools

ToolRunsGated
fs_list / fs_read / fs_write / fs_batch_write / fs_deleteclientno
execclient — wc.spawn()no
web_search / web_fetchserver, inline (managed only)no
web_postserver, on approval (managed only)yes

Plus a curated job picker (Quick changes / Most work / Hard problems — never a
model id), an activity trail, and per-turn undo.

Load-bearing invariants

  • One write path. Every agent write goes through project.FileApplied.
    project.files is the source of truth; the container FS is a replica that
    isSyncableTextFile filters. A direct wc.fs.writeFile desyncs the editor,
    ZIP export, publish, autosave, and the next turn's context — silently.
  • One install trigger.InstallDependencies, driven by pkg_dirty, cleared
    only on a successful install.
  • One bubble and one build-log entry per turn, not per step.
  • Trust the sandbox.fs_* and exec run unattended; only web_post gates,
    because it is the one tool that reaches out of the WebContainer.
  • Tainted turn. Any web read withdraws web_post for the rest of it.
  • Stateless server. No transcript is persisted — that would mean Build's
    server holds users' source code.

Review history

A plan gate returned NOT APPROVED with 12 blockers before any code was
written (the tool transport did not exist; openrouter.ts 502'd on the exact
shape of a tool-call response; the timeout path was dead code; ResetProject
bypassed cancel). An implementation judge then found 3 more, and a second
review found 4 more — including one where undo could restore project A's
files over project B. All resolved, each with a regression test.

Bugs that only a real run could find, all fixed:

  • The user's request was dropped after step 0, so the model answered "looks
    like you sent a blank message"
    while the turn reported success with zero
    files changed.
  • The transcript window was one step wide, so the model re-decided from the file
    tree every step — 12 steps for work that takes 2.
  • FetchLLMClient held globalThis.fetch unbound (pre-existing; would have
    broken BYOK's JSON path in production).
  • The agent was blind to the project: its file snapshot was published only from
    autosave, which is gated on the container being hydrated.

Validation

npm test gleam 194, vitest 236
npm run build ✓
cd server && npm test 309
cd server && npm run typecheck clean
smoke-layout-modes 25/25
smoke-interview 14/14
smoke-agent-harness 37/37 (new — drives a real multi-step turn)

Merge-base was gleam 140 / vitest 108 / server 103.

⚠️ The auto-generated preview will look broken — it is not the branch

Previews set VITE_MANAGED_AUTH: "true" but rewrite /api/* to the
production API, and the API service has previews disabled (the disk pins it
to one instance). So the preview runs managed mode against an API with no
/api/agent/step and every turn 404s. Ignore it.

Production is VITE_MANAGED_AUTH: "false", so merging exercises the BYOK
loop — the path validated live here — and the API changes are purely additive.

Not in this PR

  • Managed mode has never served a real request. All live runs went through
    BYOK. Both transports share the actor, executors, prompt and accumulation, and
    every fix landed in both — but /api/agent/step has only seen fixtures.
    Needs a Clerk instance. Flip VITE_MANAGED_AUTH separately, not with this
    merge.
  • web_* are managed-only, so they stay inert in prod until that flip.
  • The Agent / Skills / Workflow workspaces are designed in PRD §9, not built.
    My recommendation against the workflow canvas is in there, with the schema
    designed anyway so it can be overruled without re-deriving.
  • S2 (broken-handback rate) needs a corpus, not six turns.

Full detail: docs/agent-harness-prd.html and docs/agent-harness-progress.html.

🤖 Generated with Claude Code

https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i

twilson63and others added 30 commits July 27, 2026 10:52
Plans the replacement of Build's single-shot {reply, patches[]} agent with a
bounded tool-calling harness using the WebContainer as the control plane.
Research findings that shaped the design:
- project.files is the source of truth; the container FS is a lossy replica, so
every agent write must route through project.FileApplied or the editor,
publish, autosave, and next-turn context all go stale.
- The envVars/.env channel is dead code — no Gleam effect field, no server
request field, env-store.ts orphaned. Resolved by denying .env rather than
gating it.
- The 10/min rate limiter would 429 the user's second turn once one turn is
12 requests.
Plan gate returned NOT APPROVED with 12 blockers; all resolved in PRD §17 and
the three most consequential re-verified against the code first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
server/src/models.ts — OpenRouter catalog with two capability mechanisms on
purpose: a hand-maintained allowlist is authoritative for the three curated
jobs (a wildcard rule would silently admit a future non-tool variant of a name
we trust), while the advanced catalog is filtered by the live feed's
supported_parameters (274 of 342 rows). Serves stale on a fetch failure rather
than emptying the picker, and coalesces concurrent misses into one fetch.
Curated chains are grounded in a live spike against openrouter.ai on
2026-07-27, not invented: every id is present and tool-capable, with real
pricing recorded in the source. The spike also settled PRD open question B3 —
the free tier's existing default qwen/qwen3.6-35b-a3b IS tool-capable, so no
cost-affecting model change is needed.
server/src/rate-limit.ts — split turn-starts (10/min, unchanged) from
continuation steps (40/min). The old single 10/min limiter would have 429'd a
user's second turn within a minute once one turn became 12 requests.
server/src/step-token.ts — HMAC bound on a client-driven loop, key derived from
KEY_ENCRYPTION_SECRET via HKDF with a distinct info label so a step-token leak
cannot be replayed against credential encryption. Documents its own limit: not
single-use, so it bounds a token rather than a turn; the real spend ceiling is
the per-user OpenRouter key limit.
Harness deps on AppDeps are optional, so the single-shot /api/agent surface and
its tests keep working untouched.
server tests 103 -> 153, typecheck clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
The plan gate found that neither client could actually speak tool calls, and no
step owned adding it — the whole loop was planned on a transport that did not
exist.
server/src/openrouter.ts gains toolCompletion() alongside chatCompletion().
They deliberately do NOT share a result type: a pure tool-call response has
content: null, which chatCompletion maps to a 502. That is correct for the
single-shot JSON protocol and would have failed on the harness's first step.
toolCompletion errors only when a message has neither content nor a usable call,
and never sends response_format (providers reject it alongside tools).
ModelMessage gains the tool role plus tool_calls/tool_call_id on both sides.
Providers reject a tool_calls array with no matching tool message, so the pair
is documented as inseparable and pinned by a test.
normalizeToolCalls drops calls with no id: the answering tool message must
reference the id, and providers emit partial entries on truncation.
Ollama is cut from tool mode (blocker B2), enforced by the literal type
LLMStepParams.provider: 'openrouter'. It forces format:'json' (conflicts with
tools), returns tool_calls arguments as an object rather than the JSON string
every other provider sends, and exposes no capability metadata at all — which
makes the S7 pre-flight check unimplementable there. Ollama stays on the JSON
protocol and the UI will say so rather than degrading silently.
server 153 -> 169 tests; root vitest 108 -> 124; gleam 140 unchanged; tsc -b and
server typecheck clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
agent.gleam gains the tool-loop state machine: step counter, activity trail,
union of touched paths, pkg_dirty, pending calls, and the accumulated reply.
New messages AgentStepReturned / AgentToolStarted / AgentToolFinished /
AgentStepBudgetReached; new effects CallAgentStep / ExecuteTool / KillExec /
InstallDependencies.
Design change from the PRD: the step counter lives in State, not in
Lifecycle.Running. It is turn-level bookkeeping rather than a distinct
lifecycle, and keeping Running at two fields means every existing case over it
still compiles — which is what lets this unit land with update.gleam byte-for-
byte unchanged (verified: git diff on update.gleam is empty).
The transcript deliberately does NOT live in Gleam. Gleam holds counts, names,
statuses, summaries and paths; opaque provider JSON and raw tool bodies stay in
agent.mjs. Modeling them here would put file contents and fetched page bodies
into the app model, and the trail is model-visible on the next step.
Invariants pinned by test:
- MAX_TOOL_STEPS stops the loop rather than dispatching another step.
- A stale request_id is ignored at all five new entry points.
- Cancel/timeout/failure emit KillExec alongside AbortAgent — a wedged command
that outlives the turn quietly degrades the container.
- Timeout rests in Idle, not TimedOut, so the user can retry immediately.
- touched_paths dedupe and keep first-write order.
- pkg_dirty clears only on a *successful* install (blocker B4), so a failed
npm install still gets the turn-end retry.
- turn_summary never claims "checked it builds" when the check failed.
One existing test updated: cancel effects now include KillExec, with a comment
explaining why the contract changed.
gleam 140 -> 164 tests; vitest 124; npm run build green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
The unit that makes the harness Build's rather than generic: exec is wc.spawn(),
not a rented microVM, so the agent can run `npx tsc --noEmit`, read real
diagnostics, fix them, and re-check before handing back.
src/agent-tools.ts holds fs_list / fs_read / fs_write / fs_batch_write / exec
behind two rules, each enforced in exactly one function so there is one place to
audit:
- Writes go through project.FileApplied, never wc.fs. project.files is the
source of truth and the container FS is a replica that isSyncableTextFile
silently filters; a direct write would leave the editor, ZIP export, publish,
autosave, and the next turn's context reading stale bytes with no error
anywhere. Attested by grep: agent-tools.ts mentions wc.fs.writeFile only in
the comment explaining why it must not call it.
- Results never echo content. A write returns `ok · 412 bytes`. Tool results are
re-sent on every later step, so echoing pays for the same bytes repeatedly.
fs_batch_write validates the entire batch before applying any of it: half a
refactor is worse than none, because the model's next step would reason about a
file set that never existed as a coherent whole.
exec bounding, with the boundary described honestly as reliability + visibility
rather than airtight security (the user's own jsh can already run anything):
argv form, allowlist npm/npx/node, npx narrowed to {tsc, vite} — bare
`npx <pkg>` runs arbitrary remote code with no visible artifact, making it
strictly more capable than the `node -e` denied beside it. `node <file>` stays
allowed because the file had to be written first, and a write is chip-visible.
Refusals teach ("the dev server is already running... run npm run build")
because a bare no invites a retry.
Two bugs found by writing the tests:
- exec claimed its concurrency slot only after `await spawn`, so two calls in
one step could both pass the guard and race two installs against
node_modules. The claim is now synchronous, with a deferred kill for an abort
landing mid-spawn.
- validateExec checked shell metacharacters before the node -e rule, so
`node -e 'console.log(1)'` was refused with the generic reason instead of the
one that tells the model what to do instead.
- flushContainerWrites initially cleared debounced editor writes instead of
performing them, which would have silently lost the user's keystrokes.
Trail copy reports outcomes, not statuses: "Checked the code — found problems",
"Built cleanly". A test asserts no summary ever leaks a tool name or a raw
command.
vitest 124 -> 205; gleam 164; tsc -b, npm run build, server typecheck clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
POST /api/agent/step — one step of the client-driven loop, stateless by design.
The browser drives (it owns the WebContainer, so fs_*/exec execute there) and
carries the transcript in the request body, so Build's anonymous/local-first
promise holds: the server sees project bytes transiently, exactly as /api/agent
already does, and persists nothing new.
Wire shape is tree + fullFiles, not the whole project (blocker B3). A 160k-char
project would otherwise be re-uploaded on all 12 steps and re-checked against
the 2MB cap each time. Pinned by a test that a 400-file project still produces
a valid step request.
Tool-mode prompt drops the JSON envelope from the header entirely rather than
contradicting it later. The header is the strongest signal in the prompt, and
leaving the old shape there would invite answers the legacy adapter silently
accepts — so the regression would never surface as an error.
Step-budget nudge is binding, not advisory: on the final step the server both
injects the nudge AND offers no tools. Ending a long turn with a usable answer
beats an error that discards everything already done.
Legacy {reply, patches} answers decode into one synthetic fs_batch_write, so
there stays exactly one write path. Reported as usedLegacyAdapter so its hit
rate can be measured and the adapter deleted once it reaches zero.
Parity guards extended (U6). The tool specs are now a second intentionally-
duplicated surface (client executes, server declares) and a disagreement would
show up as a stalled turn rather than an error, so it is guarded like the prompt
and the starter template:
- identical tool names and descriptions across both files
- MAX_CALLS_PER_STEP and MAX_TOOL_STEPS in step with the Gleam actor constants
- no web tool ever named in the client surface (SERVER_ONLY_RULE_MARKERS)
- no client tool marked approval-gated — the tripwire for "trust the sandbox"
- the JSON-mode Rules block extracts unambiguously (advisory A13)
Guard verified to bite: drifting one server description fails with a named,
actionable message, then passes again on restore.
server 169 -> 198 tests; root vitest 205 -> 212; gleam 164; tsc -b and server
typecheck clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
Records the real state: the harness is fully built and deliberately dark.
Every unit's evidence, the live OpenRouter spike that settled open question B3,
the boundary attestations that hold today (and the two deferred to U7a/U7b),
and the three bugs found while building rather than inherited.
Validation: gleam 164, vitest 212, server 198, both smokes at full count
(25/25 and 14/14), tsc -b and server typecheck clean. Merge-base was
140/108/103, so +223 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
The switch is flipped. callAgent no longer makes one single-shot call — it opens
a turn, records the context that does not change between steps, and takes step 0;
the actor drives continuation through callAgentStep once every tool call for a
step has reported back.
Retirement is complete, not partial (blocker B4). agent.Patch,
AgentRequestSucceeded, InstallIfNeeded(patches), apply_patches, and
installIfNeeded are all GONE. There is now exactly one write path
(project.FileApplied, via the fs tools) and exactly one install trigger
(InstallDependencies, driven by pkg_dirty). Leaving either in place would have
meant two paths that can both decide to npm install.
One chat bubble and one build-log entry per TURN, not per step: the Build Story
is a narrative of what was built, and a twelve-step turn is still one thing the
founder asked for. Pinned by a test that runs read -> write -> exec -> answer and
asserts a single bubble and a single story entry.
Blocker B6 — the timeout path was dead code. Nothing dispatched
AgentTimeoutReached and update_agent had no branch for it, so a timed-out turn
would have rendered no bubble at all. The turn deadline now dispatches it and
update_agent renders a bubble that says work already done was kept. The deadline
is per TURN, not per request: before the harness those were the same number,
under a 12-step loop they are not.
Blocker B7 — ResetProject emitted a bare AbortAgent and left the lifecycle
Running, unlike New and Open. Under the loop a late step response would still
have matched its request id and kept driving tool calls into the project the user
had just reset. It now routes through AgentRequestCanceled, and abortAgent clears
the turn maps so a late reply finds no context to continue from.
Advisory A10 resolves by construction: improve_selected_element emits the same
agent.CallAgent effect, which is now the loop's entry point, so the element
picker converted without a separate change.
gleam 164 -> 169; vitest 212; server 198; npm run build, both smokes 25/25 and
14/14, tsc -b and server typecheck all clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
The trail is deliberately NOT a growing list of tool calls. While the agent
works it is a single line that changes — bolt, verb, outcome. When the turn ends
it collapses to one quiet summary ("2 steps · 1 file · checked it builds") that
expands into receipts. A twelve-row log would push the composer off a laptop and
read as CI output to a founder whose prompt forbids jargon and file paths.
Amber stays the working-state signature: the bolt while working, nothing at all
once idle. Step rows use charcoal rather than slate, because slate (#5B7FA6)
reads blue at that size and competed with Show/Hide — the row's only interactive
affordance.
Driving it in a real browser found two genuine bugs that no unit test would
have:
1. THE AGENT WAS BLIND TO THE PROJECT. publishProjectFiles only fired from
SaveCurrentProject/ScheduleSave, and autosave is gated on the container being
hydrated — so on a fresh boot the snapshot was empty and fs_list returned
"The project has no files." The agent would have rewritten files it could not
read. Fixed at three levels: seeded from the authoritative file set in
setLastFiles (boot/remount, the only path that carries files on a first run),
seeded on ProjectLoaded/Created/FilesUpdated, and upserted incrementally on
every WriteFileToContainer so it never again depends on an autosave.
The smoke now asserts "Listed 12 files" rather than an empty project.
2. dispatchAgentSucceeded still referenced the removed AgentRequestSucceeded and
Patch constructors, and agent.mjs used dispatchAgentStepReturned and
dispatchAgentTimeoutReached without importing them. All three were runtime-
only paths that vitest never executes — the timeout would have thrown instead
of ending the turn.
New scripts/smoke-agent-harness.mjs drives a real multi-step turn with the real
executors against real project state; only the model is stood in for. 17/17,
covering the file-snapshot invariant (S5), one-line-not-a-log, verbs-not-tool-
names, one bubble per turn, chips, no amber when idle, 375px composer survival,
and cancel leaving no turn state behind.
gleam 169 -> 171; vitest 212; smokes 25/25 + 14/14 + 17/17.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
The failure mode this avoids is a <select> of 300 OpenRouter slugs in a settings
modal, which makes the user responsible for a decision they cannot evaluate. So
the picker sits beside Send (choosing how hard to think is part of composing a
request), names the JOB rather than the model, and never shows a model id
anywhere: "Quick changes" / "Most work" / "Hard problems".
The blurbs talk about the tradeoff the user actually feels — their monthly
budget, not milliseconds — because that is the real decision against a $5 cap.
Hidden entirely for Ollama: it has no tool mode and no catalog, so a picker
there would promise a choice that does not exist.
The job -> model mapping is a fourth intentionally-duplicated surface (Gleam
offers, server validates), so it joins the guarded set: prompt-parity now
asserts settings.gleam's job_model matches the first choice of every
CURATED_CHAINS entry in server/src/models.ts, in order, and that no job label
contains a model id. Verified to bite — drifting one id fails with a diff.
Managed mode PUTs the chosen id to /api/me/model, where the server re-validates
it against the live tool-capable catalog; a model that cannot call tools is
refused there rather than silently breaking the harness three turns later.
Smoke grew to 22/22 with the picker checks. gleam 171, vitest 215, server 198,
smokes 25/25 + 14/14 + 22/22, tsc -b and server typecheck clean.
This completes release 1 (U1-U9).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
Marks U7a/U7b/U8/U9 pass with their evidence, promotes the two deferred boundary
attestations to held, records the blind-agent bug and the three runtime-only
reference errors, and replaces the not-wired residual with the two that actually
remain: no live-model run yet, and BYOK-OpenRouter still on the JSON path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
Completes the core tool set: web_search, web_fetch, and web_post. Managed mode
only — they need the SSRF guard, a server-held search key, and an authenticated
caller, none of which BYOK has. The parity test now pins that asymmetry in both
directions: each web tool must be declared server-side and must NOT be declared
client-side.
Ported from the reference implementation and adapted:
url-guard.ts keeps the method-class split, which is the non-obvious part. Network
boundary names (localhost/.local/.internal/metadata) are blocked for every
method. Our own trust surfaces (scoutos.live, hyper.io, openrouter.ai, Clerk,
Render) are blocked for WRITES only — an uncredentialed redirect-free GET sees
what any anonymous client sees, while a POST could publish or spend. Suffix
matching is dot-bounded so "evilscoutos.live" is not treated as ours.
safe-http.ts does the connect-time IP check inside the socket's own DNS lookup,
which is the only place that closes the rebinding window. isPrivateIp is written
out explicitly rather than pulled from a dependency, covering loopback, private,
link-local (cloud metadata), CGNAT, TEST-NET, multicast, and the IPv4-mapped and
NAT64 wrappers of all of them. No redirects, no ambient credentials, byte and
time caps. Verified live: a fetch of localtest.me (which resolves to 127.0.0.1)
is blocked at connect time, so the custom lookup does work on this runtime.
injection-guard.ts is the single choke point every web result passes through —
sanitize, scan, wrap. It WARNS rather than blocking, because heuristics have
false positives and a page about prompt injection must stay readable. The
tool-coercion pattern is extended with Build's own tool names. Delimiter
neutralization is joiner-tolerant, so a forged "<<<E‌ND UNTRUSTED" split by
zero-width joiners cannot fake an end-of-untrusted-data boundary. The truncation
budget is computed from real assembled overhead so the END marker always
survives the slice.
The tainted-turn rule is a Build addition the reference does not have: once
anything has been read from the web, that turn's web_post is withdrawn from the
offered tools AND re-checked server-side before sending, so a client that
"forgot" cannot unlock the write. Scoped honestly in the source — it closes the
channel Build's own server would lend to an injected instruction, not
exfiltration in general, since the WebContainer has its own egress.
web_post is the only gated tool in the harness. It never reaches the client as a
runnable call; it comes back as an approval request carrying the exact method,
URL, and body, because a card that summarizes is a card that hides something.
Search prefers Brave when keyed and requires an explicit opt-in for keyless
DuckDuckGo: keyless runs from shared Render egress, so throttling would hit
every user at once. Failure says "unavailable" rather than returning nothing —
an empty result set reads as "the web has nothing" and sends the model off to
invent an answer.
Both new guards verified to bite: removing the tainted-turn withdrawal fails 2
tests; leaking a web tool into the client spec set fails 3.
server 198 -> 295 tests (+97); root vitest 215 -> 220; gleam 171; smokes 25/25 +
14/14 + 22/22; tsc -b, npm run build, server typecheck clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
U11 returned an approval from the server but nothing rendered it — so a
web_post would have been silently dropped: a step with no client calls reads as
"the model answered" and closes the turn. This completes the path.
The actor gains pending_approval, and a step with no calls PAUSES rather than
finishing while one is up. Both outcomes answer the model: approve sends and
feeds back the response, decline feeds back "the user declined" — a turn that
told the model nothing would stall until the deadline.
The blocked case is enforced in the actor, not just hidden in the UI: an approve
message for a tainted-turn approval still routes to decline. Three independent
layers now have to agree before anything leaves the sandbox — the card has no
send button, the actor refuses the send, and the server re-checks the taint.
The card shows the whole request: plain-language headline naming the host,
method and full URL in mono, and the exact body in a scrollable block. Ink on
white with the amber left rule, the same "needs your attention" language as the
preview-error card — never a red alarm.
While an approval is up the working line says "waiting for you" rather than
"hyper is thinking", because it isn't.
gleam 171 -> 177; smoke 22 -> 29 checks, including that the card shows the exact
body rather than a summary and that a tainted turn offers no send button.
vitest 220, server 295, all smokes green, tsc -b and typecheck clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
B1 — the web_post approval flow broke the turn at the provider, and did so
AFTER the side effect. The server excluded the gated call from `toolCalls`, so
the client echoed an empty assistant tool_calls array while sending a tool
result for id p1. Providers reject a tool message with no matching call, so the
user would have seen "Agent request failed (502)" after their data had already
been POSTed. The server now also returns `transcriptCalls` — everything the
model emitted — and the client echoes that. This is the invariant the code
documents in three places and nothing tested; there are now three tests,
including one asserting every tool_call_id in a post-approval step is announced
in the same request.
B2 — server-run web reads never reached the trail. AgentToolStarted updates an
existing row and deliberately ignores unknown ids (a stale-id guard), but server
steps have no row to update, so every web_search, every web_fetch, and the
injection warning with them were silently discarded. Rather than weaken the
stale-id guard, a distinct AgentServerStepRecorded appends a finished row. The
PRD specified that row verbatim; it did not exist in the shipped product.
B3 — BYOK regression. The single-shot path applied patches directly and never
dispatched AgentToolFinished, which is the only thing that populates
touched_paths and pkg_dirty. So BYOK turns had no narration chips, contributed
no paths to the Build Story, and — worst — never triggered npm install: adding a
dependency left the preview broken with an unresolved import. BYOK now adapts
its response into the same synthetic fs_batch_write the managed legacy adapter
uses, so both modes go through identical machinery. (My first attempt had an
ordering bug of its own: AgentToolFinished emits CallAgentStep synchronously,
which would have fired a second provider call — the turn is dropped first.)
Advisories also fixed:
- A4: web_search result sets and web_post response bodies now go through
guardWebContent. Titles, snippets, and a POST target's reply are all
attacker-authored, and the PRD said EVERY web-tool result.
- A5: isPrivateIp gained fec0::/10 site-local, 2002::/16 6to4 (2002:7f00:1:: is
127.0.0.1 in a costume), and hex NAT64/IPv4-mapped forms.
- A6: deny patterns were inconsistently case-sensitive, leaving .ENV, ID_RSA and
.NPMRC writable.
- A8: loadJob was never called, so the picker silently reset on every reload
despite persistJob writing faithfully. SettingsLoaded now carries the job.
- A3: docs/architecture.md still described the retired patch protocol; the Agent
protocol section is rewritten and the validation battery updated.
gleam 177 -> 180; server 295 -> 305; vitest 220; smokes 25/25 + 14/14 + 29/29.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
…eads)
A1 — a step can return BOTH a client call and a web_post, which left two
independent triggers for the next step: the last tool finishing, and the user
resolving the approval. Two in-flight requests sharing one turn would race
drainToolResults and clobber each other's stepToken and toolCalls. Fixed at both
levels: the actor withholds CallAgentStep while an approval is pending, and
runStep is serialized per turn with a queued-step slot so the later trigger is
deferred rather than dropped — its results still reach the model next step.
A2 — inline web reads were lost the moment the step ended. The server pushed the
assistant/tool pair into its LOCAL message list, but step N+1 rebuilds from the
request body via buildToolModeMessages, which knows nothing about it. So the
agent would read a docs page, write a file, and on the next step have to fetch
the same page again — on a turn whose taint flag had already cost it web_post.
The server now returns the inline calls in transcriptCalls and their answers in
serverToolResults, and the client echoes the pair.
Writing the A2 test caught a bug in the A2 fix: the loop-local filter variable
shadowed the accumulator of the same name, so every push landed in a local that
was discarded each iteration and transcriptCalls came back empty.
gleam 180 -> 182; server 305 -> 307; vitest 220; smokes 25/25 + 14/14 + 29/29.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
The counterweight to unattended writing. "Trust the sandbox" means a turn can
rewrite six files without asking, so there has to be a way back — and until now
the only recovery from a bad multi-file turn was "Reset to default app", which
nukes the project.
The snapshot rides the agent actor: AgentRequestStarted carries the project as
it stands, captured before the turn writes anything. clear_turn deliberately
does NOT drop it — undo has to outlive the turn that produced it — so it is
replaced when the next turn starts and dropped on project navigation, which
resets the whole actor.
Only the most recent turn is undoable, and only when it actually changed
something. A stack would need persistence and a UI; the realistic regret is
always "that last one made it worse". An undo button after a read-only turn
would promise to reverse nothing.
Blocker B5 was specifically that the obvious mechanism does not work:
project.RemountProject discards its files argument at the interpreter and
remounts a JS-side cache that may be several turns stale, so restoring through
it would look right and silently mount the wrong thing. RestoreSnapshot carries
the files explicitly and goes out via dispatchWebContainerRemountRequested,
which is the only remount path that passes them through.
Verified end to end in a browser rather than by inspection: a turn clobbers
src/main.tsx, undo is offered, clicking it restores the byte-exact pre-turn
content, and the button is gone afterwards.
gleam 182 -> 187; smoke 29 -> 34 checks; vitest 220; server 307; all three
smokes green; tsc -b and typecheck clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
Without it the agent physically cannot finish a refactor: it writes Card.tsx to
replace OldCard.tsx and the old file lingers forever, eating the context budget
every turn, showing in the Files list, and shipping to scoutos.live on the next
publish. Until now the model had no way to express deletion at all.
Bounded hard: one path per call, no directories, the global path policy applies,
and the guarded set (entry module, database bridge, build config, BRAIN.md)
refuses with an explanation rather than a bare no — a refusal that doesn't teach
invites a retry.
New actor surface: project.FileRemoved + DeleteFileFromContainer, and
templates.remove_file as the counterpart to upsert_file (additive only, so the
byte-for-byte starter-template drift test stays green). Deleting the open file
moves selection to a sibling rather than leaving the editor pointing at nothing.
Two bugs the end-to-end smoke caught that the unit tests could not:
- FileRemoved was missing from update.gleam's auto-save branch, so a delete
never scheduled a save and never republished the file list.
- The snapshot removal was inside deleteFileFromContainer, AFTER `await
bootGate` — so a delete during boot would leave the file in the agent's
snapshot indefinitely and fs_list would keep offering a file that is gone.
It now happens synchronously in the interpreter, exactly like the write path
does with publish_project_file. Same class as the blind-agent bug: the
snapshot must never depend on an async path settling.
gleam 187 -> 191; vitest 220 -> 225; server 307; smoke 34 -> 37 checks
(delete removes it, the entry file cannot be deleted).
All twelve units are now done.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
BYOK now runs the same tool loop as managed mode, driven entirely from the
browser against the user's own key. The step budget is enforced by the actor
(there is no step token without a server), and no web tools are offered — they
need the SSRF guard and a server-held search key, so their absence is structural
rather than conditional. Ollama stays on the single-shot JSON protocol and its
response is adapted into the same synthetic fs_batch_write, so every mode goes
through identical machinery.
src/agent.ts gains the tool-mode prompt as a mirror of server/src/prompt.ts,
with SHARED_RULES lifted verbatim and WEB_TOOL_RULES deliberately absent. The
parity test now compares the built tool-mode prompt across both files and
asserts the web rules appear only when the server actually offers them —
verified to bite by drifting one shared rule.
Two bugs found by driving the loop against a stubbed provider:
1. FetchLLMClient stored `globalThis.fetch` unbound and invoked it as
`this.fetchFn(...)`, so the browser saw `this` as the client instance and
threw "Illegal invocation". PRE-EXISTING — it would have broken BYOK's JSON
path in production too. Unit tests never caught it because injected test
doubles are plain functions with no `this` to lose.
2. The transcript window was ONE STEP WIDE in both modes: each step replaced
toolCalls/toolResults instead of accumulating, so the model had no memory of
what it had already done and re-decided from the file tree every step.
Measured at 12 steps for work that takes 2 — it burned the entire budget
rediscovering the same thing, then hit the ceiling and answered "Done."
Both modes now accumulate, which is also what makes the step budget mean
what it says.
Verified end to end against a stubbed OpenRouter: 2 steps, correct reply,
correct narration chips, file written, undo offered.
vitest 225 -> 234; gleam 191; server 307; smokes 25/25 + 14/14 + 37/37.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
… most
First run against a real model over the real network driving the real
WebContainer. It immediately exposed a defect that 500+ tests and three stubbed
smokes could not:
THE USER'S REQUEST WAS DROPPED AFTER STEP 0. `userPrompt` was sent only when
stepIndex === 0, so from step 1 onward the model had tool results and no
request. It read files, found nothing to act on, and answered "Looks like you
sent a blank message — what would you like to create?" The turn completed
"successfully" with zero files changed, so nothing in the harness registered a
failure. Present in BOTH transports.
The stubs missed it because a stub does not care what you ask it. This is the
whole argument for a live run as the acceptance gate.
Fixed by passing userPrompt on every step, with a source-level regression guard
(the externals layer is not executed by unit tests) verified to bite by
reintroducing the condition.
Also strengthened TOOL_MODE_WORKFLOW on both parity-guarded sides after the
first run showed the agent reading six files — index.html and main.tsx twice
each — and then stopping: reading is now explicitly preparation rather than an
answer, re-reading is called out as a wasted step, and the starter page is named
as a placeholder to replace rather than describe back.
Measured, same prompt ("Add a footer ... Built with hyper"):
anthropic/claude-sonnet-4.6 5 steps · 22s · 6 provider calls · 1 file
Read main.tsx → Wrote main.tsx → Checked the code — found problems
→ Read main.tsx → Built cleanly
qwen/qwen3.6-35b-a3b (free) 10 steps · 9 provider calls · 2 files
... → Tried to run a command it may not run ×2 → Checked the code
— found problems ×2 → Built cleanly
Both wrote real code, typechecked it, found real errors, fixed them, and
re-verified before replying — S1 satisfied by observation, not assumption. The
footer renders in the live preview. The free tier works, which settles the
open question about its default model. The exec allowlist refused two commands
and the teaching refusal let the model recover rather than stall.
scripts/live-agent-run.mjs reads the key from gitignored .env (un-prefixed, so
vite never bundles it), injects it at runtime as a BYOK user would, and redacts
defensively. Verified: zero key occurrences in the diff.
vitest 234 -> 236; gleam 191; server 307; smokes 25/25 + 14/14 + 37/37.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
…t exposed
scripts/live-agent-suite.mjs drives escalating turns in ONE session so chat
history, project state, and the container accumulate the way they do for a real
user. Each turn strains a different part of the harness.
Result on anthropic/claude-sonnet-4.6 — S1 verification rate 3/3:
turn steps calls wall verified batched prev-err
multi-file feature 6 5 68s yes yes 0
edit existing code 8 7 46s yes no 0
new dependency 9 10 68s yes no 0
It built a real working app across the three turns: "Taskwise", 16 files, a
Header/TaskList/App split written with ONE fs_batch_write, working add-task and
strike-through, and date-fns installed and used for relative timestamps. Every
turn typechecked, found real errors, fixed them, and rebuilt before replying.
Two findings worth more than the pass:
- ADVISORY A6 DID NOT MATERIALIZE. Zero preview errors across all three turns,
including the one that ran npm install. The concern was that multi-step write
bursts would crash-loop the dev server and flood the Try-to-fix card; the
flush-before-exec batching appears to be enough. Recorded as measured rather
than assumed.
- The pkg_dirty path works end to end: package.json changed, "Installed
dependencies" ran once, and the build passed after it.
Two prompt fixes the runs exposed, applied to both parity-guarded sides:
- Replies were emitting markdown — "- **`Header.tsx`** — shows the orange..."
rendered as literal asterisks and backticks in the chat bubble, which shows
plain text. The workflow now says the final message is plain conversational
text shown as-is, two or three sentences, in the user's terms not the
codebase's. Confirmed fixed by a follow-up run.
- The model burned a step guessing at commands the allowlist refuses. The
workflow now enumerates exactly what exec runs, so it does not have to guess.
Also fixed my own measurement bug: the suite counted installs from console
output and missed real ones the trail had recorded. It reads the trail now —
what the user actually sees is the honest source.
Key handling unchanged: gitignored .env, un-prefixed, injected at runtime,
redacted from output; screenshots land in gitignored scripts/.smoke.
gleam 191; vitest 236; server 307; smokes 25/25 + 14/14 + 37/37.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
… files
A second review pass over everything that landed after the last judge found
four correctness defects, three of them reproduced against the compiled actor
and the real Hono app. All four broke an invariant an earlier review had
already forced a fix for.
B1 — STALE SNAPSHOT SURVIVES A PROJECT SWITCH (data loss). The comment claimed
the undo snapshot was "dropped on project navigation (which resets the whole
actor)". It was not: navigation dispatches AgentRequestCanceled, and that was a
no-op when the lifecycle is Idle — exactly the state you are in between turns.
So a finished turn's card, its Undo button, and project A's file snapshot all
survived opening project B. One click restored A's entire file set over B and
marked it dirty for autosave. AgentRequestCanceled now clears the turn and the
snapshot from Idle too.
B2 — APPROVING WHILE CLIENT TOOLS RUN ORPHANS A CALL AND DOUBLE-RUNS THE STEP.
The code comment described the guard; the guard did not exist. Approving a
web_post while an exec from the same step was still running sent a transcript
whose exec call had no answering result (provider 400 -> 502, turn dies), and
then the exec finishing drove a SECOND step at the same index — duplicate
writes, possibly a duplicate npm install. Step tokens are deliberately not
single-use, so nothing server-side caught it. The actor now drives the next
step from exactly one place: the approval when nothing else is pending,
otherwise the last tool to finish.
B3 — a second web_post in one step was announced and never answerable, because
only approval.call_id ever receives a result. Extra gated calls are now refused
inline with a legible reason.
B4 — a client call emitted ALONGSIDE a server-run web tool was told "your
result arrives next step" and then silently dropped, because the inner loop
overwrites `result`. The turn could report done with a read still outstanding.
Reachable as soon as web tools are offered, since the prompt tells the model to
read before it writes. Deferred calls are now carried forward.
New tests assert the shared invariant directly: every announced call must be
answerable — either echoed for the browser to run, or already answered by the
server.
Re-validated live after the fixes (2 turns, Sonnet): both verified, including a
14-step turn that hit a missing tsconfig, fixed it, installed a dependency, and
ended on "Checked the code — no problems".
gleam 191 -> 194; server 307 -> 309; vitest 236; smokes 25/25 + 14/14 + 37/37.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
The provider resolution was built correctly but the key appeared in no env
config — render.yaml or .env.example — so web_search would have shipped
reporting itself unavailable. Declared as optional (sync: false) with a note on
why keyless DuckDuckGo is not the fallback on a shared-egress service.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
@twilson63
twilson63 temporarily deployed to agent-harness - build PR #37 July 28, 2026 14:20 — with Render Destroyed

@hyperio-mchyperio-mc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

This is a well-architected, thoroughly tested, and honestly documented PR. The design invariants are clear (one write path, one install trigger, trust the sandbox), the bugs found during development are fixed with regression tests, and the prompt-parity guards prevent silent drift between client and server.

What stands out

  • Clean Gleam state machine — pure data, no mocks needed, excellent test coverage (194 Gleam + 236 vitest + 309 server + 37 new smoke tests)
  • Honest bug documentation — real bugs found only by live runs, each with a regression test
  • Prompt parity tests guard against client/server drift
  • Undo is well-scoped — snapshot travels with the effect, not through RemountProject
  • Job picker UX (Quick/Standard/Hard instead of model ids) is thoughtful

Before merge

  • Get CI running on the branch to verify test counts
  • Track the managed-mode flip (VITE_MANAGED_AUTH) separately — acknowledged in PR body

Minor suggestions (non-blocking)

  • Consider a dev-only assertion if runStep is called while inFlight is true without a queued step
  • Document the globalThis.__buildProjectFiles update order in a single place

Overall: looks good to merge once CI confirms tests pass. The managed-mode flip should be a separate, tracked change.

🤖 Reviewed by MC Agent

@twilson63
twilson63 merged commit 57bb2aa into mainJul 28, 2026
@twilson63
twilson63 deleted the agent-harness branch July 28, 2026 15:20
twilson63 added a commit that referenced this pull request Jul 28, 2026
PR #37 merged to main — which Render auto-deploys — with a local test run as
the only thing between the branch and production. That worked because the run
happened; it is not a control.
Three jobs mirroring the real battery: app (gleam test + vitest + tsc + build),
server (vitest + typecheck, neither of which the app job covers), and smoke,
which drives all three browser smokes including the agent harness one — the
test that catches desync between project.files and what the agent can see.
Gleam is pinned to the same version render.yaml installs, so a build that passes
here is the build that ships. The dev server matches production's
VITE_MANAGED_AUTH=false.
Also declared playwright as a devDependency. The smokes have been running on a
stray local install for months — CI would not have had it, and neither would a
fresh clone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@twilson63@hyperio-mc