From 0690ea47a883e0f739ee7ade7260454d747f9f08 Mon Sep 17 00:00:00 2001 From: liyb Date: Sun, 11 Oct 2026 22:09:44 +0800 Subject: [PATCH 1/2] feat: synchronous run results through the bridge and CLI; remove health (0.3.3) - bridge: when the server answers running, wait locally (sandbase_run_get every 3s, MCP progress notifications) and return the finished result under the agent's request id. Long waits for Claude Code and Codex configured with a long tool timeout; bounded 50s run_get waits elsewhere. tools/call runs concurrently; notifications/cancelled and stdin close stop waits. - Codex config: tool_timeout_sec = 1800 + SANDBASE_LONG_TOOL_CALLS=1. - CLI run / runs get wait by default (--no-wait), print the run id on stderr, keep waiting through file transfer, drop stale running hints. - Remove the HEALTH column, Health line and Skill section 7b. - Skill/README updated; version 0.3.3. --- README.md | 8 +- assets/mcp-bridge.mjs | 168 +++++++++++++++++++++++++++++++-- package-lock.json | 4 +- package.json | 2 +- server.json | 4 +- skills/sandbase/SKILL.md | 38 ++------ skills/sandbase/skill.json | 2 +- src/adapters/index.ts | 6 +- src/agent/client.ts | 2 +- src/agent/commands.ts | 69 ++++++-------- test/adapters.test.ts | 8 ++ test/agent.test.ts | 184 +++++++++++++++++++++++++++++++++---- 12 files changed, 390 insertions(+), 105 deletions(-) diff --git a/README.md b/README.md index 28dc2cf..10156ad 100644 --- a/README.md +++ b/README.md @@ -284,14 +284,14 @@ Agents without MCP, or with a broken MCP session, use the same catalog from a sh sandbase discover -q "twitter posts" # search; -t api|llm|multimodal|embedding|service sandbase inspect sandbase_twitter_web_search_timeline # fields, price, ready-to-run command sandbase run sandbase_twitter_web_search_timeline -i '{"keyword":"AI agents"}' -sandbase run -i '{"prompt":"..."}' -w -o result.json # wait with backoff, save output to disk -sandbase runs get -r -w -o result.json # resume a long run instead of re-running it +sandbase run -i '{"prompt":"..."}' -o result.json # waits for the result, saves output to disk +sandbase runs get -r -o result.json # resume a long run instead of re-running it sandbase runs list | sandbase balance ``` -`-j` prints JSON; errors are `{"error":{"code","message","hint"}}` with the next step in `hint`. Exit codes: `0` success or still running, `1` failure (including a failed run), `2` usage. +`run` and `runs get` wait for the result (`--timeout 300` by default, `--no-wait` to return right after starting). `-j` prints JSON; errors are `{"error":{"code","message","hint"}}` with the next step in `hint`. Exit codes: `0` success or still running, `1` failure (including a failed run), `2` usage. -If `api.sandbase.ai` is unreachable, the CLI and the MCP bridge fall back to `www.sandbase.ai` automatically, and the bridge keeps the SandBase tools visible with an actionable error instead of dropping the server. +Through MCP, `sandbase_run` also returns the finished result in the same call. In Claude Code and Codex the bridge keeps waiting for slow jobs (video, large scrapes) and reports progress; other clients get the result from `sandbase_run_get`. If `api.sandbase.ai` is unreachable, the bridge keeps the SandBase tools visible with an actionable error instead of dropping the server. ### MCP Tools (available to your agent after connecting) diff --git a/assets/mcp-bridge.mjs b/assets/mcp-bridge.mjs index 848bef7..2755766 100644 --- a/assets/mcp-bridge.mjs +++ b/assets/mcp-bridge.mjs @@ -22,6 +22,21 @@ const RECONNECT = "npx -y @sandbaseai/cli@latest connect"; // Tool calls get no bridge-side deadline (long synchronous tools must be able to // finish); control-plane methods are bounded so offline detection stays quick. const CONTROL_TIMEOUT_MS = 60_000; +// Runs are synchronous for the agent: the server waits up to ~50s per call, and +// when a job is still going the bridge keeps waiting locally so the agent gets +// the finished output instead of a run id to poll. Clients that allow long tool +// calls (Claude Code; Codex with the tool_timeout_sec that connect writes) wait +// in one call; others get bounded waits that stay under their 60s tool timeout. +const RUN_POLL_MS = 3_000; +const LONG_RUN_WAIT_MS = 20 * 60_000; +const BOUNDED_RUN_WAIT_MS = 50_000; +const MAX_POLL_FAILURES = 5; +// Claude Code allows very long MCP tool calls by default. Codex only does when +// its config raises tool_timeout_sec, which `connect` writes together with +// SANDBASE_LONG_TOOL_CALLS=1 — so an older Codex config keeps bounded waits. +function allowsLongToolCalls(client, env) { + return client === "claude-code" || (client === "codex" && env.SANDBASE_LONG_TOOL_CALLS === "1"); +} // Used only when SandBase is unreachable and no tools/list was ever cached. const offlineTools = [ @@ -92,6 +107,46 @@ function httpFailure(status, text, host) { if (status >= 500) return `SandBase returned HTTP ${status} from ${host}${suffix}. Temporary server error: retry in a minute; the user can run \`sandbase doctor\` if it persists.`; return `SandBase MCP request failed (${status}) at ${host}${suffix}.${hintFor(detail) ? ` ${hintFor(detail)}` : ""}`; } +// Same rule as the CLI: only an explicit failure is failed; any status we do not +// know (IN_QUEUE, created, …) is still running, so the bridge keeps waiting. +const SUCCEEDED_STATUSES = new Set(["completed", "succeeded", "success", "done"]); +const FAILED_STATUSES = new Set(["failed", "failure", "error", "errored", "cancelled", "canceled", "timeout", "timed_out", "expired", "rejected", "blocked", "stopped"]); +function runPhase(status) { + const value = String(status ?? "").trim().toLowerCase(); + return SUCCEEDED_STATUSES.has(value) ? "succeeded" : FAILED_STATUSES.has(value) ? "failed" : "running"; +} +function structuredOf(payload) { const value = payload?.result?.structuredContent; return value && typeof value === "object" ? value : undefined; } +/** A run result the bridge should keep waiting on: still running, or completed while its files are still being copied. */ +function awaitingRun(payload) { + const data = structuredOf(payload); + if (!data || payload?.result?.isError) return false; + if (data.service_id || String(data.tool_name ?? data.name ?? "").startsWith("svc_")) return false; + const phase = runPhase(data.status); + if (phase === "running") return true; + return phase === "succeeded" && (data.output === undefined || data.output === null) && /still being transferred/i.test(JSON.stringify(data.hints ?? "")); +} +function runIdOf(payload) { const data = structuredOf(payload) || {}; const id = data.run_id ?? data.prediction_id ?? data.taskId; return typeof id === "string" && id ? id : undefined; } +function sleep(ms, signal) { + return new Promise(resolve => { + if (signal?.aborted) { resolve(); return; } + const timer = setTimeout(() => { signal?.removeEventListener("abort", stop); resolve(); }, ms); + const stop = () => { clearTimeout(timer); resolve(); }; + signal?.addEventListener("abort", stop, { once: true }); + }); +} +/** The finished run as the agent should see it: output as text, failures as isError. */ +function finalRunResult(id, payload, runId) { + const data = structuredOf(payload) || {}; + if (runPhase(data.status) === "failed") { + const error = data.error; + const detail = typeof error === "string" ? error : typeof error?.message === "string" ? error.message : ""; + return { jsonrpc: "2.0", id, result: { content: [{ type: "text", text: `Run ${runId} failed${detail ? `: ${detail}` : ""}. Read the error, fix the arguments with sandbase_inspect if needed, then retry once or choose another tool from sandbase_discover.` }], structuredContent: data, isError: true } }; + } + const output = data.output; + const text = output === undefined || output === null ? JSON.stringify(data) : typeof output === "string" ? output : JSON.stringify(output, null, 2); + return { jsonrpc: "2.0", id, result: { content: [{ type: "text", text }], structuredContent: data, isError: false } }; +} + function payloadsOf(text, contentType) { const out = []; if (!text.trim()) return out; @@ -122,6 +177,7 @@ export async function runMCPBridge(options = {}) { const signal = options.signal; const env = options.env || process.env; const fetchImpl = options.fetchImpl || globalThis.fetch; + const runPollMs = Number.isFinite(options.runPollMs) && options.runPollMs > 0 ? options.runPollMs : RUN_POLL_MS; if (!allowedClients.has(client)) throw bridgeError("SandBase bridge client is invalid."); const home = env.SANDBASE_HOME || join(homedir(), ".sandbase"); @@ -163,18 +219,93 @@ export async function runMCPBridge(options = {}) { output.write(JSON.stringify(payload) + "\n"); }; + const post = async (request, timeoutMs) => { + const headers = { authorization: `Bearer ${record.credential}`, "content-type": "application/json", accept: "application/json, text/event-stream" }; + if (session) headers["mcp-session-id"] = session; + const timeout = timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined; + const combined = signal && timeout && typeof AbortSignal.any === "function" ? AbortSignal.any([signal, timeout]) : signal || timeout; + return fetchImpl(url, { method: "POST", headers, body: JSON.stringify(request), ...(combined ? { signal: combined } : {}) }); + }; + + let pollSeq = 0; + /** One sandbase_run_get read: { payload } on an answer, { failed, fatal } on a transport/HTTP/protocol problem. */ + const readRun = async (runId, timeoutMs) => { + const request = { jsonrpc: "2.0", id: `sandbase-bridge-poll-${++pollSeq}`, method: "tools/call", params: { name: "sandbase_run_get", arguments: { run_id: runId } } }; + try { + const response = await post(request, Math.max(1_000, Math.min(CONTROL_TIMEOUT_MS, timeoutMs))); + const text = await response.text(); + if (!response.ok) return { failed: true, fatal: response.status === 401 || response.status === 403 }; + const payload = payloadsOf(text, response.headers.get("content-type") || "").find(item => item?.id === request.id && (item.result || item.error)); + return payload?.result ? { payload } : { failed: true }; + } catch { return { failed: true }; } + }; + + // One cancel handle per in-flight tools/call id, registered before the request + // is sent, so notifications/cancelled (or stdin closing) can stop it at any point. + const calls = new Map(); + const CANCELLED = Symbol("cancelled"); + + /** Keep a still-running run going until it has a result or the wait budget passes. */ + const awaitRun = async (request, initial, budgetMs, cancel) => { + const runId = runIdOf(initial); + if (!runId || budgetMs <= 0) return initial; + const progressToken = request.params?._meta?.progressToken; + const deadline = Date.now() + budgetMs; + const stop = signal && typeof AbortSignal.any === "function" ? AbortSignal.any([signal, cancel.signal]) : cancel.signal; + let latest = initial, progress = 0, failures = 0; + { + while (!stop.aborted && Date.now() + runPollMs <= deadline) { + await sleep(runPollMs, stop); + if (cancel.signal.aborted) return CANCELLED; + if (stop.aborted) break; + if (progressToken !== undefined) output.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/progress", params: { progressToken, progress: ++progress, message: `SandBase run ${runId} is still running` } }) + "\n"); + const read = await readRun(runId, deadline - Date.now()); + if (cancel.signal.aborted) return CANCELLED; + if (read.failed) { + // A revoked sign-in or a persistent outage must not turn into a silent + // multi-minute wait: give the agent the run id and stop. + if (read.fatal || ++failures >= MAX_POLL_FAILURES) break; + continue; + } + failures = 0; + latest = read.payload; + // A tool error while reading the run (e.g. run not found) goes to the agent as-is. + if (latest.result?.isError) return { ...latest, id: request.id }; + if (!awaitingRun(latest)) return finalRunResult(request.id, latest, runId); + } + } + // Still running when the budget ran out: return the latest status (with its + // run id and next step) under the agent's request id. + return { ...latest, id: request.id }; + }; + const longWait = allowsLongToolCalls(client, env); + const runWaitBudget = request => { + if (request.method !== "tools/call") return 0; + const name = request.params?.name; + const args = request.params?.arguments || {}; + if (name === "sandbase_run" && !String(args.name ?? "").startsWith("svc_")) return longWait ? LONG_RUN_WAIT_MS : 0; + if (name === "sandbase_run_get" && !args.service_id) return longWait ? LONG_RUN_WAIT_MS : BOUNDED_RUN_WAIT_MS; + return 0; + }; + const forward = async line => { let request; try { request = JSON.parse(line); } catch { return; } if (!request || typeof request !== "object" || Array.isArray(request)) return; const idempotent = isIdempotentRequest(request); + // A cancelled request stops its local wait; the notification still goes to the server. + if (request.method === "notifications/cancelled") calls.get(request.params?.requestId)?.abort(); + const tracked = request.method === "tools/call" && request.id !== undefined; + const cancel = new AbortController(); + if (tracked) calls.set(request.id, cancel); + try { await send(request, idempotent, cancel); } + finally { if (tracked && calls.get(request.id) === cancel) calls.delete(request.id); } + }; + + const send = async (request, idempotent, cancel) => { let response; try { - const headers = { authorization: `Bearer ${record.credential}`, "content-type": "application/json", accept: "application/json, text/event-stream" }; - if (session) headers["mcp-session-id"] = session; - const timeout = request.method === "tools/call" ? undefined : AbortSignal.timeout(CONTROL_TIMEOUT_MS); - const combined = signal && timeout && typeof AbortSignal.any === "function" ? AbortSignal.any([signal, timeout]) : signal || timeout; - response = await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(request), ...(combined ? { signal: combined } : {}) }); + response = await post(request, request.method === "tools/call" ? undefined : CONTROL_TIMEOUT_MS); } catch (error) { if (signal?.aborted) return; const reason = describeNetworkFailure(error); @@ -198,7 +329,24 @@ export async function runMCPBridge(options = {}) { output.write(request.method === "tools/call" ? toolError(request.id, message) : responseError(request.id, message)); return; } - for (const payload of payloadsOf(text, response.headers.get("content-type") || "")) await deliver(request, payload); + const budget = runWaitBudget(request); + for (let payload of payloadsOf(text, response.headers.get("content-type") || "")) { + // MCP: no response is sent for a request the client cancelled. + if (cancel.signal.aborted && payload?.id === request.id) continue; + if (budget > 0 && payload?.id === request.id && awaitingRun(payload)) payload = await awaitRun(request, payload, budget, cancel); + if (payload === CANCELLED) continue; + await deliver(request, payload); + } + }; + // Tool calls run concurrently (each answers by its own id), so a long run never + // blocks pings, other tools, or the client's cancellation of that run. + const inflight = new Set(); + const dispatch = async line => { + let method; + try { method = JSON.parse(line)?.method; } catch { /* forward() ignores it */ } + if (method !== "tools/call") { await forward(line); return; } + const task = forward(line).catch(() => {}).finally(() => inflight.delete(task)); + inflight.add(task); }; const stopInput = () => { if (typeof input.destroy === "function") input.destroy(); }; @@ -212,11 +360,15 @@ export async function runMCPBridge(options = {}) { if (split < 0) break; const line = buffer.subarray(0, split).toString("utf8").trim(); buffer = buffer.subarray(split + 1); - if (line) await forward(line); + if (line) await dispatch(line); } } const finalLine = buffer.toString("utf8").trim(); - if (!signal?.aborted && finalLine) await forward(finalLine); + if (!signal?.aborted && finalLine) await dispatch(finalLine); + // The client closed stdin: nobody can receive a long wait's answer, so stop + // local waits now; requests already sent finish within their server budget. + for (const cancel of calls.values()) cancel.abort(); + await Promise.allSettled([...inflight]); } catch (error) { if (!signal?.aborted) throw error; } finally { signal?.removeEventListener("abort", stopInput); } diff --git a/package-lock.json b/package-lock.json index ff85080..832022a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@sandbaseai/cli", - "version": "0.3.2", + "version": "0.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@sandbaseai/cli", - "version": "0.3.2", + "version": "0.3.3", "license": "Apache-2.0", "dependencies": { "jsonc-parser": "3.3.1", diff --git a/package.json b/package.json index ae88804..1ef45f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sandbaseai/cli", - "version": "0.3.2", + "version": "0.3.3", "mcpName": "io.github.sandbaseai/cli", "description": "Give 25 AI clients—Claude Code, Codex, Cursor, Hermes, OpenClaw and more—2,000+ models and APIs through one MCP server and an agent-friendly CLI", "type": "module", diff --git a/server.json b/server.json index 1755a6b..6bfc5c6 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.sandbaseai/cli", "title": "SandBase", "description": "Connect MCP clients to 2,000+ AI models without managing provider API keys.", - "version": "0.3.2", + "version": "0.3.3", "repository": { "url": "https://github.com/sandbaseai/cli", "source": "github" @@ -26,7 +26,7 @@ { "registryType": "npm", "identifier": "@sandbaseai/cli", - "version": "0.3.2", + "version": "0.3.3", "packageArguments": [ { "type": "positional", diff --git a/skills/sandbase/SKILL.md b/skills/sandbase/SKILL.md index 4b3ef1e..32078c6 100644 --- a/skills/sandbase/SKILL.md +++ b/skills/sandbase/SKILL.md @@ -1,6 +1,6 @@ --- name: sandbase -version: 0.3.2 +version: 0.3.3 description: >- Discover better ways to complete tasks. Proactively search SandBase (the sandbase_discover MCP tool, or `sandbase discover` in a shell) before writing a @@ -58,7 +58,7 @@ Discovery, inspection, run status, and balance are **free**; only `sandbase_run` | `sandbase_discover` | Search the catalog in natural language (`q`), optionally filtered by `type` (`llm`, `api`, `multimodal`, `embedding`, `service`) or `vendor` | | `sandbase_inspect` | Input schema (`inputSchema`), pricing, and an `execute_as` template for one tool | | `sandbase_run` | Execute a tool (**costs money**) | -| `sandbase_run_get` | Status, cost, and output of a run — poll with this | +| `sandbase_run_get` | Status, cost, and output of a run; through the bridge it waits for a run that is still going | | `sandbase_runs` | Recent runs with cost | | `sandbase_account` | Balance | | `sandbase_run_cancel` | Cancel an Agent Service run | @@ -67,10 +67,8 @@ Discovery, inspection, run status, and balance are **free**; only `sandbase_run` 1. `sandbase_discover({"q": "twitter posts"})` → pick a `tool_name` from `data` (each result carries `description`, `pricing`, `vendor`, `run_count`). 2. `sandbase_inspect({"name": "sandbase_twitter_web_search_timeline"})` → read `inputSchema` and `pricing`; copy `execute_as`. -3. `sandbase_run({"name": "sandbase_twitter_web_search_timeline", "arguments": {"keyword": "AI agents"}})`. - - Fast tools return `status: "completed"` with `output` directly. - - Slow tools (video, music, large scrapes) return `status: "running"` plus `prediction_id` / `taskId` after up to ~20 s. -4. If still running, call `sandbase_run_get({"run_id": ""})` every 5–10 seconds until `status` is `completed` or `failed`. The finished `output` is in the response. If a completed run shows no `output`, fetch it with the CLI: `sandbase runs get -r -o result.json`. +3. `sandbase_run({"name": "sandbase_twitter_web_search_timeline", "arguments": {"keyword": "AI agents"}})` waits for the result and returns `status: "completed"` with `output` in the same call. Most tools, including image generation, finish within that wait. In Claude Code and Codex the SandBase bridge keeps waiting for slow jobs too (video, music, large scrapes). Do not call it again while it is working. +4. If it returns `status: "running"` (a slow job on a client that limits tool calls to about a minute), call `sandbase_run_get({"run_id": ""})` — through the bridge it waits up to ~50 s and returns the `output` once ready. Repeat until `completed` or `failed`; never call `sandbase_run` again for the same job (that starts and bills a new run). **Agent Services** (`svc_…` names from `type: "service"`): pass `arguments: {"idempotency_key": "", "input": "..."}`; reuse the same key and input on retries. Poll with `sandbase_run_get({"run_id": ..., "service_id": "svc_…"})`; `succeeded`, `failed`, and `cancelled` are terminal. @@ -90,18 +88,18 @@ If `sandbase` is not installed, prefix every command with `npx -y https://github |---|---|---| | Search | `sandbase_discover` | `sandbase discover -q "" [-t api] [-l 10]` | | Schema + price | `sandbase_inspect` | `sandbase inspect ` | -| Execute | `sandbase_run` | `sandbase run -i '' [-w] [-o result.json]` (or `-f input.json`) | -| Poll / fetch result | `sandbase_run_get` | `sandbase runs get -r [-w] [-o result.json]` | +| Execute (waits for the result) | `sandbase_run` | `sandbase run -i '' [-o result.json]` (or `-f input.json`) | +| Resume / fetch result | `sandbase_run_get` | `sandbase runs get -r [-o result.json]` | | Recent runs | `sandbase_runs` | `sandbase runs list` | | Balance | `sandbase_account` | `sandbase balance` | | Diagnose connectivity | — | `sandbase doctor` | | **Save output to a file** | not available | `-o result.json` on `run` / `runs get` | - Add `-j` for JSON output. Errors then look like `{"error": {"code", "message", "hint"}}`; follow `hint`. -- `-w` waits with backoff (default `--timeout 300` seconds). If the run is still going when the timeout elapses, the CLI prints the `runs get` command to resume — use it rather than re-running (re-running is billed again). +- `run` and `runs get` wait for the result (default `--timeout 300` seconds; `--no-wait` returns right after starting). The run id is printed to stderr as soon as the run starts. If the run is still going when the timeout elapses — or your shell command times out — resume with `sandbase runs get -r ` rather than re-running (re-running is billed again). Give long media runs a shell timeout of a few minutes. - `-o` writes only the tool's output (no envelope). Files/URLs in the output are also listed in text mode. - Exit code is `0` on success or still-running, `1` on failure (including a failed run), `2` on bad usage. -- If an error mentions a `run_id`, the run already started: resume it with `sandbase runs get -r -w` — never re-run (that bills again). +- If an error mentions a `run_id`, the run already started: resume it with `sandbase runs get -r ` — never re-run (that bills again). - To store an API key without exposing it: `printf %s "$KEY" | sandbase keys add -k -`. --- @@ -138,7 +136,7 @@ Do not use SandBase for purely local work (editing files, reasoning over code al - `inspect` shows the price: per call (`base_price`), per million tokens (`input_per_million` / `output_per_million`), or a `formula`. - Many data endpoints are billed **per result** and limits often apply **per query**: three search terms with `limit: 10` can return 30 results. Pass one search term per call and start with small limits (5–10). - Check `sandbase_account` before a batch of paid calls, and report cost when the user cares about budget (`cost` appears on `sandbase_run_get` and `sandbase_runs`). -- Never re-run a slow tool just because it is still running; poll it. +- Never re-run a slow tool just because it is still running; wait for it with `sandbase_run_get`. --- @@ -158,22 +156,6 @@ Tool failures come back as error results whose text names the cause **and the ne --- -## 7b. Health - -`discover` and `inspect` may include a `metrics` block per tool: `status` (healthy / stable / degraded / outage / unknown) plus `run_time_ms` (p50 and p95). The CLI shows a HEALTH column in discover and a Health line in inspect. - -| Status | Meaning | -|---|---| -| `healthy` | Confirmed working in the last few minutes | -| `stable` | No very recent sample, but a strong 24h record | -| `degraded` | Elevated failures — usually still works | -| `outage` | Not working right now | -| `unknown` *(or missing)* | Not enough data — never a warning | - -**Use health to break ties, never to filter.** Between two tools that both fit, prefer the healthier and faster one. Never skip a tool because its status is unknown or absent — that just means low traffic. Metrics are a platform-wide aggregate and can be briefly missing on a given call; if you need it, call again. - ---- - ## 8. Hints Responses may include `hints` (and `warnings`): suggested next steps, caveats, or alternatives from the server. Read them before deciding your next move and prefer them over guessing. @@ -187,7 +169,7 @@ Responses may include `hints` (and `warnings`): suggested next steps, caveats, o 3. **Prefer MCP when connected**; use the CLI when MCP is missing or broken, or to write large outputs to disk. 4. **Always inspect before running.** Never guess parameters; `inputSchema` is the source of truth. 5. **Keep discover queries short** and decompose multi-source tasks. -6. **Poll, don't re-run.** Slow tools return a run id; poll every 5–10 seconds. +6. **Run once, then wait.** `sandbase_run` returns the result; if a very long job comes back `running`, wait with `sandbase_run_get` — never re-run. 7. **Start with conservative limits** (5–10 results). 8. **Report cost when relevant**, and check the balance before batches. 9. **Read error text and hints, and act on them.** They name the fix. diff --git a/skills/sandbase/skill.json b/skills/sandbase/skill.json index 440b3fe..d36b234 100644 --- a/skills/sandbase/skill.json +++ b/skills/sandbase/skill.json @@ -1,6 +1,6 @@ { "name": "sandbase", - "version": "0.3.2", + "version": "0.3.3", "description": "Discover and run 2,000+ AI models and APIs (media generation, social data, search, scraping) through one MCP interface or CLI, paid per call from one balance.", "author": "SandBase AI", "license": "Apache-2.0", diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 2f87a4a..10e4a5d 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -27,7 +27,11 @@ export function detectClient(client: Client): {installed:boolean; detail:string} if (!profile.executable) return profile.mode === "auto" && existsSync(path) ? {installed:true,detail:"existing configuration"} : {installed:false,detail:"not detected"}; const result=spawnSync(profile.executable,["--version"],{encoding:"utf8",timeout:5000}); const detail=(result.stdout || result.stderr || "").trim().split("\n")[0] || "not found"; if (result.status===0) return {installed:true,detail}; if (client === "codex" && existsSync(path)) return {installed:true,detail:"Codex CLI probe failed; existing Codex configuration detected"}; return {installed:false,detail}; } const start = "# >>> sandbase managed >>>", end = "# <<< sandbase managed <<<"; function block(client: Client, bridge: string): string { - if (client === "codex") return `${start}\n[mcp_servers.sandbase]\ncommand = "node"\nargs = ["${bridge.replaceAll("\\", "\\\\").replaceAll('"','\\"')}", "--client", "codex"]\nenv = { SANDBASE_CLI_MANAGED = "1" }\n${end}`; + // tool_timeout_sec: Codex cancels tool calls after 60s by default; the bridge + // waits for long runs (video, large scrapes) so they return their output. + // SANDBASE_LONG_TOOL_CALLS tells the bridge this config carries the long timeout + // (a bridge refreshed by `setup` under an older config keeps bounded waits). + if (client === "codex") return `${start}\n[mcp_servers.sandbase]\ncommand = "node"\nargs = ["${bridge.replaceAll("\\", "\\\\").replaceAll('"','\\"')}", "--client", "codex"]\ntool_timeout_sec = 1800\nenv = { SANDBASE_CLI_MANAGED = "1", SANDBASE_LONG_TOOL_CALLS = "1" }\n${end}`; return ` ${start}\n sandbase:\n command: node\n args:\n - ${JSON.stringify(bridge)}\n - --client\n - ${client}\n ${end}`; } function managedText(raw: string, value: string): string { const re = new RegExp(`${start.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}[\\s\\S]*?${end.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}`, "m"); return re.test(raw) ? raw.replace(re, value) : raw.replace(/\s*$/, "") + (raw.trim() ? "\n\n" : "") + value + "\n"; } diff --git a/src/agent/client.ts b/src/agent/client.ts index 6a3cedf..ef2d187 100644 --- a/src/agent/client.ts +++ b/src/agent/client.ts @@ -162,7 +162,7 @@ export class SandBaseMCPClient { const reason = describeNetworkFailure(error); this.attempts.push({ url, outcome: reason }); if (!isIdempotentRequest(method, params) && !isPreSendFailure(error)) { // The request may already be running server-side; blindly retrying would bill twice. - throw new SandBaseError("NETWORK", `The request to ${host} was interrupted after it was sent (${reason}); it may have started a run.`, "Do not re-run yet. Check `sandbase runs list` for the run and poll it with `sandbase runs get -r -w`."); + throw new SandBaseError("NETWORK", `The request to ${host} was interrupted after it was sent (${reason}); it may have started a run.`, "Do not re-run yet. Check `sandbase runs list` for the run and wait for it with `sandbase runs get -r `."); } throw new SandBaseError("NETWORK", `Cannot reach SandBase at ${host} (${reason}).`, UNREACHABLE_HINT); } diff --git a/src/agent/commands.ts b/src/agent/commands.ts index 39bbd2c..7b8594c 100644 --- a/src/agent/commands.ts +++ b/src/agent/commands.ts @@ -19,8 +19,8 @@ export interface AgentDeps { env?: NodeJS.ProcessEnv; fetchImpl?: FetchLike; out export const agentUsage = `SandBase agent commands (same capabilities as the sandbase_* MCP tools): sandbase discover -q "" [-t llm|api|multimodal|embedding|service] [--vendor ] [-l 10] [-j] sandbase inspect [-j] - sandbase run [-i '' | -f input.json] [-w] [--timeout 300] [-o result.json] [-j] - sandbase runs get -r [--service ] [-w] [--timeout 300] [-o result.json] [-j] + sandbase run [-i '' | -f input.json] [--timeout 300] [--no-wait] [-o result.json] [-j] + sandbase runs get -r [--service ] [--timeout 300] [--no-wait] [-o result.json] [-j] sandbase runs list [-l 10] [--model ] [--service ] [-j] sandbase balance [-j] sandbase keys add -k - (reads the key from stdin) | keys list | keys remove @@ -47,6 +47,8 @@ class Output { else this.err(`Error [${error.code}]: ${error.message}${error.runId ? `\nRun: ${error.runId}` : ""}${error.hint ? `\nNext: ${error.hint}` : ""}`); } notice(text: string): void { if (this.json) this.notes.push(text); else this.err(text); } + /** Always stderr (also in -j mode), so stdout stays parseable and an agent whose shell times out still sees it. */ + progress(text: string): void { this.err(text); } } function truncate(value: string, width: number): string { const flat = value.replace(/\s+/g, " ").trim(); return flat.length > width ? `${flat.slice(0, width - 1)}…` : flat; } @@ -79,29 +81,6 @@ function collectUrls(value: unknown, found = new Set()): Set { return found; } function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } -/** Verbose health line for inspect, e.g. "stable · 3.5s typical, 27.7s tail · 75 samples". */ -export function healthLine(metrics: unknown): string { - if (!metrics || typeof metrics !== "object") return ""; - const m = metrics as { status?: unknown; run_time_ms?: { p50?: unknown; p95?: unknown }; sample_size?: unknown }; - const status = typeof m.status === "string" ? m.status : ""; - if (!status) return ""; - const parts = [status]; - const p50 = m.run_time_ms && typeof m.run_time_ms.p50 === "number" ? m.run_time_ms.p50 : undefined; - const p95 = m.run_time_ms && typeof m.run_time_ms.p95 === "number" ? m.run_time_ms.p95 : undefined; - if (p50 !== undefined) parts.push(p95 !== undefined ? `${(p50 / 1000).toFixed(1)}s typical, ${(p95 / 1000).toFixed(1)}s tail` : `${(p50 / 1000).toFixed(1)}s typical`); - if (typeof m.sample_size === "number") parts.push(`${m.sample_size} samples`); - return parts.join(" · "); -} -/** Compact health cell for the discover table, e.g. "healthy 4.2s" or "stable". Blank when absent. */ -export function formatHealth(metrics: unknown): string { - if (!metrics || typeof metrics !== "object") return ""; - const m = metrics as { status?: unknown; run_time_ms?: { p50?: unknown } }; - const status = typeof m.status === "string" ? m.status : ""; - const p50 = m.run_time_ms && typeof m.run_time_ms.p50 === "number" ? m.run_time_ms.p50 : undefined; - if (!status) return ""; - return p50 !== undefined ? `${status} ${(p50 / 1000).toFixed(1)}s` : status; -} - type OptionSpec = Record; const commonOptions = { json: { type: "boolean", short: "j" }, client: { type: "string" }, help: { type: "boolean", short: "h" } } as const; function parse(argv: string[], options: T) { @@ -141,7 +120,7 @@ async function assertWritableOutput(path: string | undefined): Promise { /** After a run was accepted, every failure must still tell the agent how to resume it instead of re-running (and paying) again. */ function withRunContext(error: unknown, view: RunView): SandBaseError { - const resume = `sandbase runs get -r ${view.run_id}${view.service_id ? ` --service ${view.service_id}` : ""} -w`; + const resume = `sandbase runs get -r ${view.run_id}${view.service_id ? ` --service ${view.service_id}` : ""}`; if (error instanceof SandBaseError) return new SandBaseError(error.code, error.message, `${error.hint ? `${error.hint} ` : ""}The run ${view.run_id} was already started; resume with \`${resume}\` instead of running it again.`, error.status, view.run_id); return new SandBaseError("TOOL_ERROR", error instanceof Error ? error.message : "Unknown failure", `The run ${view.run_id} was already started; resume with \`${resume}\`.`, undefined, view.run_id); } @@ -167,14 +146,19 @@ async function pollRun(client: SandBaseMCPClient, initial: RunView, timeoutSecon let view = initial, delay = 1000, emptyCompletions = 0; for (;;) { const phase = phaseOf(view.status); - // A completed media run can briefly report no output while its files are transferred. - if (phase === "failed" || (phase === "succeeded" && (hasOutput(view) || emptyCompletions >= 8))) return view; - if (phase === "succeeded") emptyCompletions++; + // A completed media run reports no output while its files are transferred: + // keep waiting for those until the timeout; other empty completions (e.g. no + // stored output) settle after a few reads. + const transferring = phase === "succeeded" && !hasOutput(view) && /still being transferred/i.test(JSON.stringify(view.hints ?? "")); + if (phase === "failed" || (phase === "succeeded" && !transferring && (hasOutput(view) || emptyCompletions >= 3))) return view; + if (phase === "succeeded" && !transferring) emptyCompletions++; if (deps.now() + delay > deadline) return { ...view, timed_out: true }; await deps.sleep(delay); delay = Math.min(Math.round(delay * 1.5), 10_000); const next = await readRun(client, initial.run_id, initial.service_id); const tool = view.tool ?? next.tool; - view = { ...view, ...next, ...(tool ? { tool } : {}) }; + // Hints describe the latest state only; a stale "still in progress" must not survive completion. + const { hints: _staleHints, ...rest } = view; + view = { ...rest, ...next, ...(tool ? { tool } : {}) }; } } @@ -191,7 +175,7 @@ function printRun(out: Output, view: RunView, outputFile?: string): void { if (outputFile) lines.push(`Saved output to ${outputFile}`); else { const text = typeof view.output === "string" ? view.output : JSON.stringify(view.output, null, 2); lines.push("Output:", text.length > 6000 ? `${text.slice(0, 6000)}\n…(truncated ${text.length - 6000} chars; rerun \`sandbase runs get -r ${view.run_id ?? ""} -o result.json\` to save everything)` : text); } } - if (phase === "running" && view.run_id) lines.push(`Next: sandbase runs get -r ${view.run_id}${view.service_id ? ` --service ${view.service_id}` : ""} -w${outputFile ? ` -o ${outputFile}` : ""} (poll every 5-10s; runs take 1-300s)`); + if (phase === "running" && view.run_id) lines.push(`Next: sandbase runs get -r ${view.run_id}${view.service_id ? ` --service ${view.service_id}` : ""}${outputFile ? ` -o ${outputFile}` : ""} (waits for the result; do not run the tool again)`); if (phase === "succeeded" && !hasOutput(view) && view.run_id) lines.push(`Output is not available yet. Retry: sandbase runs get -r ${view.run_id}${view.service_id ? ` --service ${view.service_id}` : ""}${outputFile ? ` -o ${outputFile}` : ""}`); out.print(lines.join("\n")); printHints(out, view.hints); @@ -226,8 +210,8 @@ async function discover(argv: string[], out: Output, env: NodeJS.ProcessEnv, fet const items = (Array.isArray(data.data) ? data.data : []) as Array>; if (out.json) { out.data(data); return 0; } if (!items.length) { out.print(`No tools matched "${q}". Try a shorter noun phrase, a synonym, or filter with -t.`); printHints(out, data.hints, data.warnings); return 0; } - const rows = items.map(item => [String(item.tool_name ?? item.name ?? item.id ?? ""), String(item.category ?? item.type ?? ""), String(item.vendor ?? item.vendor_slug ?? ""), formatPrice(item.pricing), formatHealth(item.metrics), truncate(String(item.description ?? ""), 60)]); - out.print([`Found ${items.length} tool${items.length === 1 ? "" : "s"}${q ? ` for "${q}"` : ""}:`, "", table(["TOOL", "TYPE", "VENDOR", "PRICE", "HEALTH", "DESCRIPTION"], rows), "", "Next: sandbase inspect (input schema, exact price, and a ready-to-run command)", "HEALTH is advisory: prefer healthy/stable and lower latency to break ties; blank/unknown means no data, not unhealthy."].join("\n")); + const rows = items.map(item => [String(item.tool_name ?? item.name ?? item.id ?? ""), String(item.category ?? item.type ?? ""), String(item.vendor ?? item.vendor_slug ?? ""), formatPrice(item.pricing), truncate(String(item.description ?? ""), 60)]); + out.print([`Found ${items.length} tool${items.length === 1 ? "" : "s"}${q ? ` for "${q}"` : ""}:`, "", table(["TOOL", "TYPE", "VENDOR", "PRICE", "DESCRIPTION"], rows), "", "Next: sandbase inspect (input schema, exact price, and a ready-to-run command)"].join("\n")); printHints(out, data.hints, data.warnings); return 0; } @@ -278,10 +262,8 @@ async function inspect(argv: string[], out: Output, env: NodeJS.ProcessEnv, fetc const priceLine = [formatPrice(pricing), typeof pricing.formula === "string" && pricing.formula ? `formula: ${pricing.formula}` : ""].filter(Boolean).join(" "); const meta = [data.model, data.category ?? data.type, data.vendor].filter(Boolean).join(" · "); const lines = [`${tool}${meta ? ` (${meta})` : ""}`, String(data.description ?? "").trim(), "", `Price: ${priceLine}`]; - const health = healthLine(data.metrics); - if (health) lines.push(`Health: ${health}`); lines.push("", rows.length ? `Input (send as JSON with -i):\n${table(["FIELD", "TYPE", "REQUIRED", "DESCRIPTION"], rows)}` : "Input: no parameters."); - lines.push("", "Run:", ` sandbase run ${tool} -i ${shellQuote(JSON.stringify(example))}${/video|image|audio|music|speech/i.test(`${data.category ?? ""} ${data.description ?? ""} ${tool}`) ? " -w -o result.json" : ""}`); + lines.push("", "Run:", ` sandbase run ${tool} -i ${shellQuote(JSON.stringify(example))}${/video|image|audio|music|speech/i.test(`${data.category ?? ""} ${data.description ?? ""} ${tool}`) ? " -o result.json" : ""}`); lines.push(`MCP: sandbase_run ${JSON.stringify({ name: tool, arguments: example })}`); out.print(lines.join("\n")); printHints(out, data.hints, data.notes); @@ -289,9 +271,11 @@ async function inspect(argv: string[], out: Output, env: NodeJS.ProcessEnv, fetc } async function run(argv: string[], out: Output, env: NodeJS.ProcessEnv, deps: Required>, fetchImpl?: FetchLike): Promise { - const { values, positionals } = parse(argv, { input: { type: "string", short: "i" }, file: { type: "string", short: "f" }, wait: { type: "boolean", short: "w" }, timeout: { type: "string" }, output: { type: "string", short: "o" } }); + // Runs wait for the result by default; --no-wait returns right after starting. -w is kept for compatibility. + const { values, positionals } = parse(argv, { input: { type: "string", short: "i" }, file: { type: "string", short: "f" }, wait: { type: "boolean", short: "w" }, "no-wait": { type: "boolean" }, timeout: { type: "string" }, output: { type: "string", short: "o" } }); const name = (positionals[0] ?? "").trim(); if (!name) throw new SandBaseError("USAGE", "Pass the tool name to run.", "Example: sandbase run sandbase_twitter_web_search_timeline -i '{\"keyword\":\"AI agents\"}'"); + const wait = !values["no-wait"]; if (values.input && values.file) throw new SandBaseError("USAGE", "Use either -i or -f, not both."); const timeout = positiveSeconds(values.timeout, 300); let args: Record = {}; @@ -314,7 +298,10 @@ async function run(argv: string[], out: Output, env: NodeJS.ProcessEnv, deps: Re if (typeof args.idempotency_key === "string" && view.service_id) view.idempotency_key = args.idempotency_key; if (!view.run_id) return finishRun(out, view, values.output); try { - if (values.wait && phaseOf(view.status) === "running") view = await pollRun(api, view, timeout, deps); + if (wait && (phaseOf(view.status) === "running" || (phaseOf(view.status) === "succeeded" && !hasOutput(view)))) { + out.progress(`Run ${view.run_id} started; waiting for the result (up to ${timeout}s). If interrupted, resume with: sandbase runs get -r ${view.run_id}${view.service_id ? ` --service ${view.service_id}` : ""}${values.output ? ` -o ${values.output}` : ""}`); + view = await pollRun(api, view, timeout, deps); + } else if (phaseOf(view.status) !== "running" && view.cost === undefined && !view.service_id) { try { const { data: detail } = await api.tool("sandbase_run_get", { run_id: view.run_id }); if (detail.cost !== undefined) view.cost = detail.cost; } catch { /* cost is informational */ } } @@ -325,15 +312,15 @@ async function run(argv: string[], out: Output, env: NodeJS.ProcessEnv, deps: Re async function runs(argv: string[], out: Output, env: NodeJS.ProcessEnv, deps: Required>, fetchImpl?: FetchLike): Promise { const [sub, ...rest] = argv; if (sub === "get") { - const { values, positionals } = parse(rest, { run: { type: "string", short: "r" }, service: { type: "string" }, wait: { type: "boolean", short: "w" }, timeout: { type: "string" }, output: { type: "string", short: "o" } }); + const { values, positionals } = parse(rest, { run: { type: "string", short: "r" }, service: { type: "string" }, wait: { type: "boolean", short: "w" }, "no-wait": { type: "boolean" }, timeout: { type: "string" }, output: { type: "string", short: "o" } }); const runId = (values.run ?? positionals[0] ?? "").trim(); - if (!runId) throw new SandBaseError("USAGE", "Pass the run id with -r.", "Example: sandbase runs get -r pred_abc123 -w -o result.json"); + if (!runId) throw new SandBaseError("USAGE", "Pass the run id with -r.", "Example: sandbase runs get -r pred_abc123 -o result.json"); const timeout = positiveSeconds(values.timeout, 300); await assertWritableOutput(values.output); const api = await client(values, env, fetchImpl); let view = await readRun(api, runId, values.service); try { - if (values.wait) view = await pollRun(api, view, timeout, deps); + if (!values["no-wait"]) view = await pollRun(api, view, timeout, deps); return await finishRun(out, view, values.output); } catch (error) { throw withRunContext(error, view); } } diff --git a/test/adapters.test.ts b/test/adapters.test.ts index beaae80..ca58141 100644 --- a/test/adapters.test.ts +++ b/test/adapters.test.ts @@ -17,6 +17,14 @@ for (const client of ["codex","claude-code","cursor","hermes"] as Client[]) test assert.equal(await unregister(client,env),true); const removed=await readFile(path,"utf8"); assert.ok(!removed.includes("safe bridge")); assert.ok(removed.includes("other") || removed.includes("model")); await rollback(one); assert.equal(await readFile(path,"utf8"),original); }); +test("Codex config raises the tool timeout and tells the bridge long waits are allowed", async () => { + const home=await mkdtemp(join(tmpdir(),"sandbase-codex-timeout-")); const env={HOME:home,CODEX_HOME:join(home,".codex")}; await mkdir(env.CODEX_HOME,{recursive:true}); + await configure("codex","/sandbase-mcp-bridge.mjs",env); + const text=await readFile(join(env.CODEX_HOME,"config.toml"),"utf8"); + assert.match(text,/^tool_timeout_sec = 1800$/m); assert.match(text,/SANDBASE_LONG_TOOL_CALLS = "1"/); + assert.equal(await isConfigured("codex",env),true); +}); + test("Codex and Hermes leave a pre-existing unowned sandbase entry untouched",async()=>{ for(const client of ["codex","hermes"] as Client[]){ const home=await mkdtemp(join(tmpdir(),"sandbase-existing-")); const env={HOME:home,CODEX_HOME:join(home,".codex"),HERMES_HOME:join(home,".hermes")}; const path=client==="codex"?join(env.CODEX_HOME,"config.toml"):join(env.HERMES_HOME,"config.yaml"); const original=client==="codex"?'[mcp_servers.sandbase]\ncommand="old"\n[mcp_servers.other]\ncommand="keep"\n':'mcp_servers:\n sandbase:\n command: old\n other:\n command: keep\n'; await mkdir(join(path,".."),{recursive:true}); await writeFile(path,original); await assert.rejects(configure(client,"/new",env),/invalid/); assert.equal(await readFile(path,"utf8"),original); }}); test("Claude Code and Cursor leave a pre-existing unowned sandbase key untouched",async()=>{ for(const client of ["claude-code","cursor"] as Client[]){ const home=await mkdtemp(join(tmpdir(),"sandbase-json-existing-")); const path=client==="claude-code"?join(home,".claude.json"):join(home,".cursor","mcp.json"); const original=JSON.stringify({theme:"keep",mcpServers:{sandbase:{command:"old"},other:{command:"keep"}}},null,2); await mkdir(join(path,".."),{recursive:true}); await writeFile(path,original); await assert.rejects(configure(client,"/new bridge",{HOME:home}),/invalid/); assert.equal(await readFile(path,"utf8"),original); }}); diff --git a/test/agent.test.ts b/test/agent.test.ts index 8e65fd1..18a591b 100644 --- a/test/agent.test.ts +++ b/test/agent.test.ts @@ -77,17 +77,46 @@ test("network and HTTP failures carry a code and a next step", async () => { assert.equal(JSON.parse(json.out).error.code, "NETWORK"); assert.doesNotMatch(json.out + json.err, /test-credential-value/); }); -test("discover shows a HEALTH column and inspect shows a health line", async () => { +test("discover and inspect show no health column or line", async () => { const env = await home(); await connected(env, "codex", "http://127.0.0.1:9/v1/mcp"); - const discover = fakeFetch(() => toolResult({ data: [{ tool_name: "sandbase_a", category: "api", vendor: "V", pricing: { base_price: "0.01" }, description: "x", metrics: { status: "stable", run_time_ms: { p50: 3543, p95: 27748 }, sample_size: 75 } }], count: 1 })); + const discover = fakeFetch(() => toolResult({ data: [{ tool_name: "sandbase_a", category: "api", vendor: "V", pricing: { base_price: "0.01" }, description: "x", metrics: { status: "stable" } }], count: 1 })); const found = await cli("discover", ["-q", "a"], env, discover.fetch); - assert.match(found.out, /HEALTH/); assert.match(found.out, /stable 3\.5s/); assert.match(found.out, /break ties/); - const inspect = fakeFetch(() => toolResult({ tool_name: "sandbase_a", description: "x", pricing: { base_price: "0.01" }, inputSchema: { type: "object", properties: {} }, metrics: { status: "stable", run_time_ms: { p50: 3543, p95: 27748 }, sample_size: 75 } })); - const detail = await cli("inspect", ["sandbase_a"], env, inspect.fetch); - assert.match(detail.out, /Health: stable · 3\.5s typical, 27\.7s tail · 75 samples/); - // No metrics -> no HEALTH noise beyond the (empty) column. - const bare = fakeFetch(() => toolResult({ tool_name: "sandbase_b", description: "y", pricing: {}, inputSchema: { type: "object", properties: {} } })); - assert.doesNotMatch((await cli("inspect", ["sandbase_b"], env, bare.fetch)).out, /Health:/); + assert.match(found.out, /TOOL\s+TYPE\s+VENDOR\s+PRICE\s+DESCRIPTION/); assert.doesNotMatch(found.out, /HEALTH|stable/); + const inspect = fakeFetch(() => toolResult({ tool_name: "sandbase_a", description: "x", pricing: { base_price: "0.01" }, inputSchema: { type: "object", properties: {} }, metrics: { status: "stable" } })); + assert.doesNotMatch((await cli("inspect", ["sandbase_a"], env, inspect.fetch)).out, /Health/); +}); + +test("run waits for the result by default and announces the run id on stderr; --no-wait returns right away", async () => { + const env = await home(); await connected(env, "codex", "http://127.0.0.1:9/v1/mcp"); + let polls = 0; + const { fetch } = fakeFetch(call => { + const name = (call.params as { name?: string } | undefined)?.name; + if (name === "sandbase_run") return toolResult({ prediction_id: "pred_d", status: "running", output: null }); + if (name === "sandbase_run_get") return toolResult(++polls < 3 ? { run_id: "pred_d", status: "running", hints: ["Run is still in progress."] } : { run_id: "pred_d", status: "completed", output: ["https://cdn.test/d.png"] }); + return { body: { error: { code: -32601, message: "unexpected" } } }; + }); + const waited = await cli("run", ["sandbase_img", "-i", "{}"], env, fetch); + assert.equal(waited.code, 0, waited.err); assert.match(waited.out, /Status: completed/); assert.match(waited.out, /https:\/\/cdn\.test\/d\.png/); + assert.doesNotMatch(waited.out, /still in progress/, "a stale running hint never survives completion"); + assert.match(waited.err, /Run pred_d started; waiting for the result.*sandbase runs get -r pred_d/); + polls = -100; + const quick = await cli("run", ["sandbase_img", "-i", "{}", "--no-wait"], env, fetch); + assert.equal(quick.code, 0); assert.match(quick.out, /Status: running/); assert.match(quick.out, /Next: sandbase runs get -r pred_d/); +}); + +test("run keeps waiting while a completed run's files are still being transferred", async () => { + const env = await home(); await connected(env, "codex", "http://127.0.0.1:9/v1/mcp"); + let polls = 0; + const { fetch } = fakeFetch(call => { + const name = (call.params as { name?: string } | undefined)?.name; + if (name === "sandbase_run") return toolResult({ prediction_id: "pred_t", status: "running", output: null }); + if (name === "sandbase_run_get") return toolResult(++polls < 6 ? { run_id: "pred_t", status: "completed", hints: ["The run completed and its files are still being transferred. Poll sandbase_run_get again in a few seconds."] } : { run_id: "pred_t", status: "completed", output: ["https://cdn.test/t.mp4"] }); + if (call.method === "tasks/result") return { body: { result: { structuredContent: { status: "completed", output: null } } } }; + return { body: { error: { code: -32601, message: "unexpected" } } }; + }); + const result = await cli("run", ["sandbase_video", "-i", "{}", "-j"], env, fetch); + assert.equal(result.code, 0, result.err); + assert.deepEqual(JSON.parse(result.out).output, ["https://cdn.test/t.mp4"]); }); test("discover renders a table with price and next step; zero results show server hints", async () => { @@ -138,7 +167,7 @@ test("a run still going at --timeout prints the resume command; a failed run exi const env = await home(); await connected(env, "codex", "http://127.0.0.1:9/v1/mcp"); const running = fakeFetch(call => toolResult((call.params as { name?: string }).name === "sandbase_run" ? { prediction_id: "pred_2", status: "running" } : { run_id: "pred_2", status: "running" })); const slow = await cli("run", ["sandbase_video", "-i", "{}", "-w", "--timeout", "5"], env, running.fetch); - assert.equal(slow.code, 0); assert.match(slow.out, /still running when --timeout elapsed/); assert.match(slow.out, /Next: sandbase runs get -r pred_2 -w/); + assert.equal(slow.code, 0); assert.match(slow.out, /still running when --timeout elapsed/); assert.match(slow.out, /Next: sandbase runs get -r pred_2 /); const failing = fakeFetch(call => call.method === "tasks/result" ? { body: { result: { structuredContent: { status: "failed", error: { message: "content policy" } } } } } : toolResult((call.params as { name?: string }).name === "sandbase_run" ? { prediction_id: "pred_3", status: "running" } : { run_id: "pred_3", status: "failed" })); const failed = await cli("runs", ["get", "-r", "pred_3"], env, failing.fetch); assert.equal(failed.code, 1); assert.match(failed.out, /Status: failed/); assert.match(failed.out, /content policy/); @@ -184,12 +213,17 @@ test("update notice compares semver and is suppressed when disabled", async () = assert.equal(await updateNotice({ ...env, SANDBASE_NO_UPDATE_CHECK: "1" }, registry), undefined); }); -async function bridge(env: NodeJS.ProcessEnv, fetch: FetchLike, lines: unknown[]): Promise>> { +async function bridge(env: NodeJS.ProcessEnv, fetch: FetchLike, lines: unknown[], client = "codex"): Promise>> { const runtime = await import(pathToFileURL(join(process.cwd(), "assets", "mcp-bridge.mjs")).href) as { runMCPBridge(options: Record): Promise }; const input = new PassThrough(), output = new PassThrough(); let text = ""; output.on("data", chunk => { text += chunk; }); - const done = runtime.runMCPBridge({ client: "codex", env, input, output, fetchImpl: fetch }); - input.end(lines.map(line => JSON.stringify(line)).join("\n") + "\n"); + const done = runtime.runMCPBridge({ client, env, input, output, fetchImpl: fetch, runPollMs: 5 }); + // Like a real host, keep stdin open until every request has its answer. + input.write(lines.map(line => JSON.stringify(line)).join("\n") + "\n"); + const ids = lines.map(line => (line as { id?: unknown }).id).filter(id => id !== undefined); + const answered = () => { const seen = new Set(text.split("\n").filter(Boolean).map(line => (JSON.parse(line) as { id?: unknown }).id)); return ids.every(id => seen.has(id)); }; + for (let i = 0; i < 1000 && !answered(); i++) await new Promise(resolve => setTimeout(resolve, 5)); + input.end(); await done; return text.trim().split("\n").filter(Boolean).map(line => JSON.parse(line)); } @@ -214,6 +248,124 @@ test("bridge keeps the server usable offline and turns tool failures into visibl assert.equal(cachedOffline[0]!.result.tools[0].description, "live", "offline tools/list serves the last live list"); }); +test("bridge returns the finished run in the same sandbase_run call, with progress, for long-wait clients", async () => { + const env = await home(); await connected(env, "claude-code", "https://api.sandbase.ai/v1/mcp"); + let polls = 0; + const { fetch, calls } = fakeFetch(call => { + const name = (call.params as { name?: string } | undefined)?.name; + if (name === "sandbase_run") return toolResult({ prediction_id: "pred_b", run_id: "pred_b", status: "running", output: null, hints: ["Poll sandbase_run_get"] }); + if (name === "sandbase_run_get") return toolResult(++polls < 3 ? { run_id: "pred_b", status: "running" } : { run_id: "pred_b", status: "completed", cost: "0.1", output: ["https://cdn.test/b.mp4"] }); + return { body: { error: { code: -32601, message: "unexpected" } } }; + }); + const out = await bridge(env, fetch, [{ jsonrpc: "2.0", id: 7, method: "tools/call", params: { name: "sandbase_run", arguments: { name: "sandbase_video", arguments: {} }, _meta: { progressToken: "tok" } } }], "claude-code"); + const progress = out.filter(line => line.method === "notifications/progress"); + const final = out.find(line => line.id === 7)!; + assert.ok(progress.length >= 1 && progress.every(line => line.params.progressToken === "tok")); + assert.equal(final.result.isError, false); assert.equal(final.result.structuredContent.status, "completed"); + assert.match(final.result.content[0].text, /https:\/\/cdn\.test\/b\.mp4/); + assert.equal(calls.filter(call => (call.params as { name?: string }).name === "sandbase_run").length, 1, "the paid run is started once"); +}); + +test("bridge waits through file transfer and reports a failed run as an error", async () => { + const env = await home(); await connected(env, "codex", "https://api.sandbase.ai/v1/mcp"); + let polls = 0; + const transfer = fakeFetch(call => (call.params as { name?: string }).name === "sandbase_run" ? toolResult({ prediction_id: "pred_x", status: "completed", output: null, hints: ["The run completed and its files are still being transferred. Poll sandbase_run_get again in a few seconds."] }) : toolResult(++polls < 2 ? { run_id: "pred_x", status: "completed", hints: ["The run completed and its files are still being transferred."] } : { run_id: "pred_x", status: "completed", output: ["https://cdn.test/x.mp4"] })); + const [done] = await bridge({ ...env, SANDBASE_LONG_TOOL_CALLS: "1" }, transfer.fetch, [{ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "sandbase_run", arguments: { name: "sandbase_video", arguments: {} } } }]); + assert.match(done!.result.content[0].text, /x\.mp4/); + const failing = fakeFetch(call => (call.params as { name?: string }).name === "sandbase_run" ? toolResult({ prediction_id: "pred_f", status: "running" }) : toolResult({ run_id: "pred_f", status: "failed", error: { message: "content policy" } })); + const [failed] = await bridge({ ...env, SANDBASE_LONG_TOOL_CALLS: "1" }, failing.fetch, [{ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "sandbase_run", arguments: { name: "sandbase_video", arguments: {} } } }]); + assert.equal(failed!.result.isError, true); assert.match(failed!.result.content[0].text, /Run pred_f failed: content policy/); +}); + +test("Codex gets long waits only when its config carries the long tool timeout", async () => { + const env = await home(); await connected(env, "codex", "https://api.sandbase.ai/v1/mcp"); + const old = fakeFetch(() => toolResult({ prediction_id: "pred_o", status: "running" })); + await bridge(env, old.fetch, [{ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "sandbase_run", arguments: { name: "sandbase_video", arguments: {} } } }]); + assert.equal(old.calls.length, 1, "an older Codex config (60s timeout) gets no local wait on sandbase_run"); + let polls = 0; + const configured = fakeFetch(call => (call.params as { name?: string }).name === "sandbase_run" ? toolResult({ prediction_id: "pred_l", status: "running" }) : toolResult(++polls < 2 ? { run_id: "pred_l", status: "running" } : { run_id: "pred_l", status: "completed", output: ["ok"] })); + const [done] = await bridge({ ...env, SANDBASE_LONG_TOOL_CALLS: "1" }, configured.fetch, [{ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "sandbase_run", arguments: { name: "sandbase_video", arguments: {} } } }]); + assert.equal(done!.result.structuredContent.status, "completed"); +}); + +test("bridge stops waiting on repeated poll failures or a revoked sign-in, and passes run_get tool errors through", async () => { + const env = await home(); await connected(env, "claude-code", "https://api.sandbase.ai/v1/mcp"); + const outage = fakeFetch(call => (call.params as { name?: string }).name === "sandbase_run" ? toolResult({ prediction_id: "pred_e", run_id: "pred_e", status: "running", hints: ["Poll sandbase_run_get"] }) : { status: 502, body: "bad gateway" }); + const [stale] = await bridge(env, outage.fetch, [{ jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: "sandbase_run", arguments: { name: "sandbase_video", arguments: {} } } }], "claude-code"); + assert.equal(stale!.id, 4); assert.equal(stale!.result.structuredContent.run_id, "pred_e", "the agent keeps the run id to resume"); + assert.equal(outage.calls.length, 1 + 5, "gives up after 5 consecutive poll failures"); + const revoked = fakeFetch(call => (call.params as { name?: string }).name === "sandbase_run" ? toolResult({ prediction_id: "pred_r", status: "running" }) : { status: 401, body: '{"error":"invalid API key"}' }); + await bridge(env, revoked.fetch, [{ jsonrpc: "2.0", id: 5, method: "tools/call", params: { name: "sandbase_run", arguments: { name: "sandbase_video", arguments: {} } } }], "claude-code"); + assert.equal(revoked.calls.length, 2, "a 401 stops the wait at once"); + const missing = fakeFetch(call => (call.params as { name?: string }).name === "sandbase_run" ? toolResult({ prediction_id: "pred_m", status: "running" }) : { body: { result: { content: [{ type: "text", text: "Run 'pred_m' was not found" }], structuredContent: { error: { code: "run_not_found" } }, isError: true } } }); + const [notFound] = await bridge(env, missing.fetch, [{ jsonrpc: "2.0", id: 6, method: "tools/call", params: { name: "sandbase_run", arguments: { name: "sandbase_video", arguments: {} } } }], "claude-code"); + assert.equal(notFound!.id, 6); assert.equal(notFound!.result.isError, true); assert.match(notFound!.result.content[0].text, /not found/); +}); + +test("a long local wait does not block other calls, and notifications/cancelled ends it without a response", async () => { + const env = await home(); await connected(env, "claude-code", "https://api.sandbase.ai/v1/mcp"); + const runtime = await import(pathToFileURL(join(process.cwd(), "assets", "mcp-bridge.mjs")).href) as { runMCPBridge(options: Record): Promise }; + const { fetch } = fakeFetch(call => (call.params as { name?: string } | undefined)?.name === "sandbase_run" ? toolResult({ prediction_id: "pred_s", status: "running" }) : call.method === "tools/call" && (call.params as { name?: string }).name === "sandbase_account" ? toolResult({ balance: "1" }) : toolResult({ run_id: "pred_s", status: "running" })); + const input = new PassThrough(), output = new PassThrough(); const lines: Array> = []; + output.on("data", chunk => { for (const line of String(chunk).split("\n").filter(Boolean)) lines.push(JSON.parse(line)); }); + const done = runtime.runMCPBridge({ client: "claude-code", env, input, output, fetchImpl: fetch, runPollMs: 5 }); + input.write(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "sandbase_run", arguments: { name: "sandbase_video", arguments: {} } } }) + "\n"); + input.write(JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "sandbase_account", arguments: {} } }) + "\n"); + for (let i = 0; i < 200 && !lines.some(line => line.id === 2); i++) await new Promise(resolve => setTimeout(resolve, 5)); + assert.ok(lines.some(line => line.id === 2), "another call is answered while the run is still being waited on"); + assert.ok(!lines.some(line => line.id === 1), "the run is still waiting"); + input.end(JSON.stringify({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: 1 } }) + "\n"); + await done; + assert.ok(!lines.some(line => line.id === 1), "a cancelled request gets no response"); +}); + +test("a cancel during the first server wait is honored, and closing stdin stops local waits", async () => { + const env = await home(); await connected(env, "claude-code", "https://api.sandbase.ai/v1/mcp"); + const runtime = await import(pathToFileURL(join(process.cwd(), "assets", "mcp-bridge.mjs")).href) as { runMCPBridge(options: Record): Promise }; + let release: () => void = () => {}; + const held = new Promise(resolve => { release = resolve; }); + let polls = 0; + const slow: FetchLike = async (url, init) => { + const request = JSON.parse(init.body) as { id: unknown; params?: { name?: string } }; + if (request.params?.name === "sandbase_run") await held; + if (request.params?.name === "sandbase_run_get") polls++; + const data = { prediction_id: "pred_h", status: "running" }; + return { ok: true, status: 200, headers: { get: () => "application/json" }, text: async () => JSON.stringify({ jsonrpc: "2.0", id: request.id, result: { content: [{ type: "text", text: "" }], structuredContent: data, isError: false } }) }; + }; + const input = new PassThrough(), output = new PassThrough(); const lines: Array> = []; + output.on("data", chunk => { for (const line of String(chunk).split("\n").filter(Boolean)) lines.push(JSON.parse(line)); }); + const done = runtime.runMCPBridge({ client: "claude-code", env, input, output, fetchImpl: slow, runPollMs: 5 }); + input.write(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "sandbase_run", arguments: { name: "sandbase_video", arguments: {} } } }) + "\n"); + await new Promise(resolve => setTimeout(resolve, 20)); + input.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: 1 } }) + "\n"); + await new Promise(resolve => setTimeout(resolve, 20)); + release(); + await new Promise(resolve => setTimeout(resolve, 50)); + assert.equal(polls, 0, "no local wait starts for a request cancelled while it was still at the server"); + assert.ok(!lines.some(line => line.id === 1), "and it gets no response"); + + // stdin closing while a long local wait is in progress ends the bridge promptly. + const env2 = await home(); await connected(env2, "claude-code", "https://api.sandbase.ai/v1/mcp"); + const running = fakeFetch(() => toolResult({ prediction_id: "pred_w", status: "running" })); + const input2 = new PassThrough(), output2 = new PassThrough(); + const done2 = runtime.runMCPBridge({ client: "claude-code", env: env2, input: input2, output: output2, fetchImpl: running.fetch, runPollMs: 5 }); + input2.write(JSON.stringify({ jsonrpc: "2.0", id: 9, method: "tools/call", params: { name: "sandbase_run", arguments: { name: "sandbase_video", arguments: {} } } }) + "\n"); + for (let i = 0; i < 100 && running.calls.length < 3; i++) await new Promise(resolve => setTimeout(resolve, 5)); + const started = Date.now(); input2.end(); await done2; input.end(); await done; + assert.ok(Date.now() - started < 1000, "the bridge exits instead of polling for 20 minutes"); +}); + +test("bridge leaves short-timeout clients' sandbase_run alone and Agent Service runs untouched", async () => { + const env = await home(); await connected(env, "cursor", "https://api.sandbase.ai/v1/mcp"); + const { fetch, calls } = fakeFetch(() => toolResult({ prediction_id: "pred_c", status: "running" })); + const [result] = await bridge(env, fetch, [{ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "sandbase_run", arguments: { name: "sandbase_video", arguments: {} } } }], "cursor"); + assert.equal(result!.result.structuredContent.status, "running"); assert.equal(calls.length, 1, "no local wait inside a client with a 60s tool timeout"); + const env2 = await home(); await connected(env2, "claude-code", "https://api.sandbase.ai/v1/mcp"); + const svc = fakeFetch(() => toolResult({ run_id: "r1", service_id: "svc_1", status: "running" })); + const [service] = await bridge(env2, svc.fetch, [{ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "sandbase_run", arguments: { name: "svc_1", arguments: { idempotency_key: "k" } } } }], "claude-code"); + assert.equal(service!.result.structuredContent.status, "running"); assert.equal(svc.calls.length, 1); +}); + function resetAfterSend(): Error { const error = new TypeError("fetch failed") as TypeError & { cause?: unknown }; error.cause = { code: "ECONNRESET" }; return error; } test("a paid run interrupted after sending is never retried (double-bill guard)", async () => { @@ -250,7 +402,7 @@ test("unknown statuses keep polling, plain-text sync results complete, server ru const env = await home(); await connected(env, "codex", "http://127.0.0.1:9/v1/mcp"); const queued = fakeFetch(() => toolResult({ prediction_id: "pred_q", status: "IN_QUEUE" })); const q = await cli("run", ["sandbase_fal", "-i", "{}"], env, queued.fetch); - assert.equal(q.code, 0); assert.match(q.out, /Next: sandbase runs get -r pred_q -w/); + assert.equal(q.code, 0); assert.match(q.out, /Next: sandbase runs get -r pred_q /); const text = fakeFetch(() => ({ body: { result: { content: [{ type: "text", text: "hello world" }], isError: false } } })); const plain = await cli("run", ["sandbase_llm", "-i", "{}"], env, text.fetch); assert.equal(plain.code, 0); assert.match(plain.out, /Status: completed/); assert.match(plain.out, /hello world/); @@ -263,7 +415,7 @@ test("failures after a run started keep the run id; an unwritable -o fails befor const env = await home(); await connected(env, "codex", "http://127.0.0.1:9/v1/mcp"); const { fetch, calls } = fakeFetch(call => (call.params as { name?: string }).name === "sandbase_run" ? toolResult({ prediction_id: "pred_k", status: "running" }) : connectTimeout()); const lost = JSON.parse((await cli("run", ["sandbase_video", "-i", "{}", "-w", "-j"], env, fetch)).out); - assert.equal(lost.run_id, "pred_k"); assert.match(lost.error.hint, /sandbase runs get -r pred_k -w/); + assert.equal(lost.run_id, "pred_k"); assert.match(lost.error.hint, /sandbase runs get -r pred_k`/); const before = calls.length; const bad = await cli("run", ["sandbase_video", "-i", "{}", "-o", join(env.HOME!, "missing-dir", "x.json")], env, fetch); assert.equal(bad.code, 2); assert.match(bad.err, /nothing was run/); assert.equal(calls.length, before); @@ -314,7 +466,7 @@ test("setup refreshes the bridge and SandBase-owned Skills but leaves edited Ski const lines: string[] = []; assert.equal(await setup(new FileCredentialStore(env.SANDBASE_HOME!), line => void lines.push(line)), true); assert.match(lines.join("\n"), /bridge: status=updated/); assert.match(lines.join("\n"), /claude-code: skill=updated/); assert.match(lines.join("\n"), /kiro: skill=modified/); - assert.match(await readFile(join(claudeDir, "SKILL.md"), "utf8"), /version: 0\.3\.2/); + assert.match(await readFile(join(claudeDir, "SKILL.md"), "utf8"), /version: 0\.3\.3/); assert.match(await readFile(join(kiroDir, "SKILL.md"), "utf8"), /user edit/); } finally { for (const [key, value] of Object.entries(previous)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } } }); From 9f336567dadd64ba61f8ff818ae5d55942a74acb Mon Sep 17 00:00:00 2001 From: liyb Date: Mon, 12 Oct 2026 01:57:27 +0800 Subject: [PATCH 2/2] refactor: follow sync/async per model like Monid; drop bridge local wait Sync tools return results directly and async tools return a run id to poll, as the server now does. Revert the bridge's local waiting and concurrency, and the CLI's wait-by-default. Keep: health removal, -w waiting through file transfer, no stale running hints, run id on stderr when -w waits, Codex tool_timeout_sec for long sync calls. Skill 0.3.3 describes the two kinds. --- README.md | 8 +- assets/mcp-bridge.mjs | 168 ++------------------------------------- skills/sandbase/SKILL.md | 20 ++--- src/adapters/index.ts | 8 +- src/agent/client.ts | 2 +- src/agent/commands.ts | 27 +++---- test/adapters.test.ts | 4 +- test/agent.test.ts | 167 ++++---------------------------------- 8 files changed, 57 insertions(+), 347 deletions(-) diff --git a/README.md b/README.md index 10156ad..db805d8 100644 --- a/README.md +++ b/README.md @@ -284,14 +284,14 @@ Agents without MCP, or with a broken MCP session, use the same catalog from a sh sandbase discover -q "twitter posts" # search; -t api|llm|multimodal|embedding|service sandbase inspect sandbase_twitter_web_search_timeline # fields, price, ready-to-run command sandbase run sandbase_twitter_web_search_timeline -i '{"keyword":"AI agents"}' -sandbase run -i '{"prompt":"..."}' -o result.json # waits for the result, saves output to disk -sandbase runs get -r -o result.json # resume a long run instead of re-running it +sandbase run -i '{"prompt":"..."}' -w -o result.json # wait with backoff, save output to disk +sandbase runs get -r -w -o result.json # resume a long run instead of re-running it sandbase runs list | sandbase balance ``` -`run` and `runs get` wait for the result (`--timeout 300` by default, `--no-wait` to return right after starting). `-j` prints JSON; errors are `{"error":{"code","message","hint"}}` with the next step in `hint`. Exit codes: `0` success or still running, `1` failure (including a failed run), `2` usage. +`-j` prints JSON; errors are `{"error":{"code","message","hint"}}` with the next step in `hint`. Exit codes: `0` success or still running, `1` failure (including a failed run), `2` usage. -Through MCP, `sandbase_run` also returns the finished result in the same call. In Claude Code and Codex the bridge keeps waiting for slow jobs (video, large scrapes) and reports progress; other clients get the result from `sandbase_run_get`. If `api.sandbase.ai` is unreachable, the bridge keeps the SandBase tools visible with an actionable error instead of dropping the server. +Sync tools (LLMs, most data APIs) return their result directly; async tools (image, video, audio jobs) return a run id — wait with `-w`, or fetch later with `sandbase runs get -r `. If `api.sandbase.ai` is unreachable, the bridge keeps the SandBase tools visible with an actionable error instead of dropping the server. ### MCP Tools (available to your agent after connecting) diff --git a/assets/mcp-bridge.mjs b/assets/mcp-bridge.mjs index 2755766..848bef7 100644 --- a/assets/mcp-bridge.mjs +++ b/assets/mcp-bridge.mjs @@ -22,21 +22,6 @@ const RECONNECT = "npx -y @sandbaseai/cli@latest connect"; // Tool calls get no bridge-side deadline (long synchronous tools must be able to // finish); control-plane methods are bounded so offline detection stays quick. const CONTROL_TIMEOUT_MS = 60_000; -// Runs are synchronous for the agent: the server waits up to ~50s per call, and -// when a job is still going the bridge keeps waiting locally so the agent gets -// the finished output instead of a run id to poll. Clients that allow long tool -// calls (Claude Code; Codex with the tool_timeout_sec that connect writes) wait -// in one call; others get bounded waits that stay under their 60s tool timeout. -const RUN_POLL_MS = 3_000; -const LONG_RUN_WAIT_MS = 20 * 60_000; -const BOUNDED_RUN_WAIT_MS = 50_000; -const MAX_POLL_FAILURES = 5; -// Claude Code allows very long MCP tool calls by default. Codex only does when -// its config raises tool_timeout_sec, which `connect` writes together with -// SANDBASE_LONG_TOOL_CALLS=1 — so an older Codex config keeps bounded waits. -function allowsLongToolCalls(client, env) { - return client === "claude-code" || (client === "codex" && env.SANDBASE_LONG_TOOL_CALLS === "1"); -} // Used only when SandBase is unreachable and no tools/list was ever cached. const offlineTools = [ @@ -107,46 +92,6 @@ function httpFailure(status, text, host) { if (status >= 500) return `SandBase returned HTTP ${status} from ${host}${suffix}. Temporary server error: retry in a minute; the user can run \`sandbase doctor\` if it persists.`; return `SandBase MCP request failed (${status}) at ${host}${suffix}.${hintFor(detail) ? ` ${hintFor(detail)}` : ""}`; } -// Same rule as the CLI: only an explicit failure is failed; any status we do not -// know (IN_QUEUE, created, …) is still running, so the bridge keeps waiting. -const SUCCEEDED_STATUSES = new Set(["completed", "succeeded", "success", "done"]); -const FAILED_STATUSES = new Set(["failed", "failure", "error", "errored", "cancelled", "canceled", "timeout", "timed_out", "expired", "rejected", "blocked", "stopped"]); -function runPhase(status) { - const value = String(status ?? "").trim().toLowerCase(); - return SUCCEEDED_STATUSES.has(value) ? "succeeded" : FAILED_STATUSES.has(value) ? "failed" : "running"; -} -function structuredOf(payload) { const value = payload?.result?.structuredContent; return value && typeof value === "object" ? value : undefined; } -/** A run result the bridge should keep waiting on: still running, or completed while its files are still being copied. */ -function awaitingRun(payload) { - const data = structuredOf(payload); - if (!data || payload?.result?.isError) return false; - if (data.service_id || String(data.tool_name ?? data.name ?? "").startsWith("svc_")) return false; - const phase = runPhase(data.status); - if (phase === "running") return true; - return phase === "succeeded" && (data.output === undefined || data.output === null) && /still being transferred/i.test(JSON.stringify(data.hints ?? "")); -} -function runIdOf(payload) { const data = structuredOf(payload) || {}; const id = data.run_id ?? data.prediction_id ?? data.taskId; return typeof id === "string" && id ? id : undefined; } -function sleep(ms, signal) { - return new Promise(resolve => { - if (signal?.aborted) { resolve(); return; } - const timer = setTimeout(() => { signal?.removeEventListener("abort", stop); resolve(); }, ms); - const stop = () => { clearTimeout(timer); resolve(); }; - signal?.addEventListener("abort", stop, { once: true }); - }); -} -/** The finished run as the agent should see it: output as text, failures as isError. */ -function finalRunResult(id, payload, runId) { - const data = structuredOf(payload) || {}; - if (runPhase(data.status) === "failed") { - const error = data.error; - const detail = typeof error === "string" ? error : typeof error?.message === "string" ? error.message : ""; - return { jsonrpc: "2.0", id, result: { content: [{ type: "text", text: `Run ${runId} failed${detail ? `: ${detail}` : ""}. Read the error, fix the arguments with sandbase_inspect if needed, then retry once or choose another tool from sandbase_discover.` }], structuredContent: data, isError: true } }; - } - const output = data.output; - const text = output === undefined || output === null ? JSON.stringify(data) : typeof output === "string" ? output : JSON.stringify(output, null, 2); - return { jsonrpc: "2.0", id, result: { content: [{ type: "text", text }], structuredContent: data, isError: false } }; -} - function payloadsOf(text, contentType) { const out = []; if (!text.trim()) return out; @@ -177,7 +122,6 @@ export async function runMCPBridge(options = {}) { const signal = options.signal; const env = options.env || process.env; const fetchImpl = options.fetchImpl || globalThis.fetch; - const runPollMs = Number.isFinite(options.runPollMs) && options.runPollMs > 0 ? options.runPollMs : RUN_POLL_MS; if (!allowedClients.has(client)) throw bridgeError("SandBase bridge client is invalid."); const home = env.SANDBASE_HOME || join(homedir(), ".sandbase"); @@ -219,93 +163,18 @@ export async function runMCPBridge(options = {}) { output.write(JSON.stringify(payload) + "\n"); }; - const post = async (request, timeoutMs) => { - const headers = { authorization: `Bearer ${record.credential}`, "content-type": "application/json", accept: "application/json, text/event-stream" }; - if (session) headers["mcp-session-id"] = session; - const timeout = timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined; - const combined = signal && timeout && typeof AbortSignal.any === "function" ? AbortSignal.any([signal, timeout]) : signal || timeout; - return fetchImpl(url, { method: "POST", headers, body: JSON.stringify(request), ...(combined ? { signal: combined } : {}) }); - }; - - let pollSeq = 0; - /** One sandbase_run_get read: { payload } on an answer, { failed, fatal } on a transport/HTTP/protocol problem. */ - const readRun = async (runId, timeoutMs) => { - const request = { jsonrpc: "2.0", id: `sandbase-bridge-poll-${++pollSeq}`, method: "tools/call", params: { name: "sandbase_run_get", arguments: { run_id: runId } } }; - try { - const response = await post(request, Math.max(1_000, Math.min(CONTROL_TIMEOUT_MS, timeoutMs))); - const text = await response.text(); - if (!response.ok) return { failed: true, fatal: response.status === 401 || response.status === 403 }; - const payload = payloadsOf(text, response.headers.get("content-type") || "").find(item => item?.id === request.id && (item.result || item.error)); - return payload?.result ? { payload } : { failed: true }; - } catch { return { failed: true }; } - }; - - // One cancel handle per in-flight tools/call id, registered before the request - // is sent, so notifications/cancelled (or stdin closing) can stop it at any point. - const calls = new Map(); - const CANCELLED = Symbol("cancelled"); - - /** Keep a still-running run going until it has a result or the wait budget passes. */ - const awaitRun = async (request, initial, budgetMs, cancel) => { - const runId = runIdOf(initial); - if (!runId || budgetMs <= 0) return initial; - const progressToken = request.params?._meta?.progressToken; - const deadline = Date.now() + budgetMs; - const stop = signal && typeof AbortSignal.any === "function" ? AbortSignal.any([signal, cancel.signal]) : cancel.signal; - let latest = initial, progress = 0, failures = 0; - { - while (!stop.aborted && Date.now() + runPollMs <= deadline) { - await sleep(runPollMs, stop); - if (cancel.signal.aborted) return CANCELLED; - if (stop.aborted) break; - if (progressToken !== undefined) output.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/progress", params: { progressToken, progress: ++progress, message: `SandBase run ${runId} is still running` } }) + "\n"); - const read = await readRun(runId, deadline - Date.now()); - if (cancel.signal.aborted) return CANCELLED; - if (read.failed) { - // A revoked sign-in or a persistent outage must not turn into a silent - // multi-minute wait: give the agent the run id and stop. - if (read.fatal || ++failures >= MAX_POLL_FAILURES) break; - continue; - } - failures = 0; - latest = read.payload; - // A tool error while reading the run (e.g. run not found) goes to the agent as-is. - if (latest.result?.isError) return { ...latest, id: request.id }; - if (!awaitingRun(latest)) return finalRunResult(request.id, latest, runId); - } - } - // Still running when the budget ran out: return the latest status (with its - // run id and next step) under the agent's request id. - return { ...latest, id: request.id }; - }; - const longWait = allowsLongToolCalls(client, env); - const runWaitBudget = request => { - if (request.method !== "tools/call") return 0; - const name = request.params?.name; - const args = request.params?.arguments || {}; - if (name === "sandbase_run" && !String(args.name ?? "").startsWith("svc_")) return longWait ? LONG_RUN_WAIT_MS : 0; - if (name === "sandbase_run_get" && !args.service_id) return longWait ? LONG_RUN_WAIT_MS : BOUNDED_RUN_WAIT_MS; - return 0; - }; - const forward = async line => { let request; try { request = JSON.parse(line); } catch { return; } if (!request || typeof request !== "object" || Array.isArray(request)) return; const idempotent = isIdempotentRequest(request); - // A cancelled request stops its local wait; the notification still goes to the server. - if (request.method === "notifications/cancelled") calls.get(request.params?.requestId)?.abort(); - const tracked = request.method === "tools/call" && request.id !== undefined; - const cancel = new AbortController(); - if (tracked) calls.set(request.id, cancel); - try { await send(request, idempotent, cancel); } - finally { if (tracked && calls.get(request.id) === cancel) calls.delete(request.id); } - }; - - const send = async (request, idempotent, cancel) => { let response; try { - response = await post(request, request.method === "tools/call" ? undefined : CONTROL_TIMEOUT_MS); + const headers = { authorization: `Bearer ${record.credential}`, "content-type": "application/json", accept: "application/json, text/event-stream" }; + if (session) headers["mcp-session-id"] = session; + const timeout = request.method === "tools/call" ? undefined : AbortSignal.timeout(CONTROL_TIMEOUT_MS); + const combined = signal && timeout && typeof AbortSignal.any === "function" ? AbortSignal.any([signal, timeout]) : signal || timeout; + response = await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(request), ...(combined ? { signal: combined } : {}) }); } catch (error) { if (signal?.aborted) return; const reason = describeNetworkFailure(error); @@ -329,24 +198,7 @@ export async function runMCPBridge(options = {}) { output.write(request.method === "tools/call" ? toolError(request.id, message) : responseError(request.id, message)); return; } - const budget = runWaitBudget(request); - for (let payload of payloadsOf(text, response.headers.get("content-type") || "")) { - // MCP: no response is sent for a request the client cancelled. - if (cancel.signal.aborted && payload?.id === request.id) continue; - if (budget > 0 && payload?.id === request.id && awaitingRun(payload)) payload = await awaitRun(request, payload, budget, cancel); - if (payload === CANCELLED) continue; - await deliver(request, payload); - } - }; - // Tool calls run concurrently (each answers by its own id), so a long run never - // blocks pings, other tools, or the client's cancellation of that run. - const inflight = new Set(); - const dispatch = async line => { - let method; - try { method = JSON.parse(line)?.method; } catch { /* forward() ignores it */ } - if (method !== "tools/call") { await forward(line); return; } - const task = forward(line).catch(() => {}).finally(() => inflight.delete(task)); - inflight.add(task); + for (const payload of payloadsOf(text, response.headers.get("content-type") || "")) await deliver(request, payload); }; const stopInput = () => { if (typeof input.destroy === "function") input.destroy(); }; @@ -360,15 +212,11 @@ export async function runMCPBridge(options = {}) { if (split < 0) break; const line = buffer.subarray(0, split).toString("utf8").trim(); buffer = buffer.subarray(split + 1); - if (line) await dispatch(line); + if (line) await forward(line); } } const finalLine = buffer.toString("utf8").trim(); - if (!signal?.aborted && finalLine) await dispatch(finalLine); - // The client closed stdin: nobody can receive a long wait's answer, so stop - // local waits now; requests already sent finish within their server budget. - for (const cancel of calls.values()) cancel.abort(); - await Promise.allSettled([...inflight]); + if (!signal?.aborted && finalLine) await forward(finalLine); } catch (error) { if (!signal?.aborted) throw error; } finally { signal?.removeEventListener("abort", stopInput); } diff --git a/skills/sandbase/SKILL.md b/skills/sandbase/SKILL.md index 32078c6..1be83e0 100644 --- a/skills/sandbase/SKILL.md +++ b/skills/sandbase/SKILL.md @@ -58,7 +58,7 @@ Discovery, inspection, run status, and balance are **free**; only `sandbase_run` | `sandbase_discover` | Search the catalog in natural language (`q`), optionally filtered by `type` (`llm`, `api`, `multimodal`, `embedding`, `service`) or `vendor` | | `sandbase_inspect` | Input schema (`inputSchema`), pricing, and an `execute_as` template for one tool | | `sandbase_run` | Execute a tool (**costs money**) | -| `sandbase_run_get` | Status, cost, and output of a run; through the bridge it waits for a run that is still going | +| `sandbase_run_get` | Status, cost, and output of a run — poll with this | | `sandbase_runs` | Recent runs with cost | | `sandbase_account` | Balance | | `sandbase_run_cancel` | Cancel an Agent Service run | @@ -67,8 +67,10 @@ Discovery, inspection, run status, and balance are **free**; only `sandbase_run` 1. `sandbase_discover({"q": "twitter posts"})` → pick a `tool_name` from `data` (each result carries `description`, `pricing`, `vendor`, `run_count`). 2. `sandbase_inspect({"name": "sandbase_twitter_web_search_timeline"})` → read `inputSchema` and `pricing`; copy `execute_as`. -3. `sandbase_run({"name": "sandbase_twitter_web_search_timeline", "arguments": {"keyword": "AI agents"}})` waits for the result and returns `status: "completed"` with `output` in the same call. Most tools, including image generation, finish within that wait. In Claude Code and Codex the SandBase bridge keeps waiting for slow jobs too (video, music, large scrapes). Do not call it again while it is working. -4. If it returns `status: "running"` (a slow job on a client that limits tool calls to about a minute), call `sandbase_run_get({"run_id": ""})` — through the bridge it waits up to ~50 s and returns the `output` once ready. Repeat until `completed` or `failed`; never call `sandbase_run` again for the same job (that starts and bills a new run). +3. `sandbase_run({"name": "sandbase_twitter_web_search_timeline", "arguments": {"keyword": "AI agents"}})`. There are two kinds of tools: + - **Sync** (LLMs, most data APIs): the call waits for the upstream and returns `status: "completed"` with `output` directly. + - **Async** (image, video, audio, other long jobs): the call returns at once with `status: "running"` and a `run_id` (also `prediction_id` / `taskId`). +4. For an async run, call `sandbase_run_get({"run_id": ""})` every 5–10 seconds until `status` is `completed` or `failed`; the finished `output` is in that response. A run that has finished but is still copying its files also reports `running` — keep polling. Never call `sandbase_run` again for the same job (that starts and bills a new run). **Agent Services** (`svc_…` names from `type: "service"`): pass `arguments: {"idempotency_key": "", "input": "..."}`; reuse the same key and input on retries. Poll with `sandbase_run_get({"run_id": ..., "service_id": "svc_…"})`; `succeeded`, `failed`, and `cancelled` are terminal. @@ -88,18 +90,18 @@ If `sandbase` is not installed, prefix every command with `npx -y https://github |---|---|---| | Search | `sandbase_discover` | `sandbase discover -q "" [-t api] [-l 10]` | | Schema + price | `sandbase_inspect` | `sandbase inspect ` | -| Execute (waits for the result) | `sandbase_run` | `sandbase run -i '' [-o result.json]` (or `-f input.json`) | -| Resume / fetch result | `sandbase_run_get` | `sandbase runs get -r [-o result.json]` | +| Execute | `sandbase_run` | `sandbase run -i '' [-w] [-o result.json]` (or `-f input.json`) | +| Poll / fetch result | `sandbase_run_get` | `sandbase runs get -r [-w] [-o result.json]` | | Recent runs | `sandbase_runs` | `sandbase runs list` | | Balance | `sandbase_account` | `sandbase balance` | | Diagnose connectivity | — | `sandbase doctor` | | **Save output to a file** | not available | `-o result.json` on `run` / `runs get` | - Add `-j` for JSON output. Errors then look like `{"error": {"code", "message", "hint"}}`; follow `hint`. -- `run` and `runs get` wait for the result (default `--timeout 300` seconds; `--no-wait` returns right after starting). The run id is printed to stderr as soon as the run starts. If the run is still going when the timeout elapses — or your shell command times out — resume with `sandbase runs get -r ` rather than re-running (re-running is billed again). Give long media runs a shell timeout of a few minutes. +- `-w` waits with backoff (default `--timeout 300` seconds). If the run is still going when the timeout elapses, the CLI prints the `runs get` command to resume — use it rather than re-running (re-running is billed again). - `-o` writes only the tool's output (no envelope). Files/URLs in the output are also listed in text mode. - Exit code is `0` on success or still-running, `1` on failure (including a failed run), `2` on bad usage. -- If an error mentions a `run_id`, the run already started: resume it with `sandbase runs get -r ` — never re-run (that bills again). +- If an error mentions a `run_id`, the run already started: resume it with `sandbase runs get -r -w` — never re-run (that bills again). - To store an API key without exposing it: `printf %s "$KEY" | sandbase keys add -k -`. --- @@ -136,7 +138,7 @@ Do not use SandBase for purely local work (editing files, reasoning over code al - `inspect` shows the price: per call (`base_price`), per million tokens (`input_per_million` / `output_per_million`), or a `formula`. - Many data endpoints are billed **per result** and limits often apply **per query**: three search terms with `limit: 10` can return 30 results. Pass one search term per call and start with small limits (5–10). - Check `sandbase_account` before a batch of paid calls, and report cost when the user cares about budget (`cost` appears on `sandbase_run_get` and `sandbase_runs`). -- Never re-run a slow tool just because it is still running; wait for it with `sandbase_run_get`. +- Never re-run a slow tool just because it is still running; poll it. --- @@ -169,7 +171,7 @@ Responses may include `hints` (and `warnings`): suggested next steps, caveats, o 3. **Prefer MCP when connected**; use the CLI when MCP is missing or broken, or to write large outputs to disk. 4. **Always inspect before running.** Never guess parameters; `inputSchema` is the source of truth. 5. **Keep discover queries short** and decompose multi-source tasks. -6. **Run once, then wait.** `sandbase_run` returns the result; if a very long job comes back `running`, wait with `sandbase_run_get` — never re-run. +6. **Poll, don't re-run.** Slow tools return a run id; poll every 5–10 seconds. 7. **Start with conservative limits** (5–10 results). 8. **Report cost when relevant**, and check the balance before batches. 9. **Read error text and hints, and act on them.** They name the fix. diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 10e4a5d..a123f97 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -27,11 +27,9 @@ export function detectClient(client: Client): {installed:boolean; detail:string} if (!profile.executable) return profile.mode === "auto" && existsSync(path) ? {installed:true,detail:"existing configuration"} : {installed:false,detail:"not detected"}; const result=spawnSync(profile.executable,["--version"],{encoding:"utf8",timeout:5000}); const detail=(result.stdout || result.stderr || "").trim().split("\n")[0] || "not found"; if (result.status===0) return {installed:true,detail}; if (client === "codex" && existsSync(path)) return {installed:true,detail:"Codex CLI probe failed; existing Codex configuration detected"}; return {installed:false,detail}; } const start = "# >>> sandbase managed >>>", end = "# <<< sandbase managed <<<"; function block(client: Client, bridge: string): string { - // tool_timeout_sec: Codex cancels tool calls after 60s by default; the bridge - // waits for long runs (video, large scrapes) so they return their output. - // SANDBASE_LONG_TOOL_CALLS tells the bridge this config carries the long timeout - // (a bridge refreshed by `setup` under an older config keeps bounded waits). - if (client === "codex") return `${start}\n[mcp_servers.sandbase]\ncommand = "node"\nargs = ["${bridge.replaceAll("\\", "\\\\").replaceAll('"','\\"')}", "--client", "codex"]\ntool_timeout_sec = 1800\nenv = { SANDBASE_CLI_MANAGED = "1", SANDBASE_LONG_TOOL_CALLS = "1" }\n${end}`; + // tool_timeout_sec: Codex cancels tool calls after 60s by default; a sync model + // (e.g. a long LLM or scrape call) can legitimately take longer. + if (client === "codex") return `${start}\n[mcp_servers.sandbase]\ncommand = "node"\nargs = ["${bridge.replaceAll("\\", "\\\\").replaceAll('"','\\"')}", "--client", "codex"]\ntool_timeout_sec = 1800\nenv = { SANDBASE_CLI_MANAGED = "1" }\n${end}`; return ` ${start}\n sandbase:\n command: node\n args:\n - ${JSON.stringify(bridge)}\n - --client\n - ${client}\n ${end}`; } function managedText(raw: string, value: string): string { const re = new RegExp(`${start.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}[\\s\\S]*?${end.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}`, "m"); return re.test(raw) ? raw.replace(re, value) : raw.replace(/\s*$/, "") + (raw.trim() ? "\n\n" : "") + value + "\n"; } diff --git a/src/agent/client.ts b/src/agent/client.ts index ef2d187..6a3cedf 100644 --- a/src/agent/client.ts +++ b/src/agent/client.ts @@ -162,7 +162,7 @@ export class SandBaseMCPClient { const reason = describeNetworkFailure(error); this.attempts.push({ url, outcome: reason }); if (!isIdempotentRequest(method, params) && !isPreSendFailure(error)) { // The request may already be running server-side; blindly retrying would bill twice. - throw new SandBaseError("NETWORK", `The request to ${host} was interrupted after it was sent (${reason}); it may have started a run.`, "Do not re-run yet. Check `sandbase runs list` for the run and wait for it with `sandbase runs get -r `."); + throw new SandBaseError("NETWORK", `The request to ${host} was interrupted after it was sent (${reason}); it may have started a run.`, "Do not re-run yet. Check `sandbase runs list` for the run and poll it with `sandbase runs get -r -w`."); } throw new SandBaseError("NETWORK", `Cannot reach SandBase at ${host} (${reason}).`, UNREACHABLE_HINT); } diff --git a/src/agent/commands.ts b/src/agent/commands.ts index 7b8594c..c3a7a6b 100644 --- a/src/agent/commands.ts +++ b/src/agent/commands.ts @@ -19,8 +19,8 @@ export interface AgentDeps { env?: NodeJS.ProcessEnv; fetchImpl?: FetchLike; out export const agentUsage = `SandBase agent commands (same capabilities as the sandbase_* MCP tools): sandbase discover -q "" [-t llm|api|multimodal|embedding|service] [--vendor ] [-l 10] [-j] sandbase inspect [-j] - sandbase run [-i '' | -f input.json] [--timeout 300] [--no-wait] [-o result.json] [-j] - sandbase runs get -r [--service ] [--timeout 300] [--no-wait] [-o result.json] [-j] + sandbase run [-i '' | -f input.json] [-w] [--timeout 300] [-o result.json] [-j] + sandbase runs get -r [--service ] [-w] [--timeout 300] [-o result.json] [-j] sandbase runs list [-l 10] [--model ] [--service ] [-j] sandbase balance [-j] sandbase keys add -k - (reads the key from stdin) | keys list | keys remove @@ -47,7 +47,7 @@ class Output { else this.err(`Error [${error.code}]: ${error.message}${error.runId ? `\nRun: ${error.runId}` : ""}${error.hint ? `\nNext: ${error.hint}` : ""}`); } notice(text: string): void { if (this.json) this.notes.push(text); else this.err(text); } - /** Always stderr (also in -j mode), so stdout stays parseable and an agent whose shell times out still sees it. */ + /** Always stderr (also in -j mode), so stdout stays parseable. */ progress(text: string): void { this.err(text); } } @@ -120,7 +120,7 @@ async function assertWritableOutput(path: string | undefined): Promise { /** After a run was accepted, every failure must still tell the agent how to resume it instead of re-running (and paying) again. */ function withRunContext(error: unknown, view: RunView): SandBaseError { - const resume = `sandbase runs get -r ${view.run_id}${view.service_id ? ` --service ${view.service_id}` : ""}`; + const resume = `sandbase runs get -r ${view.run_id}${view.service_id ? ` --service ${view.service_id}` : ""} -w`; if (error instanceof SandBaseError) return new SandBaseError(error.code, error.message, `${error.hint ? `${error.hint} ` : ""}The run ${view.run_id} was already started; resume with \`${resume}\` instead of running it again.`, error.status, view.run_id); return new SandBaseError("TOOL_ERROR", error instanceof Error ? error.message : "Unknown failure", `The run ${view.run_id} was already started; resume with \`${resume}\`.`, undefined, view.run_id); } @@ -175,7 +175,7 @@ function printRun(out: Output, view: RunView, outputFile?: string): void { if (outputFile) lines.push(`Saved output to ${outputFile}`); else { const text = typeof view.output === "string" ? view.output : JSON.stringify(view.output, null, 2); lines.push("Output:", text.length > 6000 ? `${text.slice(0, 6000)}\n…(truncated ${text.length - 6000} chars; rerun \`sandbase runs get -r ${view.run_id ?? ""} -o result.json\` to save everything)` : text); } } - if (phase === "running" && view.run_id) lines.push(`Next: sandbase runs get -r ${view.run_id}${view.service_id ? ` --service ${view.service_id}` : ""}${outputFile ? ` -o ${outputFile}` : ""} (waits for the result; do not run the tool again)`); + if (phase === "running" && view.run_id) lines.push(`Next: sandbase runs get -r ${view.run_id}${view.service_id ? ` --service ${view.service_id}` : ""} -w${outputFile ? ` -o ${outputFile}` : ""} (poll every 5-10s; runs take 1-300s)`); if (phase === "succeeded" && !hasOutput(view) && view.run_id) lines.push(`Output is not available yet. Retry: sandbase runs get -r ${view.run_id}${view.service_id ? ` --service ${view.service_id}` : ""}${outputFile ? ` -o ${outputFile}` : ""}`); out.print(lines.join("\n")); printHints(out, view.hints); @@ -263,7 +263,7 @@ async function inspect(argv: string[], out: Output, env: NodeJS.ProcessEnv, fetc const meta = [data.model, data.category ?? data.type, data.vendor].filter(Boolean).join(" · "); const lines = [`${tool}${meta ? ` (${meta})` : ""}`, String(data.description ?? "").trim(), "", `Price: ${priceLine}`]; lines.push("", rows.length ? `Input (send as JSON with -i):\n${table(["FIELD", "TYPE", "REQUIRED", "DESCRIPTION"], rows)}` : "Input: no parameters."); - lines.push("", "Run:", ` sandbase run ${tool} -i ${shellQuote(JSON.stringify(example))}${/video|image|audio|music|speech/i.test(`${data.category ?? ""} ${data.description ?? ""} ${tool}`) ? " -o result.json" : ""}`); + lines.push("", "Run:", ` sandbase run ${tool} -i ${shellQuote(JSON.stringify(example))}${/video|image|audio|music|speech/i.test(`${data.category ?? ""} ${data.description ?? ""} ${tool}`) ? " -w -o result.json" : ""}`); lines.push(`MCP: sandbase_run ${JSON.stringify({ name: tool, arguments: example })}`); out.print(lines.join("\n")); printHints(out, data.hints, data.notes); @@ -271,11 +271,9 @@ async function inspect(argv: string[], out: Output, env: NodeJS.ProcessEnv, fetc } async function run(argv: string[], out: Output, env: NodeJS.ProcessEnv, deps: Required>, fetchImpl?: FetchLike): Promise { - // Runs wait for the result by default; --no-wait returns right after starting. -w is kept for compatibility. - const { values, positionals } = parse(argv, { input: { type: "string", short: "i" }, file: { type: "string", short: "f" }, wait: { type: "boolean", short: "w" }, "no-wait": { type: "boolean" }, timeout: { type: "string" }, output: { type: "string", short: "o" } }); + const { values, positionals } = parse(argv, { input: { type: "string", short: "i" }, file: { type: "string", short: "f" }, wait: { type: "boolean", short: "w" }, timeout: { type: "string" }, output: { type: "string", short: "o" } }); const name = (positionals[0] ?? "").trim(); if (!name) throw new SandBaseError("USAGE", "Pass the tool name to run.", "Example: sandbase run sandbase_twitter_web_search_timeline -i '{\"keyword\":\"AI agents\"}'"); - const wait = !values["no-wait"]; if (values.input && values.file) throw new SandBaseError("USAGE", "Use either -i or -f, not both."); const timeout = positiveSeconds(values.timeout, 300); let args: Record = {}; @@ -298,8 +296,9 @@ async function run(argv: string[], out: Output, env: NodeJS.ProcessEnv, deps: Re if (typeof args.idempotency_key === "string" && view.service_id) view.idempotency_key = args.idempotency_key; if (!view.run_id) return finishRun(out, view, values.output); try { - if (wait && (phaseOf(view.status) === "running" || (phaseOf(view.status) === "succeeded" && !hasOutput(view)))) { - out.progress(`Run ${view.run_id} started; waiting for the result (up to ${timeout}s). If interrupted, resume with: sandbase runs get -r ${view.run_id}${view.service_id ? ` --service ${view.service_id}` : ""}${values.output ? ` -o ${values.output}` : ""}`); + if (values.wait && phaseOf(view.status) === "running") { + // stderr, also in -j mode: an agent whose shell times out still sees the run id to resume. + out.progress(`Run ${view.run_id} started; waiting for the result (up to ${timeout}s). If interrupted, resume with: sandbase runs get -r ${view.run_id}${view.service_id ? ` --service ${view.service_id}` : ""} -w${values.output ? ` -o ${values.output}` : ""}`); view = await pollRun(api, view, timeout, deps); } else if (phaseOf(view.status) !== "running" && view.cost === undefined && !view.service_id) { @@ -312,15 +311,15 @@ async function run(argv: string[], out: Output, env: NodeJS.ProcessEnv, deps: Re async function runs(argv: string[], out: Output, env: NodeJS.ProcessEnv, deps: Required>, fetchImpl?: FetchLike): Promise { const [sub, ...rest] = argv; if (sub === "get") { - const { values, positionals } = parse(rest, { run: { type: "string", short: "r" }, service: { type: "string" }, wait: { type: "boolean", short: "w" }, "no-wait": { type: "boolean" }, timeout: { type: "string" }, output: { type: "string", short: "o" } }); + const { values, positionals } = parse(rest, { run: { type: "string", short: "r" }, service: { type: "string" }, wait: { type: "boolean", short: "w" }, timeout: { type: "string" }, output: { type: "string", short: "o" } }); const runId = (values.run ?? positionals[0] ?? "").trim(); - if (!runId) throw new SandBaseError("USAGE", "Pass the run id with -r.", "Example: sandbase runs get -r pred_abc123 -o result.json"); + if (!runId) throw new SandBaseError("USAGE", "Pass the run id with -r.", "Example: sandbase runs get -r pred_abc123 -w -o result.json"); const timeout = positiveSeconds(values.timeout, 300); await assertWritableOutput(values.output); const api = await client(values, env, fetchImpl); let view = await readRun(api, runId, values.service); try { - if (!values["no-wait"]) view = await pollRun(api, view, timeout, deps); + if (values.wait) view = await pollRun(api, view, timeout, deps); return await finishRun(out, view, values.output); } catch (error) { throw withRunContext(error, view); } } diff --git a/test/adapters.test.ts b/test/adapters.test.ts index ca58141..df57df2 100644 --- a/test/adapters.test.ts +++ b/test/adapters.test.ts @@ -17,11 +17,11 @@ for (const client of ["codex","claude-code","cursor","hermes"] as Client[]) test assert.equal(await unregister(client,env),true); const removed=await readFile(path,"utf8"); assert.ok(!removed.includes("safe bridge")); assert.ok(removed.includes("other") || removed.includes("model")); await rollback(one); assert.equal(await readFile(path,"utf8"),original); }); -test("Codex config raises the tool timeout and tells the bridge long waits are allowed", async () => { +test("Codex config raises the tool timeout so long sync calls are not cut at 60s", async () => { const home=await mkdtemp(join(tmpdir(),"sandbase-codex-timeout-")); const env={HOME:home,CODEX_HOME:join(home,".codex")}; await mkdir(env.CODEX_HOME,{recursive:true}); await configure("codex","/sandbase-mcp-bridge.mjs",env); const text=await readFile(join(env.CODEX_HOME,"config.toml"),"utf8"); - assert.match(text,/^tool_timeout_sec = 1800$/m); assert.match(text,/SANDBASE_LONG_TOOL_CALLS = "1"/); + assert.match(text,/^tool_timeout_sec = 1800$/m); assert.equal(await isConfigured("codex",env),true); }); diff --git a/test/agent.test.ts b/test/agent.test.ts index 18a591b..7e2f8da 100644 --- a/test/agent.test.ts +++ b/test/agent.test.ts @@ -86,37 +86,23 @@ test("discover and inspect show no health column or line", async () => { assert.doesNotMatch((await cli("inspect", ["sandbase_a"], env, inspect.fetch)).out, /Health/); }); -test("run waits for the result by default and announces the run id on stderr; --no-wait returns right away", async () => { +test("run -w keeps waiting while files are transferred, drops stale hints, and announces the run id on stderr", async () => { const env = await home(); await connected(env, "codex", "http://127.0.0.1:9/v1/mcp"); let polls = 0; const { fetch } = fakeFetch(call => { const name = (call.params as { name?: string } | undefined)?.name; - if (name === "sandbase_run") return toolResult({ prediction_id: "pred_d", status: "running", output: null }); - if (name === "sandbase_run_get") return toolResult(++polls < 3 ? { run_id: "pred_d", status: "running", hints: ["Run is still in progress."] } : { run_id: "pred_d", status: "completed", output: ["https://cdn.test/d.png"] }); - return { body: { error: { code: -32601, message: "unexpected" } } }; - }); - const waited = await cli("run", ["sandbase_img", "-i", "{}"], env, fetch); - assert.equal(waited.code, 0, waited.err); assert.match(waited.out, /Status: completed/); assert.match(waited.out, /https:\/\/cdn\.test\/d\.png/); - assert.doesNotMatch(waited.out, /still in progress/, "a stale running hint never survives completion"); - assert.match(waited.err, /Run pred_d started; waiting for the result.*sandbase runs get -r pred_d/); - polls = -100; - const quick = await cli("run", ["sandbase_img", "-i", "{}", "--no-wait"], env, fetch); - assert.equal(quick.code, 0); assert.match(quick.out, /Status: running/); assert.match(quick.out, /Next: sandbase runs get -r pred_d/); -}); - -test("run keeps waiting while a completed run's files are still being transferred", async () => { - const env = await home(); await connected(env, "codex", "http://127.0.0.1:9/v1/mcp"); - let polls = 0; - const { fetch } = fakeFetch(call => { - const name = (call.params as { name?: string } | undefined)?.name; - if (name === "sandbase_run") return toolResult({ prediction_id: "pred_t", status: "running", output: null }); + if (name === "sandbase_run") return toolResult({ prediction_id: "pred_t", status: "running", output: null, hints: ["Run is still in progress."] }); if (name === "sandbase_run_get") return toolResult(++polls < 6 ? { run_id: "pred_t", status: "completed", hints: ["The run completed and its files are still being transferred. Poll sandbase_run_get again in a few seconds."] } : { run_id: "pred_t", status: "completed", output: ["https://cdn.test/t.mp4"] }); if (call.method === "tasks/result") return { body: { result: { structuredContent: { status: "completed", output: null } } } }; return { body: { error: { code: -32601, message: "unexpected" } } }; }); - const result = await cli("run", ["sandbase_video", "-i", "{}", "-j"], env, fetch); - assert.equal(result.code, 0, result.err); - assert.deepEqual(JSON.parse(result.out).output, ["https://cdn.test/t.mp4"]); + const json = await cli("run", ["sandbase_video", "-i", "{}", "-w", "-j"], env, fetch); + assert.equal(json.code, 0, json.err); + assert.deepEqual(JSON.parse(json.out).output, ["https://cdn.test/t.mp4"]); + assert.match(json.err, /Run pred_t started; waiting for the result.*sandbase runs get -r pred_t -w/); + polls = 0; + const text = await cli("run", ["sandbase_video", "-i", "{}", "-w"], env, fetch); + assert.match(text.out, /Status: completed/); assert.doesNotMatch(text.out, /still in progress|being transferred/); }); test("discover renders a table with price and next step; zero results show server hints", async () => { @@ -167,7 +153,7 @@ test("a run still going at --timeout prints the resume command; a failed run exi const env = await home(); await connected(env, "codex", "http://127.0.0.1:9/v1/mcp"); const running = fakeFetch(call => toolResult((call.params as { name?: string }).name === "sandbase_run" ? { prediction_id: "pred_2", status: "running" } : { run_id: "pred_2", status: "running" })); const slow = await cli("run", ["sandbase_video", "-i", "{}", "-w", "--timeout", "5"], env, running.fetch); - assert.equal(slow.code, 0); assert.match(slow.out, /still running when --timeout elapsed/); assert.match(slow.out, /Next: sandbase runs get -r pred_2 /); + assert.equal(slow.code, 0); assert.match(slow.out, /still running when --timeout elapsed/); assert.match(slow.out, /Next: sandbase runs get -r pred_2 -w/); const failing = fakeFetch(call => call.method === "tasks/result" ? { body: { result: { structuredContent: { status: "failed", error: { message: "content policy" } } } } } : toolResult((call.params as { name?: string }).name === "sandbase_run" ? { prediction_id: "pred_3", status: "running" } : { run_id: "pred_3", status: "failed" })); const failed = await cli("runs", ["get", "-r", "pred_3"], env, failing.fetch); assert.equal(failed.code, 1); assert.match(failed.out, /Status: failed/); assert.match(failed.out, /content policy/); @@ -213,17 +199,12 @@ test("update notice compares semver and is suppressed when disabled", async () = assert.equal(await updateNotice({ ...env, SANDBASE_NO_UPDATE_CHECK: "1" }, registry), undefined); }); -async function bridge(env: NodeJS.ProcessEnv, fetch: FetchLike, lines: unknown[], client = "codex"): Promise>> { +async function bridge(env: NodeJS.ProcessEnv, fetch: FetchLike, lines: unknown[]): Promise>> { const runtime = await import(pathToFileURL(join(process.cwd(), "assets", "mcp-bridge.mjs")).href) as { runMCPBridge(options: Record): Promise }; const input = new PassThrough(), output = new PassThrough(); let text = ""; output.on("data", chunk => { text += chunk; }); - const done = runtime.runMCPBridge({ client, env, input, output, fetchImpl: fetch, runPollMs: 5 }); - // Like a real host, keep stdin open until every request has its answer. - input.write(lines.map(line => JSON.stringify(line)).join("\n") + "\n"); - const ids = lines.map(line => (line as { id?: unknown }).id).filter(id => id !== undefined); - const answered = () => { const seen = new Set(text.split("\n").filter(Boolean).map(line => (JSON.parse(line) as { id?: unknown }).id)); return ids.every(id => seen.has(id)); }; - for (let i = 0; i < 1000 && !answered(); i++) await new Promise(resolve => setTimeout(resolve, 5)); - input.end(); + const done = runtime.runMCPBridge({ client: "codex", env, input, output, fetchImpl: fetch }); + input.end(lines.map(line => JSON.stringify(line)).join("\n") + "\n"); await done; return text.trim().split("\n").filter(Boolean).map(line => JSON.parse(line)); } @@ -248,124 +229,6 @@ test("bridge keeps the server usable offline and turns tool failures into visibl assert.equal(cachedOffline[0]!.result.tools[0].description, "live", "offline tools/list serves the last live list"); }); -test("bridge returns the finished run in the same sandbase_run call, with progress, for long-wait clients", async () => { - const env = await home(); await connected(env, "claude-code", "https://api.sandbase.ai/v1/mcp"); - let polls = 0; - const { fetch, calls } = fakeFetch(call => { - const name = (call.params as { name?: string } | undefined)?.name; - if (name === "sandbase_run") return toolResult({ prediction_id: "pred_b", run_id: "pred_b", status: "running", output: null, hints: ["Poll sandbase_run_get"] }); - if (name === "sandbase_run_get") return toolResult(++polls < 3 ? { run_id: "pred_b", status: "running" } : { run_id: "pred_b", status: "completed", cost: "0.1", output: ["https://cdn.test/b.mp4"] }); - return { body: { error: { code: -32601, message: "unexpected" } } }; - }); - const out = await bridge(env, fetch, [{ jsonrpc: "2.0", id: 7, method: "tools/call", params: { name: "sandbase_run", arguments: { name: "sandbase_video", arguments: {} }, _meta: { progressToken: "tok" } } }], "claude-code"); - const progress = out.filter(line => line.method === "notifications/progress"); - const final = out.find(line => line.id === 7)!; - assert.ok(progress.length >= 1 && progress.every(line => line.params.progressToken === "tok")); - assert.equal(final.result.isError, false); assert.equal(final.result.structuredContent.status, "completed"); - assert.match(final.result.content[0].text, /https:\/\/cdn\.test\/b\.mp4/); - assert.equal(calls.filter(call => (call.params as { name?: string }).name === "sandbase_run").length, 1, "the paid run is started once"); -}); - -test("bridge waits through file transfer and reports a failed run as an error", async () => { - const env = await home(); await connected(env, "codex", "https://api.sandbase.ai/v1/mcp"); - let polls = 0; - const transfer = fakeFetch(call => (call.params as { name?: string }).name === "sandbase_run" ? toolResult({ prediction_id: "pred_x", status: "completed", output: null, hints: ["The run completed and its files are still being transferred. Poll sandbase_run_get again in a few seconds."] }) : toolResult(++polls < 2 ? { run_id: "pred_x", status: "completed", hints: ["The run completed and its files are still being transferred."] } : { run_id: "pred_x", status: "completed", output: ["https://cdn.test/x.mp4"] })); - const [done] = await bridge({ ...env, SANDBASE_LONG_TOOL_CALLS: "1" }, transfer.fetch, [{ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "sandbase_run", arguments: { name: "sandbase_video", arguments: {} } } }]); - assert.match(done!.result.content[0].text, /x\.mp4/); - const failing = fakeFetch(call => (call.params as { name?: string }).name === "sandbase_run" ? toolResult({ prediction_id: "pred_f", status: "running" }) : toolResult({ run_id: "pred_f", status: "failed", error: { message: "content policy" } })); - const [failed] = await bridge({ ...env, SANDBASE_LONG_TOOL_CALLS: "1" }, failing.fetch, [{ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "sandbase_run", arguments: { name: "sandbase_video", arguments: {} } } }]); - assert.equal(failed!.result.isError, true); assert.match(failed!.result.content[0].text, /Run pred_f failed: content policy/); -}); - -test("Codex gets long waits only when its config carries the long tool timeout", async () => { - const env = await home(); await connected(env, "codex", "https://api.sandbase.ai/v1/mcp"); - const old = fakeFetch(() => toolResult({ prediction_id: "pred_o", status: "running" })); - await bridge(env, old.fetch, [{ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "sandbase_run", arguments: { name: "sandbase_video", arguments: {} } } }]); - assert.equal(old.calls.length, 1, "an older Codex config (60s timeout) gets no local wait on sandbase_run"); - let polls = 0; - const configured = fakeFetch(call => (call.params as { name?: string }).name === "sandbase_run" ? toolResult({ prediction_id: "pred_l", status: "running" }) : toolResult(++polls < 2 ? { run_id: "pred_l", status: "running" } : { run_id: "pred_l", status: "completed", output: ["ok"] })); - const [done] = await bridge({ ...env, SANDBASE_LONG_TOOL_CALLS: "1" }, configured.fetch, [{ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "sandbase_run", arguments: { name: "sandbase_video", arguments: {} } } }]); - assert.equal(done!.result.structuredContent.status, "completed"); -}); - -test("bridge stops waiting on repeated poll failures or a revoked sign-in, and passes run_get tool errors through", async () => { - const env = await home(); await connected(env, "claude-code", "https://api.sandbase.ai/v1/mcp"); - const outage = fakeFetch(call => (call.params as { name?: string }).name === "sandbase_run" ? toolResult({ prediction_id: "pred_e", run_id: "pred_e", status: "running", hints: ["Poll sandbase_run_get"] }) : { status: 502, body: "bad gateway" }); - const [stale] = await bridge(env, outage.fetch, [{ jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: "sandbase_run", arguments: { name: "sandbase_video", arguments: {} } } }], "claude-code"); - assert.equal(stale!.id, 4); assert.equal(stale!.result.structuredContent.run_id, "pred_e", "the agent keeps the run id to resume"); - assert.equal(outage.calls.length, 1 + 5, "gives up after 5 consecutive poll failures"); - const revoked = fakeFetch(call => (call.params as { name?: string }).name === "sandbase_run" ? toolResult({ prediction_id: "pred_r", status: "running" }) : { status: 401, body: '{"error":"invalid API key"}' }); - await bridge(env, revoked.fetch, [{ jsonrpc: "2.0", id: 5, method: "tools/call", params: { name: "sandbase_run", arguments: { name: "sandbase_video", arguments: {} } } }], "claude-code"); - assert.equal(revoked.calls.length, 2, "a 401 stops the wait at once"); - const missing = fakeFetch(call => (call.params as { name?: string }).name === "sandbase_run" ? toolResult({ prediction_id: "pred_m", status: "running" }) : { body: { result: { content: [{ type: "text", text: "Run 'pred_m' was not found" }], structuredContent: { error: { code: "run_not_found" } }, isError: true } } }); - const [notFound] = await bridge(env, missing.fetch, [{ jsonrpc: "2.0", id: 6, method: "tools/call", params: { name: "sandbase_run", arguments: { name: "sandbase_video", arguments: {} } } }], "claude-code"); - assert.equal(notFound!.id, 6); assert.equal(notFound!.result.isError, true); assert.match(notFound!.result.content[0].text, /not found/); -}); - -test("a long local wait does not block other calls, and notifications/cancelled ends it without a response", async () => { - const env = await home(); await connected(env, "claude-code", "https://api.sandbase.ai/v1/mcp"); - const runtime = await import(pathToFileURL(join(process.cwd(), "assets", "mcp-bridge.mjs")).href) as { runMCPBridge(options: Record): Promise }; - const { fetch } = fakeFetch(call => (call.params as { name?: string } | undefined)?.name === "sandbase_run" ? toolResult({ prediction_id: "pred_s", status: "running" }) : call.method === "tools/call" && (call.params as { name?: string }).name === "sandbase_account" ? toolResult({ balance: "1" }) : toolResult({ run_id: "pred_s", status: "running" })); - const input = new PassThrough(), output = new PassThrough(); const lines: Array> = []; - output.on("data", chunk => { for (const line of String(chunk).split("\n").filter(Boolean)) lines.push(JSON.parse(line)); }); - const done = runtime.runMCPBridge({ client: "claude-code", env, input, output, fetchImpl: fetch, runPollMs: 5 }); - input.write(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "sandbase_run", arguments: { name: "sandbase_video", arguments: {} } } }) + "\n"); - input.write(JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "sandbase_account", arguments: {} } }) + "\n"); - for (let i = 0; i < 200 && !lines.some(line => line.id === 2); i++) await new Promise(resolve => setTimeout(resolve, 5)); - assert.ok(lines.some(line => line.id === 2), "another call is answered while the run is still being waited on"); - assert.ok(!lines.some(line => line.id === 1), "the run is still waiting"); - input.end(JSON.stringify({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: 1 } }) + "\n"); - await done; - assert.ok(!lines.some(line => line.id === 1), "a cancelled request gets no response"); -}); - -test("a cancel during the first server wait is honored, and closing stdin stops local waits", async () => { - const env = await home(); await connected(env, "claude-code", "https://api.sandbase.ai/v1/mcp"); - const runtime = await import(pathToFileURL(join(process.cwd(), "assets", "mcp-bridge.mjs")).href) as { runMCPBridge(options: Record): Promise }; - let release: () => void = () => {}; - const held = new Promise(resolve => { release = resolve; }); - let polls = 0; - const slow: FetchLike = async (url, init) => { - const request = JSON.parse(init.body) as { id: unknown; params?: { name?: string } }; - if (request.params?.name === "sandbase_run") await held; - if (request.params?.name === "sandbase_run_get") polls++; - const data = { prediction_id: "pred_h", status: "running" }; - return { ok: true, status: 200, headers: { get: () => "application/json" }, text: async () => JSON.stringify({ jsonrpc: "2.0", id: request.id, result: { content: [{ type: "text", text: "" }], structuredContent: data, isError: false } }) }; - }; - const input = new PassThrough(), output = new PassThrough(); const lines: Array> = []; - output.on("data", chunk => { for (const line of String(chunk).split("\n").filter(Boolean)) lines.push(JSON.parse(line)); }); - const done = runtime.runMCPBridge({ client: "claude-code", env, input, output, fetchImpl: slow, runPollMs: 5 }); - input.write(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "sandbase_run", arguments: { name: "sandbase_video", arguments: {} } } }) + "\n"); - await new Promise(resolve => setTimeout(resolve, 20)); - input.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: 1 } }) + "\n"); - await new Promise(resolve => setTimeout(resolve, 20)); - release(); - await new Promise(resolve => setTimeout(resolve, 50)); - assert.equal(polls, 0, "no local wait starts for a request cancelled while it was still at the server"); - assert.ok(!lines.some(line => line.id === 1), "and it gets no response"); - - // stdin closing while a long local wait is in progress ends the bridge promptly. - const env2 = await home(); await connected(env2, "claude-code", "https://api.sandbase.ai/v1/mcp"); - const running = fakeFetch(() => toolResult({ prediction_id: "pred_w", status: "running" })); - const input2 = new PassThrough(), output2 = new PassThrough(); - const done2 = runtime.runMCPBridge({ client: "claude-code", env: env2, input: input2, output: output2, fetchImpl: running.fetch, runPollMs: 5 }); - input2.write(JSON.stringify({ jsonrpc: "2.0", id: 9, method: "tools/call", params: { name: "sandbase_run", arguments: { name: "sandbase_video", arguments: {} } } }) + "\n"); - for (let i = 0; i < 100 && running.calls.length < 3; i++) await new Promise(resolve => setTimeout(resolve, 5)); - const started = Date.now(); input2.end(); await done2; input.end(); await done; - assert.ok(Date.now() - started < 1000, "the bridge exits instead of polling for 20 minutes"); -}); - -test("bridge leaves short-timeout clients' sandbase_run alone and Agent Service runs untouched", async () => { - const env = await home(); await connected(env, "cursor", "https://api.sandbase.ai/v1/mcp"); - const { fetch, calls } = fakeFetch(() => toolResult({ prediction_id: "pred_c", status: "running" })); - const [result] = await bridge(env, fetch, [{ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "sandbase_run", arguments: { name: "sandbase_video", arguments: {} } } }], "cursor"); - assert.equal(result!.result.structuredContent.status, "running"); assert.equal(calls.length, 1, "no local wait inside a client with a 60s tool timeout"); - const env2 = await home(); await connected(env2, "claude-code", "https://api.sandbase.ai/v1/mcp"); - const svc = fakeFetch(() => toolResult({ run_id: "r1", service_id: "svc_1", status: "running" })); - const [service] = await bridge(env2, svc.fetch, [{ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "sandbase_run", arguments: { name: "svc_1", arguments: { idempotency_key: "k" } } } }], "claude-code"); - assert.equal(service!.result.structuredContent.status, "running"); assert.equal(svc.calls.length, 1); -}); - function resetAfterSend(): Error { const error = new TypeError("fetch failed") as TypeError & { cause?: unknown }; error.cause = { code: "ECONNRESET" }; return error; } test("a paid run interrupted after sending is never retried (double-bill guard)", async () => { @@ -402,7 +265,7 @@ test("unknown statuses keep polling, plain-text sync results complete, server ru const env = await home(); await connected(env, "codex", "http://127.0.0.1:9/v1/mcp"); const queued = fakeFetch(() => toolResult({ prediction_id: "pred_q", status: "IN_QUEUE" })); const q = await cli("run", ["sandbase_fal", "-i", "{}"], env, queued.fetch); - assert.equal(q.code, 0); assert.match(q.out, /Next: sandbase runs get -r pred_q /); + assert.equal(q.code, 0); assert.match(q.out, /Next: sandbase runs get -r pred_q -w/); const text = fakeFetch(() => ({ body: { result: { content: [{ type: "text", text: "hello world" }], isError: false } } })); const plain = await cli("run", ["sandbase_llm", "-i", "{}"], env, text.fetch); assert.equal(plain.code, 0); assert.match(plain.out, /Status: completed/); assert.match(plain.out, /hello world/); @@ -415,7 +278,7 @@ test("failures after a run started keep the run id; an unwritable -o fails befor const env = await home(); await connected(env, "codex", "http://127.0.0.1:9/v1/mcp"); const { fetch, calls } = fakeFetch(call => (call.params as { name?: string }).name === "sandbase_run" ? toolResult({ prediction_id: "pred_k", status: "running" }) : connectTimeout()); const lost = JSON.parse((await cli("run", ["sandbase_video", "-i", "{}", "-w", "-j"], env, fetch)).out); - assert.equal(lost.run_id, "pred_k"); assert.match(lost.error.hint, /sandbase runs get -r pred_k`/); + assert.equal(lost.run_id, "pred_k"); assert.match(lost.error.hint, /sandbase runs get -r pred_k -w/); const before = calls.length; const bad = await cli("run", ["sandbase_video", "-i", "{}", "-o", join(env.HOME!, "missing-dir", "x.json")], env, fetch); assert.equal(bad.code, 2); assert.match(bad.err, /nothing was run/); assert.equal(calls.length, before);