From 1e444ffe68614d18209dc85c4cf41ee262c0f46f Mon Sep 17 00:00:00 2001 From: milind-soni Date: Sat, 29 Aug 2026 19:19:44 +0530 Subject: [PATCH] =?UTF-8?q?feat(computer):=20wait=5Ffor=20=E2=80=94=20box-?= =?UTF-8?q?side=20poll=20loop=20so=20a=20bot=20stops=20burning=20turns=20w?= =?UTF-8?q?hile=20things=20boot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stolen from vercel-labs/fx's terminal monitors. A bot that starts a dev server or kicks off a long job currently polls with repeated computer_exec or screenshot calls — one model inference per peek. wait_for runs the whole wait in ONE round trip: a 2s poll loop on the box (http_ready / tcp_ready / output_matches / file_exists, bounded 1-240s), then the settled screen rides back in the same result, matching the file's act-and-observe contract. The schema is flat (enum + per-condition fields described in words) and bad input answers with a copyable example instead of a wall — both per the CONTRIBUTING "MCP tool schemas" rules from #544. A timeout returns advice (inspect with computer_exec) rather than an invitation to wait again, and the preview poker learns the tool name so the panel refreshes. Verified: 22/22 proxy contract tests (schema flatness guard, free-of-charge guidance on bad input, single-round-trip with frame, timeout advice, bash -n on every generated shell); mutation check (breaking the loop's sentinel fails 3 tests); tsc clean; lint parity with main (14 = 14 findings). Co-Authored-By: Claude Fable 5 --- server/computer-proxy.test.ts | 82 +++++++++++++++++++++++++++++++++++ server/computer-proxy.ts | 78 +++++++++++++++++++++++++++++++++ server/index.ts | 2 +- 3 files changed, 161 insertions(+), 1 deletion(-) diff --git a/server/computer-proxy.test.ts b/server/computer-proxy.test.ts index 43700acf3..77334b2c2 100644 --- a/server/computer-proxy.test.ts +++ b/server/computer-proxy.test.ts @@ -74,6 +74,10 @@ describe("computer proxy (fake box)", () => { }) : command.includes("openmausbot-cdp.mjs click") || command.includes("openmausbot-cdp.mjs fill") ? `GEOM 1920 1080\nHASH ${hash}\nSIZE ${size}\nB64 ${JPEG}\nSEM ok\n` + : command.includes('echo "WAIT') + ? command.includes("/dev/tcp/") + ? "WAIT no ELAPSED 5\n" + : `WAIT yes ELAPSED 3\nGEOM 1920 1080\nHASH ${hash}\nSIZE ${size}\nB64 ${JPEG}\nACT ok\n` : cropFails && /convert "\$f" -crop/.test(command) ? `GEOM 1920 1080\nHASH ${hash}\nCROP_FAILED\n` : /GEOM/.test(command) @@ -151,6 +155,84 @@ describe("computer proxy (fake box)", () => { expect(screenshot.inputSchema.properties.region).toBeTruthy(); }); + it("wait_for publishes a flat schema and answers bad input with guidance, not a box call", async () => { + rpc({ jsonrpc: "2.0", id: 70, method: "tools/list" }); + const list = await waitFor(70); + const tool = list.result.tools.find((t: any) => t.name === "wait_for"); + expect(tool.inputSchema.properties.condition.enum).toEqual([ + "http_ready", + "tcp_ready", + "output_matches", + "file_exists", + ]); + // composition keywords do not survive provider schema conversion (#544) + expect(JSON.stringify(tool.inputSchema)).not.toMatch(/"(oneOf|anyOf|allOf|const|format)":/); + + const before = commands.length; + rpc({ jsonrpc: "2.0", id: 71, method: "tools/call", params: { name: "wait_for", arguments: { condition: "http_ready" } } }); + const missing = await waitFor(71); + expect(missing.result.isError).toBe(true); + expect(missing.result.content[0].text).toContain('"url"'); + rpc({ jsonrpc: "2.0", id: 72, method: "tools/call", params: { name: "wait_for", arguments: { condition: "every_5_minutes" } } }); + const unknown = await waitFor(72); + expect(unknown.result.isError).toBe(true); + expect(unknown.result.content[0].text).toContain("http_ready"); + expect(commands.length).toBe(before); // guidance is free + }); + + it("wait_for runs the whole poll box-side in ONE round trip and returns the settled frame", async () => { + hash = "wait1111"; + const before = commands.length; + rpc({ + jsonrpc: "2.0", + id: 73, + method: "tools/call", + params: { + name: "wait_for", + arguments: { condition: "http_ready", url: "http://localhost:3000/health", timeout_seconds: 90 }, + }, + }); + const res = await waitFor(73); + expect(commands.length - before).toBe(1); + const command = commands.at(-1)!; + if (process.platform !== "win32") expect(spawnSync("/bin/bash", ["-n", "-c", command]).status).toBe(0); + expect(command).toContain("'http://localhost:3000/health'"); + expect(command).toContain("END=$((SECONDS+90))"); + expect(command).toMatch(/while \[ \$SECONDS -lt \$END \]/); + expect(res.result.content[0].text).toContain("condition met"); + expect(res.result.content[1].type).toBe("image"); + hash = "aaaa1111"; // restore: later tests assert their own frame changes + }); + + it("wait_for reports a timeout as advice, and builds sound shells for every condition", async () => { + rpc({ + jsonrpc: "2.0", + id: 74, + method: "tools/call", + params: { name: "wait_for", arguments: { condition: "tcp_ready", port: 5432, observe: false, timeout_seconds: 5 } }, + }); + const timedOut = await waitFor(74); + expect(timedOut.result.isError).toBe(true); + expect(timedOut.result.content[0].text).toContain("timed out after 5s"); + expect(timedOut.result.content[0].text).toContain("computer_exec"); + + rpc({ + jsonrpc: "2.0", + id: 75, + method: "tools/call", + params: { + name: "wait_for", + arguments: { condition: "output_matches", command: "tail -1 /tmp/build.log", pattern: "done|failed", observe: false }, + }, + }); + const matched = await waitFor(75); + expect(matched.result.content[0].text).toContain("condition met"); + const command = commands.at(-1)!; + if (process.platform !== "win32") expect(spawnSync("/bin/bash", ["-n", "-c", command]).status).toBe(0); + expect(command).toContain("grep -Eq"); + expect(command).toContain("done|failed"); + }); + it("clicks and returns the frame in ONE round trip, scaled box-side", async () => { const before = commands.length; rpc({ diff --git a/server/computer-proxy.ts b/server/computer-proxy.ts index 362d29626..8329c2702 100644 --- a/server/computer-proxy.ts +++ b/server/computer-proxy.ts @@ -641,6 +641,30 @@ const TOOLS = [ required: ["command"], }, }, + { + name: "wait_for", + description: + "Wait on the bot's cloud computer until a condition holds, then return — ONE call instead of polling with repeated computer_exec or screenshot calls while a server boots or a job finishes. Give only the fields your chosen condition needs.", + inputSchema: { + type: "object", + properties: { + condition: { + type: "string", + enum: ["http_ready", "tcp_ready", "output_matches", "file_exists"], + description: + "http_ready = a URL answers; tcp_ready = a local port accepts; output_matches = a command's output matches a pattern; file_exists = a path appears", + }, + url: { type: "string", description: "http_ready only: the URL to poll, e.g. http://localhost:3000" }, + port: { type: "integer", description: "tcp_ready only: the local TCP port, e.g. 5432" }, + command: { type: "string", description: "output_matches only: shell command whose combined output is checked each poll" }, + pattern: { type: "string", description: "output_matches only: extended regex the output must match, e.g. ready|listening" }, + path: { type: "string", description: "file_exists only: absolute path on the computer" }, + timeout_seconds: { type: "integer", description: "give up after this many seconds; default 60, max 240" }, + ...OBSERVE_PROPS, + }, + required: ["condition"], + }, + }, { name: "open_url", description: @@ -990,6 +1014,60 @@ async function call(id: unknown, name: string, args: any) { const shot = await runOnBox([ENV, GEOMETRY, ensureRemoteCuaCommand(), captureBlock()].join("; "), 60_000); return observed(id, note, await frameFrom(shot)); } + if (name === "wait_for") { + const condition = String(args.condition ?? "").trim().toLowerCase(); + const timeout = Math.min(Math.max(Math.trunc(Number(args.timeout_seconds) || 60), 1), 240); + let check = ""; + let label = ""; + if (condition === "http_ready") { + const url = String(args.url ?? "").trim(); + if (!/^https?:\/\//i.test(url)) { + return text(id, 'http_ready needs "url", e.g. {"condition":"http_ready","url":"http://localhost:3000"}.', true); + } + check = `[ "$(curl -s -o /dev/null -m 2 -w '%{http_code}' ${shellQuote(url)})" != "000" ]`; + label = url; + } else if (condition === "tcp_ready") { + const portNumber = Math.trunc(Number(args.port)); + if (!Number.isInteger(portNumber) || portNumber < 1 || portNumber > 65535) { + return text(id, 'tcp_ready needs "port" 1-65535, e.g. {"condition":"tcp_ready","port":5432}.', true); + } + check = `(echo > /dev/tcp/127.0.0.1/${portNumber}) 2>/dev/null`; + label = `port ${portNumber}`; + } else if (condition === "output_matches") { + const probe = String(args.command ?? "").slice(0, 2000); + const pattern = String(args.pattern ?? "").slice(0, 500); + if (!probe.trim() || !pattern.trim()) { + return text(id, 'output_matches needs "command" and "pattern", e.g. {"condition":"output_matches","command":"tail -1 /tmp/build.log","pattern":"done|failed"}.', true); + } + check = `bash -c ${shellQuote(probe)} 2>&1 | grep -Eq ${shellQuote(pattern)}`; + label = `/${pattern}/ in ${probe.slice(0, 80)}`; + } else if (condition === "file_exists") { + const target = String(args.path ?? "").trim(); + if (!target.startsWith("/")) { + return text(id, 'file_exists needs an absolute "path", e.g. {"condition":"file_exists","path":"/tmp/render.done"}.', true); + } + check = `[ -e ${shellQuote(target)} ]`; + label = target; + } else { + return text(id, 'wait_for needs "condition": http_ready, tcp_ready, output_matches, or file_exists.', true); + } + // The whole wait runs box-side in ONE round trip — a 2s poll loop, then + // (by default) the settled screen — instead of the model burning an + // inference per poll while a server boots or a job finishes. + const loop = `MET=no; END=$((SECONDS+${timeout})); while [ $SECONDS -lt $END ]; do if ${check}; then MET=yes; break; fi; sleep 2; done; echo "WAIT $MET ELAPSED $SECONDS"`; + observations.noteAction(); + const observe = wantsFrame(args); + const out = observe + ? await runOnBox([ENV, GEOMETRY, loop, ensureRemoteCuaCommand(), captureBlock(settleOf(args))].join("; "), (timeout + 60) * 1000) + : await runOnBox(loop, (timeout + 30) * 1000); + const met = /WAIT yes/.test(out.stdout); + const elapsed = out.stdout.match(/ELAPSED (\d+)/)?.[1]; + const note = met + ? `condition met: ${label}${elapsed ? ` (~${elapsed}s)` : ""}` + : `timed out after ${timeout}s waiting for ${label} — it may not be coming up; inspect with computer_exec (logs, process list) before waiting again.`; + if (!observe) return text(id, note, !met); + return observed(id, note, await frameFrom(out)); + } if (name === "open_url") { const url = String(args.url ?? ""); const normalized = normalizeBrowserUrl(url); diff --git a/server/index.ts b/server/index.ts index 269fbc61e..6e1049986 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1092,7 +1092,7 @@ bus.subscribe((event: RuntimeEvent) => { // computer tools can change the screen, and each capture competes // with the agent for the box's command endpoint, so a bot grinding // through file edits must not trigger one per tool. - if (bot && /computer|screenshot|click|type_text|press_key|scroll|open_url/i.test(toolName)) { + if (bot && /computer|screenshot|click|type_text|press_key|scroll|open_url|wait_for/i.test(toolName)) { pokeScreenPoller(bot.id); } }