I deployed a change, and the agent needed to confirm the new version was live before running a smoke test. So it wrote what agents always write in this situation:
for i in $(seq 1 40); do sleep 3; curl -s http://host/health && break; done
Then it read the output, saw the old version still serving, and declared the deploy done anyway — because the loop had exited on the first curl that returned anything, not the first one that returned the right thing. I had watched a variant of this play out a dozen times. The agent either polls too few times and gives up early, or hard-codes a sleep 120 and blocks the whole session on a fixed guess, or exits on a 200 that carries a stale body. Every time, it re-derives the same fragile loop from scratch, because there is nothing better sitting in its toolbox.
Why agents reach for sleep
An agent acts through the tools it has. Give it bash, and "wait until the server is ready" collapses into "sleep and hope," because a fixed sleep is the cheapest thing the shell offers. It has no primitive for readiness — no verb that means "keep checking this condition, on a sensible cadence, until it holds or you give up." So it fakes one, and the fake is worse in every dimension: it doesn't know how long to wait, it doesn't know what "ready" actually looks like, and when it fails it throws away everything it observed on the way down.
That last part is the real cost. A hand-rolled loop that times out tells you nothing. The agent is left to run another probe just to find out why the first forty failed — a human-speed round trip through the clipboard, which is exactly the distance I keep trying to shorten between an agent and the ground truth it can't see.
The primitive
So I wrote opencode-waitfor, a zero-dependency plugin that adds one tool, wait_for. You install it by adding one line to opencode.json:
{ "plugin": ["opencode-waitfor"] }
The tool infers what kind of target it's watching. A URL with a scheme gets polled over HTTP; a bare host:port gets a TCP connection check; anything else runs as a shell command. Three shapes of "is it up yet," one verb:
wait_for http://localhost:3000
wait_for localhost:5432 timeout 10
wait_for http://host/health expect { json_match: { status: ok, version: abc123 } }
That last one is the case that started this. After deploying commit abc123, the agent waits until /health reports that version — not any response, the correct one. The stale-body deploy I opened with simply cannot pass.
And when it does time out, it returns the last thing it saw: the final HTTP status and body, or the last command's exit code and output. The agent gets to diagnose from the failure it already has, instead of firing a fresh probe to reconstruct it.
I've written before that the job left to a human, when the agent writes more code than you can read, is standing at the boundary and shortening the distance to ground truth. Usually that's a manual act. Sometimes you can make it structural — hand the agent a primitive shaped like the thing it kept faking, and the bad behavior stops being something you prompt against and starts being something it can't easily do. A tool changes what an agent reaches for more reliably than any instruction telling it to reach for something else.
Thanks for reading. I build tools for AI coding agents at github.com/chncaesar:
- opencode-db-clean — reclaim GBs of SQLite disk space
- opencode-waitfor — proper readiness checks, no more sleep loops
- opencode-session-reflection — turn past sessions into workflow improvements
- opencode-fleet — multi-node OpenCode orchestration
Top comments (2)
The "exits on a 200 that carries a stale body" failure is painfully familiar — we had an agent mark a deploy green because
/healthreturned a response, not the right one, and the smoke test then ran against the old build. Your point that the fix is structural, not a prompt, matches what we've seen: no amount of "please verify the version matches" in the system prompt is as reliable as a tool that simply can't pass on a mismatch.The detail I'd push on is the returned-context-on-timeout part. That's underrated. Most hand-rolled
wait_forloops throw away everything they observed, so the agent burns a second round-trip just to reconstruct why the first 40 probes failed — and that second probe is where it tends to hallucinate a cause. Handing back the last status + body means it diagnoses from real evidence. Curious whether you've considered exposing the per-attempt history (not just the last one), so it can distinguish "never came up" from "flapped"?Yes, agreed. Returning evidence on timeout is the part I care about most: the agent should diagnose from what actually happened, not run a second blind probe.
I kept v1 to the last observed state to avoid flooding context, but per-attempt history makes sense for flapping targets. If I add it, I’d make it opt-in and bounded, so the default result stays compact while deploy/debug flows can ask for the full trace.