From df3d8888aed2ff5bbaa60a434c2fadc07096dc7a Mon Sep 17 00:00:00 2001 From: max bittker Date: Mon, 12 Oct 2026 14:30:19 -0400 Subject: [PATCH] feat(sdk,mcp): cancellable execute_code, bot.mineRock, bounded walkTo execute_code - Cancellation now reaches inside the SDK via an AsyncLocalStorage scope: timeout, MCP cancel, or a newer call rejects pending waits and blocks further action packets, including those inside walkTo(). - One execution per bot: a new call supersedes the running one and starts once it has unwound; disconnect_bot does the same. - Cancelled user code parks on bot/sdk calls instead of throwing, so a catch-all loop can't spin and starve the server. - Console capture is routed per execution, so concurrent bots no longer clobber each other's output; leftover logs go to stderr. - Stale world state warns and still runs, so a stale client can be diagnosed. - Credentials are redacted from logs, results and launch messages. SDK - bot.mineRock() waits for ore plus Mining XP; interactLoc docs note that success can mean the action only started. - walkTo() takes options.timeout (default 120s), refuses stale state, and reports the real last position. - runScript uses the same cancellation scope for timeouts and signals. Docs trimmed of duplicated or stale execute_code text. Co-Authored-By: Claude Opus 5.5 --- README.md | 22 ++- bots/create-bot.ts | 3 + learnings/README.md | 9 +- learnings/mining.md | 49 ++--- learnings/smithing.md | 48 ++--- mcp/README.md | 68 ++----- mcp/api/index.ts | 34 ++-- mcp/console-capture.ts | 35 ++++ mcp/execute-code.ts | 153 ++++++++++++++++ mcp/server.ts | 191 +------------------ sdk/API.md | 26 ++- sdk/actions-helpers.ts | 9 +- sdk/actions.ts | 98 +++++++++- sdk/execution-context.ts | 73 ++++++++ sdk/generate-api-docs.ts | 1 + sdk/index.ts | 170 ++++++++--------- sdk/redact.ts | 6 + sdk/runner.ts | 33 +--- sdk/test/action-dispatch-failure.test.ts | 7 + sdk/test/execution-cancellation.test.ts | 224 +++++++++++++++++++++++ sdk/test/mine-rock.test.ts | 67 +++++++ sdk/types.ts | 11 ++ wiki/skills/mining.md | 14 +- 23 files changed, 910 insertions(+), 441 deletions(-) create mode 100644 mcp/execute-code.ts create mode 100644 sdk/execution-context.ts create mode 100644 sdk/redact.ts create mode 100644 sdk/test/execution-cancellation.test.ts create mode 100644 sdk/test/mine-rock.test.ts diff --git a/README.md b/README.md index b30484901..28f57282e 100644 --- a/README.md +++ b/README.md @@ -40,13 +40,19 @@ bun install bun bots/create-bot.ts {username} ``` -A script drives a running game client; it does not start one. Start the -headless lite client in its own terminal and leave it running: +Use MCP `execute_code` for interactive play; it opens a browser client for the +bot automatically. Keep that browser tab running when switching to a saved +script. To start the client manually, open +`https://rs-sdk-demo.fly.dev/bot?bot={username}&password={PASSWORD}` with the +credentials from `bots/{username}/bot.env` (use your server's origin for a local +or custom server). + +Standalone scripts drive an attached game client and never start one. For +large fleets of many bots, the optional headless lite client avoids maintaining +many browser tabs. Run one process per bot and leave it running: ```sh cd server/webclient && bun install && bun src/lite/runner.ts {username} ``` -(Or open `https://rs-sdk-demo.fly.dev/bot?bot={username}&password={PASSWORD}` -in a browser, with the password from `bots/{username}/bot.env`.) Then, from the repo root: ```sh @@ -63,10 +69,8 @@ browser-client dispatch and do not prove the server applied the effect. MCP `execute_code` snippets receive `bot` and `sdk` globals. Standalone files under `bots//` instead use `runScript(async ({ bot, sdk }) => { ... })`; -see [`learnings/README.md`](learnings/README.md) for copyable examples. -The current executor evaluates JavaScript-compatible async bodies directly, so -use the TypeScript API reference for type information but omit type-only syntax -from an `execute_code` body. +see [`learnings/README.md`](learnings/README.md) for copyable examples and +[`mcp/README.md`](mcp/README.md) for `execute_code` behaviour. Chat is shown by default. Note that seeing other players' chat exposes the bot to scamming and prompt-injection attempts; opt out with `SHOW_CHAT=false` in the bot.env file (or `bun bots/create-bot.ts --no-chat`). @@ -93,7 +97,7 @@ Once connected to the gateway, the botclient relays game state to the SDK and dispatches low-level actions such as `sendWalk(x, z)` from the SDK. Dispatch success is not confirmation that the game server applied the intended effect. -This means that the SDK can't talk directly to the game server, but must go through a client: the browser botclient or the headless lite client (`server/webclient/src/lite`). The MCP server opens a browser tab if none is running; standalone `runScript` scripts never launch one and exit with an error when no client is attached. +This means that the SDK can't talk directly to the game server, but must go through a client: the browser botclient or the headless lite client (`server/webclient/src/lite`). You don't need to run your own gateway or web client bundler in order to run automations against the demo server, but you may choose to if you are fixing bugs or adding features to the rs-sdk project diff --git a/bots/create-bot.ts b/bots/create-bot.ts index 469e2cd1a..6583801d2 100644 --- a/bots/create-bot.ts +++ b/bots/create-bot.ts @@ -96,6 +96,9 @@ async function createBot(username?: string, serverOverride?: string, hideChat?: console.log(`\n✓ Bot "${botUsername}" created successfully!`); console.log(`\nCredentials saved in bots/${botUsername}/bot.env`); console.log(`\nTo get started:`); + console.log(` Use MCP execute_code on "${botUsername}" — it opens the browser client automatically.`); + console.log(` For a standalone script, first open the browser bot client using the credentials in bot.env and leave it running.`); + console.log(` Scripts do not start a game client. Once the browser is attached, run:`); console.log(` bun bots/${botUsername}/script.ts`); } diff --git a/learnings/README.md b/learnings/README.md index e6d70fb4a..56941f57f 100644 --- a/learnings/README.md +++ b/learnings/README.md @@ -2,9 +2,7 @@ Unless a section explicitly says otherwise, snippets in this directory are written for the MCP `execute_code` environment. That environment provides -`bot` and `sdk` as globals and supports top-level `await`. Although the API -reference uses TypeScript notation, keep `execute_code` bodies -JavaScript-compatible and omit type-only syntax: +`bot` and `sdk` as globals and accepts TypeScript with top-level `await`: ```typescript const state = sdk.getState(); @@ -26,9 +24,8 @@ await runScript(async ({ bot, sdk }) => { For a long or background run, execute the standalone file with `bun bots//script.ts`. Do not launch a detached bare snippet: `runScript` -owns connection setup, timeout reporting, signal handling, and shutdown. - -Do not paste context-prefixed runner expressions into `execute_code`. +owns connection setup, timeout reporting, signal handling, and shutdown. Keep +the browser client opened by MCP running: standalone scripts do not start a client. ## Action semantics diff --git a/learnings/mining.md b/learnings/mining.md index 6ebaed9b6..1cbd8ec03 100644 --- a/learnings/mining.md +++ b/learnings/mining.md @@ -6,19 +6,23 @@ Successful patterns for mining automation. Rocks are **locations** (not NPCs). Filter for rocks with a "Mine" option: +The client names live rocks `Rocks copper ore`, `Rocks tin ore`, and so on. +Use `/copper ore/i` to find a rock; `/^copper ore$/i` matches the inventory item only. +Depleted rocks can appear as plain `Rocks`; select a live ore-specific rock again. + ```typescript const state = sdk.getState(); const rock = state?.nearbyLocs - .filter(loc => /rocks?/i.test(loc.name)) - .filter(loc => loc.optionsWithIndex.some(o => /^mine$/i.test(o.text))) - .sort((a, b) => a.distance - b.distance)[0]; + .filter((loc) => /rocks?/i.test(loc.name)) + .filter((loc) => loc.optionsWithIndex.some((o) => /^mine$/i.test(o.text))) + .sort((a, b) => a.distance - b.distance)[0]; ``` ## Mining Action ```typescript -if (!rock) throw new Error('No mineable rock nearby'); -const result = await bot.interactLoc(rock, 'mine'); +if (!rock) throw new Error("No mineable rock nearby"); +const result = await bot.mineRock(rock); if (!result.success) console.warn(result.reason, result.message); ``` @@ -36,18 +40,15 @@ const isMining = animation === 625; const isIdle = animation === -1; ``` - - - **Note:** Al Kharid mine is full of Lvl 14 scorpions. Combat 27+ with defensive style is enough to survive while mining. The scorpion fights actually train Defence passively. ## Reliable Locations -| Location | Coordinates | Notes | -|----------|-------------|-------| -| SE Varrock mine | (3285, 3365) | Copper, tin, iron | -| Al Kharid mine | (3295, 3287) | Iron, coal, gold, silver, mithril, tin. Scorpions! | -| Lumbridge Swamp mine | - | Interactions fail silently, avoid | +| Location | Coordinates | Notes | +| -------------------- | ------------ | -------------------------------------------------- | +| SE Varrock mine | (3285, 3365) | Copper, tin, iron | +| Al Kharid mine | (3295, 3287) | Iron, coal, gold, silver, mithril, tin. Scorpions! | +| Lumbridge Swamp mine | - | | **Getting to Al Kharid mine from Lumbridge:** Pay 10gp toll at gate (3268, 3227), walk NE. Dialog sequence: continue → continue → "Yes, ok." (index 3) → continue. @@ -55,11 +56,11 @@ const isIdle = animation === -1; ```typescript function countOre() { - const state = sdk.getState(); - if (!state) return 0; - return state.inventory - .filter(i => /ore$/i.test(i.name)) - .reduce((sum, i) => sum + i.count, 0); + const state = sdk.getState(); + if (!state) return 0; + return state.inventory + .filter((i) => /ore$/i.test(i.name)) + .reduce((sum, i) => sum + i.count, 0); } ``` @@ -67,12 +68,12 @@ function countOre() { ```typescript const state = sdk.getState(); -if (!state) throw new Error('No world state'); +if (!state) throw new Error("No world state"); if (state.inventory.length >= 28) { - const ores = state.inventory.filter(i => /ore$/i.test(i.name)); - for (const ore of ores) { - await sdk.sendDropItem(ore.slot); - await new Promise(r => setTimeout(r, 100)); - } + const ores = state.inventory.filter((i) => /ore$/i.test(i.name)); + for (const ore of ores) { + await sdk.sendDropItem(ore.slot); + await new Promise((r) => setTimeout(r, 100)); + } } ``` diff --git a/learnings/smithing.md b/learnings/smithing.md index c2fe45139..44ca0f4dc 100644 --- a/learnings/smithing.md +++ b/learnings/smithing.md @@ -13,14 +13,15 @@ portable low-level fallback: ```typescript const state = sdk.getState(); -if (!state) throw new Error('No world state'); -const copper = state.inventory.find(i => /copper ore/i.test(i.name)); +if (!state) throw new Error("No world state"); +const copper = state.inventory.find((i) => /copper ore/i.test(i.name)); const furnace = sdk.findNearbyLoc(/furnace/i, { withOption: /smelt/i }); -if (!copper || !furnace) throw new Error('Copper ore and a usable nearby furnace are required'); +if (!copper || !furnace) + throw new Error("Copper ore and a usable nearby furnace are required"); await sdk.sendUseItemOnLoc(copper.slot, furnace.x, furnace.z, furnace.id); await sdk.waitForCondition( - next => next.inventory.some(item => /bronze bar/i.test(item.name)), - 10_000, + (next) => next.inventory.some((item) => /bronze bar/i.test(item.name)), + 10_000, ); ``` @@ -30,16 +31,17 @@ await sdk.waitForCondition( - Each bronze bar gives **155 xp** (at level 1-14 range, leveled from 1→14 with 10 bars = 1550 xp total, so ~155 xp per smelt) ### Furnace Locations -| Location | Coordinates | Furnace IDs | Notes | -|----------|-------------|-------------|-------| -| Lumbridge furnace | (3225, 3256) | 2785, 2781 | Right next to spawn, reliable. 2781 has `Smelt`; 2785 is a decoy. -| Al Kharid furnace | (3273, 3184) | 2785, 2781 | Needs 10gp toll gate. 2781 has `Smelt`; 2785 is a decoy. +| Location | Coordinates | Furnace IDs | Notes | +| ----------------- | ------------ | ----------- | ----------------------------------------------------------------- | +| Lumbridge furnace | (3225, 3256) | 2785, 2781 | Right next to spawn, reliable. 2781 has `Smelt`; 2785 is a decoy. | +| Al Kharid furnace | (3273, 3184) | 2785, 2781 | Needs 10gp toll gate. 2781 has `Smelt`; 2785 is a decoy. | ### Anvil Locations -| Location | Coordinates | Anvil ID | Notes | -|----------|-------------|----------|-------| -| Varrock (west) | (3188, 3421) | 2783 | Multiple anvils in smithy, near west bank | + +| Location | Coordinates | Anvil ID | Notes | +| -------------- | ------------ | -------- | ----------------------------------------- | +| Varrock (west) | (3188, 3421) | 2783 | Multiple anvils in smithy, near west bank | Also anvils at (3188, 3424) and (3188, 3426) — same building, same id 2783. @@ -48,9 +50,9 @@ Also anvils at (3188, 3424) and (3188, 3426) — same building, same id 2783. Prefer the high-level helper, which opens the interface and observes Smithing XP: ```typescript -const result = await bot.smithAtAnvil('dagger', { - barPattern: /bronze bar/i, - timeout: 10_000, +const result = await bot.smithAtAnvil("dagger", { + barPattern: /bronze bar/i, + timeout: 10_000, }); if (!result.success) console.warn(result.reason, result.message); ``` @@ -58,25 +60,29 @@ if (!result.success) console.warn(result.reason, result.message); **Requires hammer in inventory** (buy from Lumbridge General Store for 1gp). ### XP Rates + - Smelting bronze bar: ~155 xp per bar (includes level-up bonuses) - Smithing bronze dagger: ~312 xp per dagger at level 14-26 range - 10 bars smelted: 1→14 Smithing - 10 daggers smithed: 14→26 Smithing ### Workflow -1. Mine equal copper (rock ids 2090/2091) and tin (rock ids 2093/2094) at SE Varrock mine (3285, 3365) + +1. Mine equal copper (rock ids 2090/2091) and tin (rock ids 2094/2095) at SE Varrock mine (3285, 3365) 2. Walk to Lumbridge furnace (3225, 3256) → smelt into bronze bars 3. Walk to Varrock anvil (3188, 3421) → smith into items (requires hammer) ### Key Locations for Full Loop -| Step | Location | Coordinates | -|------|----------|-------------| -| Mine | SE Varrock mine | (3285, 3365) | -| Smelt | Lumbridge furnace | (3225, 3256) | -| Smith | Varrock anvil | (3188, 3421) | + +| Step | Location | Coordinates | +| ---------- | ----------------------- | ------------ | +| Mine | SE Varrock mine | (3285, 3365) | +| Smelt | Lumbridge furnace | (3225, 3256) | +| Smith | Varrock anvil | (3188, 3421) | | Buy hammer | Lumbridge General Store | (3210, 3244) | ### Route: Mine → Furnace → Anvil + This is a long loop. Consider using Varrock West Bank (nearby anvils) to store bars between trips. Use `sdk/API.md` for the current `smithAtAnvil` product and option signature diff --git a/mcp/README.md b/mcp/README.md index 9df12d7fe..506167e51 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -34,7 +34,13 @@ Use `get_market` with `operation: "status"` to check whether the server enables Disabled servers return a clear `ge_unavailable` error for market data reads. ```json -{"origin":"http://localhost:8888","operation":"items","query":"logs","sort":"buyValue","days":7} +{ + "origin": "http://localhost:8888", + "operation": "items", + "query": "logs", + "sort": "buyValue", + "days": 7 +} ``` Operations: `status`, `items`, `item` (requires `item_id`), `history` (requires `item_id`). @@ -42,10 +48,17 @@ Use the game HTTP origin, not the gateway port. This tool cannot trade or read private offers. See [the GE guide](../sdk/MARKET.md) for typed options and trading through `bot.openGE()`, `bot.placeGEOffer()`, `bot.cancelGEOffer()` and collection. - ### `execute_code` -Execute TypeScript code on a bot. Auto-connects on first use using credentials from `bots/{name}/bot.env`. +Execute TypeScript on a bot. The first call auto-connects with the credentials +in `bots/{name}/bot.env` and opens a browser game client; leave it running if +you switch to a standalone script. + +Each bot runs one execution at a time: a new call cancels the one still running. +Timeout, MCP cancellation, or a newer call stops further SDK waits and action +packets, including those inside `walkTo()`; packets already sent may still take +effect. Different bots run concurrently. If world state is stale, the code +still runs and the output starts with a warning. ```typescript execute_code({ @@ -85,25 +98,6 @@ The server exposes API documentation as a resource: - `file://../sdk/MARKET.md` — GE workflow, market data, receipts and recovery. - `file://../sdk/API.md` — Auto-generated reference for `bot.*` (high-level actions) and `sdk.*` (low-level SDK) -Read this to discover available methods. - -## Multiple Bots - -Control multiple bots simultaneously — each auto-connects on first use: - -```typescript -// Execute on different bots (auto-connects each) -execute_code({ - bot_name: "woodcutter", - code: "await bot.chopTree()", -}); - -execute_code({ - bot_name: "miner", - code: "await bot.interactLoc(/^rocks$/i, 'mine')", -}); -``` - ## Manual Setup (without auto-discovery) If you're not using Claude Code's auto-discovery, add to your MCP client config: @@ -129,7 +123,10 @@ bun run mcp/server.ts ``` mcp/ -├── server.ts # MCP server (stdio transport) +├── server.ts # MCP server (stdio transport) and tool definitions +├── execute-code.ts # execute_code: per-bot cancellation, console capture, output +├── console-capture.ts # Bounded, per-execution console buffer +├── market-tools.ts # get_market └── api/ └── index.ts # BotManager - manages multiple connections ``` @@ -143,11 +140,6 @@ The `@modelcontextprotocol/sdk` dependency lives in the root `package.json`. - Create the bot first: `bun bots/create-bot.ts {name}` - Check `bots/{name}/bot.env` exists -**"Bot is not connected"** - -- Bots auto-connect on the first `execute_code` call — check the error output for connection failures -- Use `list_bots` to see connected bots - **"Connection failed"** - Check the gateway is running @@ -162,23 +154,3 @@ The `@modelcontextprotocol/sdk` dependency lives in the root `package.json`. ## API Reference See [`sdk/API.md`](../sdk/API.md) for the full auto-generated API documentation. - -### High-Level Bot Actions - -- Movement: `walkTo(x, z)` -- Skills: `chopTree()`, `burnLogs()`, `fletchLogs()`, `smithAtAnvil()`, `craftLeather()` -- Combat: `attack(target)`, `eatFood(target)`, `castSpell(target, spell)` -- Interaction: `interactLoc(target, option)`, `interactNpc(target, option)`, `talkTo(target)` -- Banking: `openBank()`, `depositItem()`, `withdrawItem()` -- Shopping: `openShop()`, `buyFromShop()`, `sellToShop()` -- Crafting: `smithAtAnvil()`, `fletchLogs()`, `craftLeather()` -- UI: `dismissBlockingUI()`, `skipTutorial()` - -### Low-Level SDK Methods - -- State: `getState()`, `getStateAge()` -- Inventory: `getInventory()`, `findInventoryItem(pattern)` -- NPCs: `getNearbyNpcs()`, `findNearbyNpc(pattern)` -- Locations: `getNearbyLocs()`, `findNearbyLoc(pattern)` -- Actions: `sendWalk()`, `sendInteractLoc()`, `sendInteractNpc()` -- Utilities: `findPath()`, `waitForCondition()` diff --git a/mcp/api/index.ts b/mcp/api/index.ts index 566ed7f24..ecc14f872 100644 --- a/mcp/api/index.ts +++ b/mcp/api/index.ts @@ -6,8 +6,11 @@ import { BotActions } from '../../sdk/actions'; import { readFile } from 'fs/promises'; import { join } from 'path'; import { existsSync } from 'fs'; +import { withExecutionDeadline, throwIfExecutionAborted } from '../../sdk/execution-context'; export interface BotConnection { + /** Folder name as supplied at first connection; lookup is case-insensitive. */ + name?: string; sdk: BotSDK; bot: BotActions; username: string; @@ -30,9 +33,10 @@ class BotManager { * If password is not provided, loads credentials from bots/{name}/bot.env */ async connect(name: string, password?: string, gatewayUrl?: string): Promise { + const key = name.toLowerCase(); // Check if already connected - if (this.connections.has(name)) { - const existing = this.connections.get(name)!; + if (this.connections.has(key)) { + const existing = this.connections.get(key)!; if (existing.connected) { return existing; } @@ -86,7 +90,7 @@ class BotManager { console.error(`[MCP] Connecting bot "${name}":`); console.error(`[MCP] username: ${username}`); console.error(`[MCP] gateway: ${gateway}`); - console.error(`[MCP] password: ${pwd ? pwd.substring(0, 3) + '...' : 'MISSING'}`); + console.error('[MCP] credentials loaded'); const sdk = new BotSDK({ botUsername: username, @@ -106,6 +110,7 @@ class BotManager { console.error(`[MCP] Bot "${name}" connected!`); const connection: BotConnection = { + name, sdk, bot, username, @@ -124,21 +129,18 @@ class BotManager { } }); - this.connections.set(name, connection); + this.connections.set(key, connection); return connection; } private async connectWithRetry(sdk: BotSDK, maxAttempts = 3, timeoutMs = 30000): Promise { for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { - let timeoutId: ReturnType; - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => reject(new Error(`Connection timed out after ${timeoutMs / 1000}s`)), timeoutMs); - }); - - await Promise.race([sdk.connect(), timeoutPromise]).finally(() => clearTimeout(timeoutId!)); + await withExecutionDeadline(timeoutMs, () => sdk.connect(), () => { throw new Error(`Connection timed out after ${timeoutMs / 1000}s`); }); return; // success } catch (err) { + await sdk.disconnect().catch(() => {}); + throwIfExecutionAborted(); const msg = err instanceof Error ? err.message : String(err); if (attempt < maxAttempts) { const delay = attempt * 2000; // 2s, 4s backoff @@ -155,7 +157,7 @@ class BotManager { * Disconnect a bot by name */ async disconnect(name: string): Promise { - const connection = this.connections.get(name); + const connection = this.connections.get(name.toLowerCase()); if (!connection) { throw new Error(`Bot "${name}" is not connected`); } @@ -164,9 +166,9 @@ class BotManager { if (connection.unsubscribeConnectionState) { connection.unsubscribeConnectionState(); } - connection.sdk.disconnect(); + await connection.sdk.disconnect(); connection.connected = false; - this.connections.delete(name); + this.connections.delete(name.toLowerCase()); console.error(`[MCP] Bot "${name}" disconnected`); } @@ -174,7 +176,7 @@ class BotManager { * Get a bot connection by name */ get(name: string): BotConnection | undefined { - return this.connections.get(name); + return this.connections.get(name.toLowerCase()); } /** @@ -182,7 +184,7 @@ class BotManager { */ list(): Array<{ name: string; username: string; connected: boolean }> { return Array.from(this.connections.entries()).map(([name, conn]) => ({ - name, + name: conn.name ?? name, username: conn.username, connected: conn.connected })); @@ -192,7 +194,7 @@ class BotManager { * Check if a bot is connected */ has(name: string): boolean { - return this.connections.has(name); + return this.connections.has(name.toLowerCase()); } private parseEnv(content: string): Record { diff --git a/mcp/console-capture.ts b/mcp/console-capture.ts index 0860f04bd..3e7665bb9 100644 --- a/mcp/console-capture.ts +++ b/mcp/console-capture.ts @@ -9,6 +9,41 @@ * and count everything dropped in between so memory stays constant. */ +import { executionContext } from '../sdk/execution-context'; +import { redactCredentials } from '../sdk/redact'; + +export function formatLogArgument(value: unknown): string { + let text: string; + if (value instanceof Error) text = value.stack ?? `${value.name}: ${value.message}`; + else if (value instanceof RegExp) text = String(value); + else if (typeof value === 'object' && value !== null) { + try { + text = JSON.stringify(value, (key, item) => /^(password|token|secret|api[_-]?key)$/i.test(key) + ? '[REDACTED]' : item instanceof RegExp ? String(item) : item, 2) ?? String(value); + } catch { text = String(value); } + } else text = String(value); + return redactCredentials(text); +} + +const LEVELS = { log: 'log', info: 'log', debug: 'log', dir: 'log', warn: 'warn', error: 'error' } as const; +let restore: (() => void) | undefined; + +/** Route each execution's console (user code and SDK alike) to its own buffer; keep everything else off MCP stdout. Idempotent. */ +export function installExecutionConsoleCapture(): () => void { + if (restore) return restore; + const original = { ...console }; + for (const [method, level] of Object.entries(LEVELS) as [keyof typeof LEVELS, 'log' | 'warn' | 'error'][]) { + console[method] = (...args: unknown[]) => { + const context = executionContext.getStore(); + // A finished execution's leftover timers still carry its scope; nobody reads that buffer. + if (context?.log && !context.signal.aborted) context.log(level, args); + else original.error(...args.map(formatLogArgument)); + }; + } + restore = () => { Object.assign(console, original); restore = undefined; }; + return restore; +} + export const CONSOLE_HEAD_LINES = 300; export const CONSOLE_TAIL_LINES = 300; export const CONSOLE_MAX_LINE_CHARS = 2000; diff --git a/mcp/execute-code.ts b/mcp/execute-code.ts new file mode 100644 index 000000000..144860691 --- /dev/null +++ b/mcp/execute-code.ts @@ -0,0 +1,153 @@ +import type { BotConnection } from './api/index'; +import { ConsoleCapture, formatLogArgument, installExecutionConsoleCapture } from './console-capture'; +import { executionContext, abortable, throwIfExecutionAborted } from '../sdk/execution-context'; +import { formatWorldState } from '../sdk/formatter'; +import { Spells } from '../sdk/spells'; +import { redactCredentials } from '../sdk/redact'; + +/** One execution per character: a new call cancels the running one and starts once it has unwound. */ +export class BotExecutions { + private current = new Map }>(); + + async run(name: string, signal: AbortSignal | undefined, task: (controller: AbortController) => Promise): Promise { + const key = name.toLowerCase(); + const previous = this.current.get(key); + previous?.controller.abort(new Error('Superseded by a newer call for this bot')); + const controller = new AbortController(); + const onClientAbort = () => controller.abort(signal?.reason ?? new Error('Cancelled by client')); + signal?.addEventListener('abort', onClientAbort, { once: true }); + if (signal?.aborted) onClientAbort(); + const done = (async () => { + await previous?.done; // the old run releases the bot before the new one touches it + controller.signal.throwIfAborted(); + return task(controller); + })(); + const entry = { controller, done: done.catch(() => {}) }; + this.current.set(key, entry); + try { + return await done; + } finally { + signal?.removeEventListener('abort', onClientAbort); + if (this.current.get(key) === entry) this.current.delete(key); + } + } +} + +export const botExecutions = new BotExecutions(); +type Manager = { get(name: string): BotConnection | undefined; connect(name: string): Promise }; + +/** Records which bot/sdk calls are in flight for diagnostics; once cancelled, calls park instead of throwing. */ +function tracked(object: T, label: string, active: Map, signal: AbortSignal): T { + return new Proxy(object, { + get(target, prop) { + const value = Reflect.get(target, prop, target); + if (typeof value !== 'function') return value; + return (...args: unknown[]) => { + // A throw here would let a catch-all loop in cancelled code spin and starve the server. + if (signal.aborted) return new Promise(() => {}); + const token = Symbol(); + active.set(token, `${label}.${String(prop)}`); + try { + const result = value.apply(target, args); + if (result && typeof result.then === 'function') return Promise.resolve(result).finally(() => active.delete(token)); + active.delete(token); + return result; + } catch (error) { active.delete(token); throw error; } + }; + }, + }); +} + +export async function executeCode( + manager: Manager, + botName: string, + code: string, + timeoutMs: number, + clientSignal?: AbortSignal, + executions = botExecutions, +) { + installExecutionConsoleCapture(); + try { + return await executions.run(botName, clientSignal, async controller => { + const logs = new ConsoleCapture(); + const active = new Map(); + const record = (level: 'log' | 'warn' | 'error', args: unknown[]) => { + logs.push((level === 'log' ? '' : `[${level}] `) + args.map(formatLogArgument).join(' ')); + }; + const started = Date.now(); + let connection: BotConnection | undefined; + let result: unknown; + let failure: unknown; + let failed = false; + let interruptedActions: string[] = []; + const rememberActions = () => { interruptedActions = [...active.values()]; }; + controller.signal.addEventListener('abort', rememberActions, { once: true }); + const timer = setTimeout(() => controller.abort(new Error(`Code execution timed out after ${timeoutMs / 60_000} minute(s)`)), timeoutMs); + + const work = async () => { + const connecting = Symbol(); + active.set(connecting, 'Connecting / waiting for fresh state'); + try { + connection = manager.get(botName); + if (!connection?.connected) connection = await manager.connect(botName); + const sdk = connection.sdk; + try { + if (sdk.getStateAge() > 10_000) await sdk.waitForStateChange(15_000); + if (!sdk.getState()?.inGame) await sdk.waitForReady(15_000); + } catch (error) { + throwIfExecutionAborted(); + // Still run: diagnosing a stale client is exactly when you need execute_code. + record('warn', [`No fresh in-game state (${error instanceof Error ? error.message : error}); running anyway, actions may not take effect.`]); + } + } finally { active.delete(connecting); } + throwIfExecutionAborted(); + + const compiled = new Bun.Transpiler({ loader: 'ts' }).transformSync( + `const __rsExec = async (bot, sdk, Spells) => {\n${code}\n};`, + ); + const fn = new Function(`${compiled}\nreturn __rsExec;`)(); + return fn(tracked(connection.bot, 'bot', active, controller.signal), tracked(connection.sdk, 'sdk', active, controller.signal), Spells); + }; + + try { + result = await executionContext.run({ signal: controller.signal, log: record }, () => abortable(work())); + } catch (error) { + failure = error; + failed = true; + if (interruptedActions.length === 0) interruptedActions = [...active.values()]; + } finally { + clearTimeout(timer); + controller.signal.removeEventListener('abort', rememberActions); + // Detached continuations inherit this scope and must not dispatch after it ends. + if (!controller.signal.aborted) controller.abort(new Error('Execution finished')); + } + + const parts: string[] = []; + if (logs.length) parts.push('── Console ──', ...logs.lines()); + if (failed) { + const errorText = failure instanceof Error + ? formatLogArgument(failure).split('\n').slice(0, 7).join('\n') + : formatLogArgument(failure); + parts.push('', '── Error ──', errorText, + `Elapsed: ${Date.now() - started}ms`, + `Active action: ${interruptedActions.join(', ') || 'User code'}`); + } else if (result !== undefined) { + try { parts.push('', '── Result ──', redactCredentials(JSON.stringify(result, null, 2) ?? String(result))); } + catch (error) { failure = error; failed = true; parts.push('', '── Error ──', formatLogArgument(error)); } + } + const state = connection?.sdk.getState(); + if (state) { + const ticks = state.gameMessages?.map(message => message.tick) ?? []; + if (ticks.length && ticks.every(tick => tick < connection!.lastShownMessageTick)) connection!.lastShownMessageTick = -1; + const sinceTick = connection!.lastShownMessageTick; + parts.push('', '── World State ──', redactCredentials(formatWorldState(state, connection!.sdk.getStateAge(), { sinceTick, brief: true }))); + // Advance only from messages present in this snapshot; tolerate client cycle resets. + if (ticks.length) connection!.lastShownMessageTick = Math.max(connection!.lastShownMessageTick, ...ticks); + } + if (code.length > 2000) parts.push('', '── Tip ──', `For long runs, save a script under bots/${botName}/ and run it against the browser client already opened by MCP.`); + return { content: [{ type: 'text' as const, text: parts.join('\n').trim() || '(no output)' }], ...(failed ? { isError: true } : {}) }; + }); + } catch (error) { + return { content: [{ type: 'text' as const, text: `Error: ${formatLogArgument(error)}` }], isError: true }; + } +} diff --git a/mcp/server.ts b/mcp/server.ts index 3df9496e5..14f7bb723 100644 --- a/mcp/server.ts +++ b/mcp/server.ts @@ -17,11 +17,10 @@ import { import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { botManager } from './api/index.js'; -import { formatWorldState } from '../sdk/formatter.js'; import { initPathfinding } from '../sdk/pathfinding.js'; -import { Spells } from '../sdk/spells.js'; import { marketTool, readMarket } from './market-tools.js'; -import { ConsoleCapture } from './console-capture.js'; +import { installExecutionConsoleCapture } from './console-capture.js'; +import { executeCode, botExecutions } from './execute-code.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -91,7 +90,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { marketTool, { name: 'execute_code', - description: 'Execute TypeScript code on a bot. Auto-connects using credentials from bots/{name}/bot.env. The code runs in an async context with bot (BotActions), sdk (BotSDK), and Spells (spell component ids) available. WARNING: many MCP clients cap tool-call waits at ~60s; if the client gives up, the code KEEPS RUNNING on the bot until it finishes or hits the timeout - keep interactive snippets short and put long loops in a bots/{name}/*.ts script instead.', + description: 'Execute TypeScript code on a bot. Auto-connects using credentials from bots/{name}/bot.env. The code runs in an async context with bot (BotActions), sdk (BotSDK), and Spells (spell component ids) available. A new call for the same bot cancels the one still running. Timeout or explicit MCP cancellation stops SDK dispatches and observations, including inside high-level actions. Keep interactive snippets short; use a saved script with the existing browser client for long loops.', inputSchema: { type: 'object', properties: { @@ -105,7 +104,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { }, timeout: { type: 'number', - description: 'Execution timeout in minutes (default: 2, max: 60). Note: the MCP client may stop waiting sooner (often ~60s) - the code still runs to completion on the bot.' + description: 'Execution timeout in minutes (default: 2, max: 60).' } }, required: ['bot_name', 'code'] @@ -152,7 +151,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { return errorResponse('Bot name is required'); } - await botManager.disconnect(botName); + // Supersedes any execution still running on this bot. + await botExecutions.run(botName, extra.signal, async () => botManager.disconnect(botName)); return successResponse({ message: `Disconnected bot "${botName}"` }); } @@ -176,182 +176,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { return errorResponse('code is required'); } - const isLongCode = code.length > 2000; - - // Capture console output BEFORE connecting to prevent SDK logs from corrupting MCP JSON-RPC stdout - const logs = new ConsoleCapture(); - const originalLog = console.log; - const originalWarn = console.warn; - const originalError = console.error; - - // Errors need special-casing: JSON.stringify(new Error(...)) is '{}', - // which used to swallow every console.log(err) into two braces. - const fmtLogArg = (a: unknown): string => { - if (a instanceof Error) return a.stack ?? `${a.name}: ${a.message}`; - if (typeof a === 'object' && a !== null) { - try { return JSON.stringify(a, null, 2) ?? String(a); } catch { return String(a); } - } - return String(a); - }; - console.log = (...args) => logs.push(args.map(fmtLogArg).join(' ')); - console.warn = (...args) => logs.push('[warn] ' + args.map(fmtLogArg).join(' ')); - // Don't capture console.error - let it go to stderr for MCP debugging - - // Auto-connect if not already connected - let connection = botManager.get(botName); - if (!connection) { - console.error(`[MCP] Bot "${botName}" not connected, auto-connecting...`); - connection = await botManager.connect(botName); - // Wait up to 15s for initial world state after fresh connection - try { - await connection.sdk.waitForCondition(() => connection!.sdk.getState() !== null, 15000); - } catch { - console.error(`[MCP] Warning: initial state not received within 15s for bot "${botName}"`); - } - } - - try { - // The tool advertises TypeScript, so strip type syntax (non-null !, - // annotations, generics) before evaluation - new Function() alone - // rejects TS with a bare SyntaxError. Fall back to evaluating the - // raw code so plain JS behaves exactly as before if transpilation - // trips on something. - const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; - let fn: (bot: unknown, sdk: unknown, spells: unknown) => Promise; - try { - // The assignment keeps the wrapper from being tree-shaken as a - // side-effect-free expression (which yields empty output). - const transpiled = new Bun.Transpiler({ loader: 'ts' }) - .transformSync(`globalThis.__rsExecFn = (async (bot, sdk, Spells) => {\n${code}\n})`); - (0, eval)(transpiled); - fn = (globalThis as any).__rsExecFn; - delete (globalThis as any).__rsExecFn; - if (typeof fn !== 'function') throw new Error('transpile produced no function'); - } catch { - fn = new AsyncFunction('bot', 'sdk', 'Spells', code); - } - - // Execute code with configurable timeout + MCP cancellation signal - const timeoutMinutes = Math.min(Math.max((args?.timeout as number) || 2, 0.1), 60); - const EXECUTION_TIMEOUT = timeoutMinutes * 60 * 1000; - let timeoutId: ReturnType; - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => reject(new Error(`Code execution timed out after ${timeoutMinutes} minute(s)`)), EXECUTION_TIMEOUT); - }); - - // AbortController that fires on MCP cancellation - const abortController = new AbortController(); - const signal = abortController.signal; - - if (extra.signal) { - if (extra.signal.aborted) { - abortController.abort(extra.signal.reason); - } else { - extra.signal.addEventListener('abort', () => { - console.error(`[MCP] execute_code cancelled by client for bot "${botName}"`); - abortController.abort('Cancelled by client'); - }, { once: true }); - } - } - - const cancelPromise = new Promise((_, reject) => { - signal.addEventListener('abort', () => { - reject(new Error(typeof signal.reason === 'string' ? signal.reason : 'Code execution cancelled')); - }, { once: true }); - }); - - // Wrap bot and sdk in proxies that throw on every method call once cancelled - const cancellable = (target: T): T => - new Proxy(target, { - get(obj, prop, receiver) { - const value = Reflect.get(obj, prop, receiver); - if (typeof value === 'function') { - return (...args: any[]) => { - if (signal.aborted) throw new Error('Execution cancelled'); - return value.apply(obj, args); - }; - } - return value; - } - }); - - let result: any; - let failure: Error | null = null; - try { - result = await Promise.race([fn(cancellable(connection.bot), cancellable(connection.sdk), Spells), timeoutPromise, cancelPromise]); - } catch (err: any) { - failure = err instanceof Error ? err : new Error(String(err)); - } finally { - clearTimeout(timeoutId!); - if (!signal.aborted) abortController.abort('Execution finished'); - } - - // Build formatted output. A throw or timeout still gets the console - // output and world state - that's when the agent most needs to see - // it died, a dialog is open, or it got "I can't reach that". - const parts: string[] = []; - - if (logs.length > 0) { - parts.push('── Console ──'); - parts.push(logs.lines().join('\n')); - } - - if (failure) { - if (parts.length > 0) parts.push(''); - parts.push('── Error ──'); - const frames = (failure.stack ?? '').split('\n').filter(l => /^\s+at /.test(l)).slice(0, 6); - parts.push([`${failure.name}: ${failure.message}`, ...frames].join('\n')); - } else if (result !== undefined) { - if (parts.length > 0) parts.push(''); - parts.push('── Result ──'); - parts.push(JSON.stringify(result, null, 2)); - } - - // Append formatted world state. The connection's tick cursor - // advances each call so repeat execute_code invocations only show - // NEW chat / system messages — old ones the bot already saw don't - // get re-emitted. Cursor lives on BotConnection (MCP-only concern) - // so the SDK surface stays minimal for script authors. - const state = connection.sdk.getState(); - if (state) { - // Message ticks are the client's loopCycle, which restarts near 0 - // when the bot page reloads. If every tick is below our cursor the - // client reloaded — reset the cursor or chat would be muted forever. - if (state.gameMessages && state.gameMessages.length > 0 && - state.gameMessages.every(m => m.tick < connection.lastShownMessageTick)) { - connection.lastShownMessageTick = -1; - } - const sinceTick = connection.lastShownMessageTick; - if (state.gameMessages) { - for (const m of state.gameMessages) { - if (m.tick > connection.lastShownMessageTick) { - connection.lastShownMessageTick = m.tick; - } - } - } - parts.push(''); - parts.push('── World State ──'); - parts.push(formatWorldState(state, connection.sdk.getStateAge(), { sinceTick, brief: true })); - } - - // Add reminder for long code - if (isLongCode) { - parts.push(''); - parts.push('── Tip ──'); - parts.push(`Long script detected. Consider writing to a .ts file and running with: bun run bots/${botName}/script.ts`); - } - - const output = parts.length > 0 ? parts.join('\n') : '(no output)'; - - return { - content: [{ type: 'text', text: output }], - ...(failure ? { isError: true } : {}) - }; - } finally { - console.log = originalLog; - console.warn = originalWarn; - console.error = originalError; - } + const timeoutMinutes = Math.min(Math.max((args?.timeout as number) || 2, 0.1), 60); + return executeCode(botManager, botName, code, timeoutMinutes * 60_000, extra.signal); } default: @@ -381,6 +207,7 @@ function errorResponse(message: string) { // Start server async function main() { + installExecutionConsoleCapture(); console.error('[MCP Server] Starting RS-Agent MCP server v2.0...'); console.error('[MCP Server] Bots auto-connect on the first execute_code call.'); diff --git a/sdk/API.md b/sdk/API.md index 53dad0494..5da72b5dc 100644 --- a/sdk/API.md +++ b/sdk/API.md @@ -65,7 +65,7 @@ | Signature | Description | |---|---| | `async talkTo(target: NearbyNpc \| string \| RegExp): Promise` | Talk to an NPC and wait for dialog to open. Walks to the NPC first (handling doors). | -| `async interactLoc(target: NearbyLoc \| string \| RegExp, option: number \| string \| RegExp = 1): Promise` | Interact with a nearby location object (rock, fishing spot, furnace, etc.). Walks to the target first (handling doors), sends the interaction, then waits for an effect (animation, dialog, interface) or detects failure when the player has been idle for 2 ticks with nothing happening. | +| `async interactLoc(target: NearbyLoc \| string \| RegExp, option: number \| string \| RegExp = 1): Promise` | Interact with a nearby location object (rock, fishing spot, furnace, etc.). Walks to the target first (handling doors), sends the interaction, then waits for an effect (animation, dialog, interface) or detects failure when the player has been idle for 2 ticks with nothing happening. Success can mean the action started, before its product arrives; use mineRock for completed mining. | | `async interactNpc(target: NearbyNpc \| string \| RegExp, option: number \| string \| RegExp = 1): Promise` | Interact with a nearby NPC using a specified option (e.g. "Trade", "Pickpocket", "Fish"). Walks to the NPC first (handling doors), sends the interaction, then waits for an effect (animation, dialog, interface) or detects failure when the player has been idle for 2 ticks with nothing happening. | | `async pickpocketNpc(target: NearbyNpc \| string \| RegExp): Promise` | Pickpocket an NPC. Handles door retrying if path is blocked. | @@ -73,7 +73,7 @@ | Signature | Description | |---|---| -| `async walkTo(x: number, z: number, tolerance: number = 3): Promise` | Walk to coordinates using pathfinding, auto-opening doors. Aborts if the player dies en route. | +| `async walkTo(x: number, z: number, tolerance: number = 3, options: { timeout?: number } = {}): Promise` | Walk to coordinates using pathfinding, auto-opening doors. Bounds the whole route with options.timeout (default 120s), refuses stale state, and aborts on cancellation or death. | ### Shopping @@ -137,6 +137,12 @@ | `async enchantItem(target: InventoryItem \| string \| RegExp, level: 1 \| 2 \| 3 \| 4 \| 5, options: { timeout?: number } = {}): Promise` | Cast an enchantment spell on a jewelry item. | | `async stringAmulet(target: InventoryItem \| string \| RegExp = /amulet/i, options: { timeout?: number } = {}): Promise` | String an amulet using a ball of wool. | +### Mining + +| Signature | Description | +|---|---| +| `async mineRock(target: NearbyLoc \| string \| RegExp = /rocks?/i, options: { timeout?: number } = {}): Promise` | Mine a rock and wait for new ore plus Mining XP. Rock names have a "Rocks" prefix: use /copper ore/i for rocks and /^copper ore$/i for inventory. | + ### Prayer | Signature | Description | @@ -594,6 +600,21 @@ interface ChopTreeResult { } ``` +### MineRockResult + +One ore observed entering the inventory, with Mining XP as corroboration. + +```typescript +interface MineRockResult { + success: boolean; + message: string; + ore?: InventoryItem; + oresMined: number; + xpGained: number; + reason?: 'loc_not_found' | 'no_matching_option' | 'cant_reach' | 'inventory_full' | 'rejected' | 'timeout'; +} +``` + ### BurnLogsResult ```typescript @@ -839,6 +860,7 @@ interface UseItemOnNpcResult { ```typescript interface InteractLocResult { + /** Activity observed; for mining this can be an animation before ore arrives. Use mineRock for ore completion. */ success: boolean; message: string; reason?: 'loc_not_found' | 'no_matching_option' | 'cant_reach' | 'timeout'; diff --git a/sdk/actions-helpers.ts b/sdk/actions-helpers.ts index d15f1a8bc..bc34476fc 100644 --- a/sdk/actions-helpers.ts +++ b/sdk/actions-helpers.ts @@ -2,6 +2,7 @@ // Private helper methods extracted from BotActions for reusability import { BotSDK } from './index'; +import { executionWait } from './execution-context'; import { shortestNameMatch } from './action-quantity'; import type { NearbyLoc, @@ -319,13 +320,11 @@ export class ActionHelpers { let lastZ = startZ; let lastMoveTick = startTick; - return new Promise((resolve) => { + return executionWait((resolve) => { let resolved = false; const done = (result: { arrived: boolean; stoppedMoving: boolean; x: number; z: number }) => { if (resolved) return; resolved = true; - clearTimeout(safetyTimer); - unsub(); resolve(result); }; @@ -370,6 +369,7 @@ export class ActionHelpers { done({ arrived: distToTarget <= tolerance, stoppedMoving: true, x: currentX, z: currentZ }); } }); + return () => { clearTimeout(safetyTimer); unsub(); }; }); } @@ -385,7 +385,8 @@ export class ActionHelpers { tolerance: number, lastPos: { x: number; z: number } ): Promise<{ status: 'arrived' | 'progress' | 'stuck'; pos: { x: number; z: number } }> { - await this.sdk.sendWalk(targetX, targetZ, true); + const dispatch = await this.sdk.sendWalk(targetX, targetZ, true); + if (!dispatch.success) return { status: 'stuck', pos: lastPos }; const moveResult = await this.waitForMovementComplete(targetX, targetZ, tolerance); const pos = this.sdk.getState()?.player; diff --git a/sdk/actions.ts b/sdk/actions.ts index debabca72..ee833c554 100644 --- a/sdk/actions.ts +++ b/sdk/actions.ts @@ -3,6 +3,7 @@ // Actions resolve when the EFFECT is complete (not just acknowledged) import { BotSDK } from './index'; +import { throwIfExecutionAborted, withExecutionDeadline } from './execution-context'; import { GEActions } from './ge-actions'; import type { GEOfferRequest, GEActionResult } from './ge-types'; import { ActionHelpers, ALREADY_FIGHTING_REFUSALS, NO_RUNES_REFUSALS } from './actions-helpers'; @@ -20,6 +21,7 @@ import type { GroundItem, ShopItem, ChopTreeResult, + MineRockResult, BurnLogsResult, PickupResult, DropItemResult, @@ -1063,8 +1065,19 @@ export class BotActions { } } - /** Walk to coordinates using pathfinding, auto-opening doors. Aborts if the player dies en route. */ - async walkTo(x: number, z: number, tolerance: number = 3): Promise { + /** Walk to coordinates using pathfinding, auto-opening doors. Bounds the whole route with options.timeout (default 120s), refuses stale state, and aborts on cancellation or death. */ + async walkTo(x: number, z: number, tolerance: number = 3, options: { timeout?: number } = {}): Promise { + const timeout = options.timeout ?? 120_000; + if (!Number.isFinite(timeout) || timeout <= 0 || !Number.isFinite(tolerance) || tolerance < 0) { + return { success: false, message: 'walkTo requires a positive finite timeout and a non-negative finite tolerance' }; + } + return withExecutionDeadline(timeout, () => this._walkToRoute(x, z, tolerance), () => { + const player = this.sdk.getState()?.player; + return { success: false, reason: 'timeout', message: `Walk timed out after ${timeout}ms: at (${player?.worldX}, ${player?.worldZ}), destination (${x}, ${z})` }; + }); + } + + private async _walkToRoute(x: number, z: number, tolerance: number): Promise { if (!Number.isFinite(x) || !Number.isFinite(z)) { // walkTo(undefined, undefined) used to be accepted and marched the // bot across the map (players expose coordinates as .x/.z). @@ -1073,10 +1086,23 @@ export class BotActions { message: `walkTo requires numeric coordinates - got (${x}, ${z}). Nearby entities (players included) expose them as .x/.z; your own position is state.player.worldX/.worldZ`, }; } + const ensureFreshState = async (): Promise => { + throwIfExecutionAborted(); + if (this.sdk.getStateAge && this.sdk.getStateAge() > 10_000) { + try { await this.sdk.waitForStateChange(5000); } + catch { + throwIfExecutionAborted(); + return { success: false, reason: 'stale_state', message: `Cannot walk to (${x}, ${z}): no fresh world state` }; + } + } + return null; + }; + const stale = await ensureFreshState(); + if (stale) return stale; await this.dismissBlockingUI(); const state = this.sdk.getState(); - if (!state?.player) return { success: false, message: 'No player state' }; + if (!state?.player || state.inGame === false) return { success: false, message: 'No in-game player state' }; // Death mid-walk: an orphaned walkTo that keeps marching after a // respawn re-enters the danger zone that killed it. Abort instead. @@ -1140,6 +1166,8 @@ export class BotActions { }; for (let i = 0; i < MAX_ITERATIONS; i++) { + const stale = await ensureFreshState(); + if (stale) return stale; const dead = diedEnRoute(); if (dead) return dead; @@ -1192,6 +1220,8 @@ export class BotActions { let consecutiveStuck = 0; for (const wp of path.waypoints) { + const stale = await ensureFreshState(); + if (stale) return stale; // Proactively open doors the path requires — only when we're close enough to see them if (requiredDoorKeys.size > 0) { const wpDoorKey = `${wp.x},${wp.z}`; @@ -1257,6 +1287,7 @@ export class BotActions { const isFinalWp = wp === path.waypoints[path.waypoints.length - 1]; const wpTolerance = isFinalWp ? Math.min(2, tolerance) : 2; const result = await this.helpers.walkStepToward(wp.x, wp.z, wpTolerance, pos); + pos = result.pos; const deadMidStep = diedEnRoute(); if (deadMidStep) return deadMidStep; if (distTo(result.pos) <= tolerance) return { success: true, message: 'Arrived' }; @@ -1268,7 +1299,6 @@ export class BotActions { } } else { consecutiveStuck = 0; - pos = result.pos; } } @@ -3994,11 +4024,71 @@ export class BotActions { // ============ Porcelain: Generic Interactions ============ + /** Mine a rock and wait for new ore plus Mining XP. Rock names have a "Rocks" prefix: use /copper ore/i for rocks and /^copper ore$/i for inventory. */ + async mineRock(target: NearbyLoc | string | RegExp = /rocks?/i, options: { timeout?: number } = {}): Promise { + const timeout = options.timeout ?? 30_000; + if (!Number.isFinite(timeout) || timeout <= 0) return { success: false, reason: 'timeout', message: 'mineRock requires a positive finite timeout', oresMined: 0, xpGained: 0 }; + return withExecutionDeadline(timeout, () => this._mineRockOnce(target, timeout), + () => ({ success: false, reason: 'timeout', message: `Mining timed out after ${timeout}ms`, oresMined: 0, xpGained: 0 })); + } + + private async _mineRockOnce(target: NearbyLoc | string | RegExp, timeout: number): Promise { + const fail = (reason: MineRockResult['reason'], message: string): MineRockResult => + ({ success: false, reason, message, oresMined: 0, xpGained: 0 }); + if (target == null) return fail('loc_not_found', 'mineRock requires a rock, name, or pattern'); + await this.dismissBlockingUI(); + const rock = this.helpers.resolveLocation(target, /rocks?/i, /^mine$/i); + if (!rock) return fail('loc_not_found', `No mineable rock matches ${target}`); + if (this.sdk.getInventory().length >= 28) return fail('inventory_full', 'Inventory is full'); + if (rock.distance > 2) { + const walk = await this.walkTo(rock.x, rock.z, 2); + if (!walk.success) return fail('cant_reach', walk.message); + } + const current = this.helpers.refindLocation(rock) ?? rock; + const mine = current.optionsWithIndex.find(option => /^mine$/i.test(option.text)); + if (!mine) return fail('no_matching_option', `${current.name} has no Mine option`); + + const before = new Map(); + for (const item of this.sdk.getInventory()) before.set(item.id, (before.get(item.id) ?? 0) + item.count); + const xp = this.sdk.getSkillXp('mining') ?? 0; + const oreName = /rocks?\s+(.+?)\s+ore$/i.exec(rock.name)?.[1]; + const expected = oreName === 'gem' ? /^uncut /i : oreName ? exactNamePattern(`${oreName} ore`) : /(?:ore|essence|^coal$|^clay$|^uncut )/i; + const matchesOre = (item: InventoryItem) => expected.test(item.name) || (oreName !== undefined && item.name.toLowerCase() === oreName.toLowerCase()); + const gainedOre = (state: BotWorldState) => { + const amounts = new Map(); + for (const item of state.inventory) amounts.set(item.id, (amounts.get(item.id) ?? 0) + item.count); + return state.inventory.find(item => matchesOre(item) && (amounts.get(item.id) ?? 0) > (before.get(item.id) ?? 0)); + }; + const miningXp = (state: BotWorldState) => state.skills.find(skill => /^mining$/i.test(skill.name))?.experience ?? xp; + const baseline = this.helpers.getMessageTick(); + const rejection = this.helpers.opRejectionCursor(); + const refusals = ['need a mining level', 'need a pickaxe', 'do not have a pickaxe', "can't reach", 'cannot reach', 'no ore']; + const interaction = await this.sdk.sendInteractLoc(current.x, current.z, current.id, mine.opIndex); + if (!interaction.success) return fail(interaction.reason === 'cant_reach' ? 'cant_reach' : 'rejected', interaction.message); + try { + const final = await this.sdk.waitForCondition(state => + (Boolean(gainedOre(state)) && miningXp(state) > xp) || + this.helpers.wasOpRejectedSince(rejection, state) || + Boolean(this.helpers.findRefusal(baseline, refusals)), timeout); + const ore = gainedOre(final); + const xpGained = miningXp(final) - xp; + if (ore && xpGained > 0) { + const count = final.inventory.filter(item => item.id === ore.id).reduce((sum, item) => sum + item.count, 0); + return { success: true, message: `Mined ${ore.name}`, ore, oresMined: count - (before.get(ore.id) ?? 0), xpGained }; + } + return fail('rejected', this.helpers.findRefusal(baseline, refusals) ?? 'Mining operation was rejected'); + } catch { + throwIfExecutionAborted(); + return fail('timeout', `No ${oreName ?? 'ore'} gained from ${rock.name} within ${timeout}ms`); + } + } + /** * Interact with a nearby location object (rock, fishing spot, furnace, etc.). * Walks to the target first (handling doors), sends the interaction, then waits * for an effect (animation, dialog, interface) or detects failure when the player * has been idle for 2 ticks with nothing happening. + * Success can mean the action started, before its product arrives; use mineRock for completed mining. * @param target - NearbyLoc object or name string/regex to find * @param option - Option index or name regex to match (default: 1, the first option) */ diff --git a/sdk/execution-context.ts b/sdk/execution-context.ts new file mode 100644 index 000000000..900b5ce3e --- /dev/null +++ b/sdk/execution-context.ts @@ -0,0 +1,73 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +/** A scope belongs to one execution, never to the lifetime of a bot connection. */ +export interface ExecutionContext { + signal: AbortSignal; + log?: (level: 'log' | 'warn' | 'error', args: unknown[]) => void; +} + +export const executionContext = new AsyncLocalStorage(); + +function abortError(signal: AbortSignal): Error { + return signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason ?? 'Execution cancelled')); +} + +export function throwIfExecutionAborted(): void { + const signal = executionContext.getStore()?.signal; + if (signal?.aborted) throw abortError(signal); +} + +/** Cancel a pending observation/dispatch and release its listeners and timers. */ +export function executionWait( + setup: (resolve: (value: T) => void, reject: (error: unknown) => void) => () => void, + signal: AbortSignal | undefined = executionContext.getStore()?.signal, +): Promise { + if (signal?.aborted) return Promise.reject(abortError(signal)); + return new Promise((resolve, reject) => { + let settled = false; + let cleanup: (() => void) | undefined; + const finish = (complete: () => void) => { + if (settled) return; + settled = true; + signal?.removeEventListener('abort', abort); + cleanup?.(); + complete(); + }; + const abort = () => finish(() => reject(abortError(signal!))); + signal?.addEventListener('abort', abort, { once: true }); + try { + cleanup = setup(value => finish(() => resolve(value)), error => finish(() => reject(error))); + if (settled) cleanup(); + else if (signal?.aborted) abort(); + } catch (error) { + finish(() => reject(error)); + } + }); +} + +/** Settle with `promise`, or reject as soon as `signal` aborts. */ +export function abortable(promise: Promise, signal = executionContext.getStore()?.signal): Promise { + promise.catch(() => {}); // nobody awaits a rejection that lands after cancellation + return executionWait((resolve, reject) => { promise.then(resolve, reject); return () => {}; }, signal); +} + +/** Bound a helper's whole route, including nested door attempts and waits. Its own deadline returns onTimeout(); a parent cancellation still throws. */ +export async function withExecutionDeadline(timeout: number, work: () => Promise, onTimeout: () => T): Promise { + throwIfExecutionAborted(); + const parent = executionContext.getStore(); + const controller = new AbortController(); + const timedOut = new Error('Deadline exceeded'); + const abort = () => controller.abort(parent?.signal.reason); + parent?.signal.addEventListener('abort', abort, { once: true }); + const timer = setTimeout(() => controller.abort(timedOut), timeout); + try { + return await executionContext.run({ ...parent, signal: controller.signal }, () => abortable(work())); + } catch (error) { + if (error === timedOut) return onTimeout(); + throw error; + } finally { + clearTimeout(timer); + parent?.signal.removeEventListener('abort', abort); + if (!controller.signal.aborted) controller.abort(new Error('Action finished')); + } +} diff --git a/sdk/generate-api-docs.ts b/sdk/generate-api-docs.ts index e3b2f8dbd..363e341f3 100644 --- a/sdk/generate-api-docs.ts +++ b/sdk/generate-api-docs.ts @@ -138,6 +138,7 @@ function methodCategory(name: string, surface: 'actions' | 'sdk'): string { if (surface === 'actions') { if (lower.includes('dialog') || lower.includes('blocking') || lower.includes('tutorial')) return 'UI & Dialog'; if (lower.includes('walk')) return 'Movement'; + if (lower.includes('mine')) return 'Mining'; if (lower.includes('fletch') || lower.includes('craft') || lower.includes('smith') || lower.includes('enchant') || lower.includes('string')) { return 'Crafting & Smithing'; } diff --git a/sdk/index.ts b/sdk/index.ts index a0ae55eb6..574ec1d15 100644 --- a/sdk/index.ts +++ b/sdk/index.ts @@ -32,6 +32,8 @@ import type { import type { TradeState } from './types'; import { PRAYER_INDICES, PRAYER_NAMES, PLAYER_CHAT_TYPES, isPlayerChat, TRADE_REQUEST_CHAT_TYPE } from './types'; import { ChatHistory } from './chat-history'; +import { executionContext, executionWait, throwIfExecutionAborted } from './execution-context'; +import { redactCredentials } from './redact'; import * as pathfinding from './pathfinding'; import { resolveInterfaceOption, shortestNameMatch, type InterfaceOptionSelector } from './action-quantity'; @@ -227,6 +229,7 @@ export class BotSDK { /** Connect to the gateway WebSocket. */ async connect(): Promise { + throwIfExecutionAborted(); if (this.ws && this.ws.readyState === WebSocket.OPEN) { return; } @@ -260,6 +263,7 @@ export class BotSDK { } const configuredUrl = this.config.gatewayUrl || `ws://${this.config.host}:${this.config.port}`; + throwIfExecutionAborted(); this.connectPromise = preferDirectGateway(configuredUrl).then(url => new Promise((resolve, reject) => { if (this.intentionalDisconnect) { reject(new Error('Disconnected while connecting')); @@ -415,7 +419,9 @@ export class BotSDK { console.log(`[BotSDK] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})`); - this.reconnectTimer = setTimeout(async () => { + // Reconnect belongs to the connection, not to the execution that first + // opened its socket; that execution may already be cancelled/finished. + this.reconnectTimer = executionContext.exit(() => setTimeout(async () => { this.reconnectTimer = null; try { await this.connect(); @@ -423,7 +429,7 @@ export class BotSDK { } catch (e) { console.log(`[BotSDK] Reconnection attempt ${this.reconnectAttempt} failed`); } - }, delay); + }, delay)); } /** Disconnect from the gateway. */ @@ -569,15 +575,17 @@ export class BotSDK { * Falls back to printing the URL if no browser can be opened. */ async launchBrowser(): Promise { + throwIfExecutionAborted(); const url = this.buildClientUrl(); - console.log(`[BotSDK] Opening browser: ${url}`); + console.log(`[BotSDK] Opening browser: ${redactCredentials(url)}`); try { const open = (await import('open')).default; + throwIfExecutionAborted(); await open(url); } catch (e) { console.warn(`[BotSDK] Could not open browser automatically.`); - console.warn(`[BotSDK] Open this URL manually: ${url}`); + console.warn(`[BotSDK] Open the bot client for ${this.config.botUsername} manually using the credentials in its bot.env file.`); } } @@ -593,6 +601,7 @@ export class BotSDK { console.log(`[BotSDK] Waiting for bot to connect and load game (timeout: ${timeoutMs}ms)...`); while (Date.now() - startTime < timeoutMs) { + throwIfExecutionAborted(); attemptCount++; const elapsed = Date.now() - startTime; const status = await this.checkBotStatus(); @@ -683,27 +692,14 @@ export class BotSDK { /** Wait for WebSocket connection to be established. */ async waitForConnection(timeout: number = 60000): Promise { - if (this.isConnected()) { - return; - } - - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - unsubscribe(); - reject(new Error('waitForConnection timed out')); - }, timeout); - - const unsubscribe = this.onConnectionStateChange((state) => { - if (state === 'connected') { - clearTimeout(timeoutId); - unsubscribe(); - resolve(); - } else if (state === 'disconnected') { - clearTimeout(timeoutId); - unsubscribe(); - reject(new Error('Connection failed')); - } + if (this.isConnected()) return; + return executionWait((resolve, reject) => { + const timeoutId = setTimeout(() => reject(new Error('waitForConnection timed out')), timeout); + const unsubscribe = this.onConnectionStateChange(state => { + if (state === 'connected') resolve(); + else if (state === 'disconnected') reject(new Error('Connection failed')); }); + return () => { clearTimeout(timeoutId); unsubscribe(); }; }); } @@ -817,9 +813,8 @@ export class BotSDK { // Private cursor starting "now" — does not consume getNewChat's cursor. let cursor = this.chatHistory.seen; - return new Promise((resolve) => { + return executionWait((resolve) => { const timeoutId = setTimeout(() => { - unsubscribe(); resolve(null); }, timeout); @@ -828,11 +823,10 @@ export class BotSDK { cursor = this.chatHistory.seen; const match = fresh.find(accepts); if (match) { - clearTimeout(timeoutId); - unsubscribe(); resolve(match); } }); + return () => { clearTimeout(timeoutId); unsubscribe(); }; }); } @@ -1089,7 +1083,7 @@ export class BotSDK { const actionId = `act-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; try { - return await new Promise((resolve, reject) => { + return await executionWait((resolve, reject) => { const timeout = setTimeout(() => { this.pendingActions.delete(actionId); reject(new ActionDispatchError(`Action timed out: ${action.type}`, 'timeout')); @@ -1097,15 +1091,24 @@ export class BotSDK { this.pendingActions.set(actionId, { resolve, reject, timeout }); - this.send({ - type: 'sdk_action', - username: this.config.botUsername, - actionId, - action, - actionTimeoutMs: this.config.actionTimeout - }); + try { + this.send({ + type: 'sdk_action', + username: this.config.botUsername, + actionId, + action, + actionTimeoutMs: this.config.actionTimeout + }); + } catch (error) { + reject(error); + } + return () => { + clearTimeout(timeout); + this.pendingActions.delete(actionId); + }; }); } catch (err) { + throwIfExecutionAborted(); // A dropped dispatch is a reportable failure, not a fatal one: callers // check result.success and can retry, reposition, or give up. Losing // the connection outright still throws from the guard above. @@ -1704,7 +1707,7 @@ export class BotSDK { const screenshotId = `ss-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - return new Promise((resolve, reject) => { + return executionWait((resolve, reject) => { const timeoutHandle = setTimeout(() => { this.pendingScreenshots.delete(screenshotId); reject(new Error('Screenshot request timed out')); @@ -1712,11 +1715,16 @@ export class BotSDK { this.pendingScreenshots.set(screenshotId, { resolve, reject, timeout: timeoutHandle }); - this.send({ - type: 'sdk_screenshot_request', - username: this.config.botUsername, - screenshotId - }); + try { + this.send({ + type: 'sdk_screenshot_request', + username: this.config.botUsername, + screenshotId + }); + } catch (error) { + reject(error); + } + return () => { clearTimeout(timeoutHandle); this.pendingScreenshots.delete(screenshotId); }; }); } @@ -1861,75 +1869,47 @@ export class BotSDK { predicate: (state: BotWorldState) => boolean, timeout: number = 30000 ): Promise { - if (this.state && predicate(this.state)) { - return this.state; - } + if (this.state && predicate(this.state)) return this.state; + return this.waitForObservation(predicate, timeout, 'waitForCondition timed out'); + } - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - unsubscribe(); - reject(new Error('waitForCondition timed out')); - }, timeout); - - const unsubscribe = this.onStateUpdate((state) => { - if (predicate(state)) { - clearTimeout(timeoutId); - unsubscribe(); - resolve(state); + private waitForObservation( + predicate: (state: BotWorldState) => boolean, + timeout: number, + timeoutMessage: string, + ): Promise { + return executionWait((resolve, reject) => { + const timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), timeout); + const unsubscribe = this.onStateUpdate(state => { + try { + if (predicate(state)) resolve(state); + } catch (error) { + reject(error); } }); + return () => { clearTimeout(timeoutId); unsubscribe(); }; }); } /** Wait for next state update from server. */ async waitForStateChange(timeout: number = 30000): Promise { - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - unsubscribe(); - reject(new Error('waitForStateChange timed out')); - }, timeout); - - const unsubscribe = this.onStateUpdate((state) => { - clearTimeout(timeoutId); - unsubscribe(); - resolve(state); - }); - }); + return this.waitForObservation(() => true, timeout, 'waitForStateChange timed out'); } /** * Wait for a specific number of server ticks (~300ms each). - * * @param ticks - Number of server ticks to wait * @returns The state after waiting */ async waitForTicks(ticks: number = 1): Promise { - if (!this.state) { - throw new Error('waitForTicks: no state available'); - } - - if (ticks <= 0) { - return this.state; - } - - const startTick = this.state.tick; - const targetTick = startTick + ticks; - - return new Promise((resolve, reject) => { - // Safety timeout: ticks * 1s + 5s buffer (server tick is ~300ms, so 1s is generous) - const safetyTimeout = setTimeout(() => { - unsubscribe(); - reject(new Error(`waitForTicks(${ticks}) safety timeout - no state updates received`)); - }, ticks * 1000 + 5000); - - const unsubscribe = this.onStateUpdate((state) => { - if (state.tick >= targetTick) { - clearTimeout(safetyTimeout); - unsubscribe(); - resolve(state); - } - }); - }); + if (!this.state) throw new Error('waitForTicks: no state available'); + if (ticks <= 0) return this.state; + const targetTick = this.state.tick + ticks; + return this.waitForObservation( + state => state.tick >= targetTick, + ticks * 1000 + 5000, + `waitForTicks(${ticks}) safety timeout - no state updates received`, + ); } /** diff --git a/sdk/redact.ts b/sdk/redact.ts new file mode 100644 index 000000000..834d59171 --- /dev/null +++ b/sdk/redact.ts @@ -0,0 +1,6 @@ +/** Keep login URLs useful in diagnostics without exposing their credentials. */ +export function redactCredentials(text: string): string { + return text + .replace(/([?&](?:password|token|secret|api[_-]?key)=)[^&#\s"'\\]*/gi, '$1[REDACTED]') + .replace(/(\b(?:wss?|https?):\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1[REDACTED]@'); +} diff --git a/sdk/runner.ts b/sdk/runner.ts index 6e5c0632b..bdd12556c 100644 --- a/sdk/runner.ts +++ b/sdk/runner.ts @@ -3,6 +3,7 @@ import { BotSDK, deriveGatewayUrl } from './index'; import { BotActions } from './actions'; +import { executionContext, abortable } from './execution-context'; import { initPathfinding } from './pathfinding'; import { formatWorldStateSummary } from './formatter'; import type { BotWorldState } from './types'; @@ -248,8 +249,8 @@ async function requireLiveClient(sdk: BotSDK, username: string, server: string | throw new Error( `No game client is attached to "${username}" (${why}).\n` + `Scripts drive a running client; they don't start one. Start one, leave it running, then rerun:\n` + - ` headless: cd server/webclient && bun src/lite/runner.ts ${username}\n` + - ` browser: ${origin}/bot?bot=${encodeURIComponent(username)}&password=` + ` browser: ${origin}/bot?bot=${encodeURIComponent(username)}&password=\n` + + `MCP execute_code can open that browser automatically. For fleets of many bots, an optional lite client is available: cd server/webclient && bun src/lite/runner.ts ${username}` ); } @@ -357,6 +358,7 @@ export async function runScript( } // Clean exit on signals - prevents orphaned processes when parent shell is killed + const executionController = new AbortController(); let signalReceived = false; const signalCleanup = (signal: string, exitCode: number) => { if (signalReceived) { @@ -364,6 +366,7 @@ export async function runScript( process.exit(exitCode); } signalReceived = true; + executionController.abort(new Error(`Script interrupted by ${signal}`)); console.error(`[Runner] Received ${signal} - disconnecting and exiting...`); // Exit 128+signum, never 0: a supervisor must not read a killed run // as success. And a wedged disconnect must not hang the process. @@ -386,26 +389,9 @@ export async function runScript( try { // Set up connection monitoring - const scriptPromise = new Promise(async (resolve, reject) => { - disconnectReject = reject; - try { - const scriptResult = await script(ctx); - resolve(scriptResult); - } catch (e) { - reject(e); - } - }); - - // Create race conditions based on options - const promises: Promise[] = [scriptPromise]; - - // Add timeout if specified - if (timeout) { - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => reject(new Error(`Script timeout after ${timeout}ms`)), timeout); - }); - promises.push(timeoutPromise); - } + disconnectReject = err => executionController.abort(err); + const scriptPromise = executionContext.run({ signal: executionController.signal }, () => abortable(script(ctx))); + if (timeout) timeoutId = setTimeout(() => executionController.abort(new Error(`Script timeout after ${timeout}ms`)), timeout); // Add connection monitoring if not ignored if (onDisconnect !== 'ignore') { @@ -434,10 +420,11 @@ export async function runScript( }); } - result = await Promise.race(promises); + result = await scriptPromise; } catch (e: any) { error = e; } finally { + if (!executionController.signal.aborted) executionController.abort(error ?? new Error('Script finished')); // Failures and disconnects must release the deadline too, or a finished // run keeps the process alive until its original timeout expires. if (timeoutId) clearTimeout(timeoutId); diff --git a/sdk/test/action-dispatch-failure.test.ts b/sdk/test/action-dispatch-failure.test.ts index c02d4df62..2208d1cc7 100644 --- a/sdk/test/action-dispatch-failure.test.ts +++ b/sdk/test/action-dispatch-failure.test.ts @@ -22,6 +22,13 @@ function dispatch(sdk: BotSDK): Promise { } describe('BotSDK.sendAction dispatch failures', () => { + test('cleans pending dispatches when the socket write throws synchronously', async () => { + const { sdk, pending } = stubSdk(5000); + (sdk as any).send = () => { throw new Error('Socket write failed'); }; + expect(await dispatch(sdk)).toMatchObject({ success: false, reason: 'error', message: 'Socket write failed' }); + expect(pending.size).toBe(0); + }); + test('reports a timeout instead of throwing', async () => { const { sdk, sent, pending } = stubSdk(20); diff --git a/sdk/test/execution-cancellation.test.ts b/sdk/test/execution-cancellation.test.ts new file mode 100644 index 000000000..3e9bbd049 --- /dev/null +++ b/sdk/test/execution-cancellation.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, test } from 'bun:test'; +import { BotSDK } from '../index'; +import { BotActions } from '../actions'; +import { executionContext, executionWait } from '../execution-context'; +import { executeCode, BotExecutions } from '../../mcp/execute-code'; +import { formatLogArgument, installExecutionConsoleCapture } from '../../mcp/console-capture'; + +function harness() { + const sdk = new BotSDK({ botUsername: 'test', password: 'test', autoLaunchBrowser: false }); + const internal = sdk as any; + const packets: any[] = []; + internal.state = { + inGame: true, tick: 1, + player: { name: 'test', worldX: 3200, worldZ: 3200, x: 128, z: 128, level: 0, lifeId: 1, combat: { inCombat: false }, animId: -1 }, + inventory: [], skills: [], gameMessages: [], nearbyLocs: [], nearbyNpcs: [], nearbyPlayers: [], groundItems: [], + dialog: { isOpen: false }, interface: { isOpen: false }, bank: { isOpen: false }, shop: { isOpen: false }, + }; + internal.stateReceivedAt = Date.now(); + internal.connectionState = 'connected'; + internal.ws = { readyState: WebSocket.OPEN }; + internal.send = (message: any) => { + packets.push(message); + // Dispatch is acknowledged, but there is no movement/state update. + queueMicrotask(() => internal.pendingActions.get(message.actionId)?.resolve({ success: true, message: 'dispatched' })); + }; + sdk.sendFindPath = async () => ({ success: true, reachedDestination: true, waypoints: [{ x: 3208, z: 3200, level: 0 }] }); + const bot = new BotActions(sdk); + const connection = { sdk, bot, username: 'test', connected: true, lastShownMessageTick: -1 }; + const manager = { get: () => connection, connect: async () => connection }; + return { sdk, bot, internal, packets, manager }; +} + +describe('execution cancellation', () => { + test('cancels an internal walk wait, retains diagnostics, and allows the next execution', async () => { + const h = harness(); + const result = await executeCode(h.manager, 'test', "console.log('walking started'); await bot.walkTo(3208, 3200);", 20); + expect(result.isError).toBeTrue(); + expect(result.content[0]!.text).toContain('walking started'); + expect(result.content[0]!.text).toContain('bot.walkTo'); + expect(result.content[0]!.text).toContain('Position: (3200, 3200)'); + expect(h.internal.stateListeners.size).toBe(0); + expect(h.internal.pendingActions.size).toBe(0); + expect(h.packets).toHaveLength(1); + const next = await executeCode(h.manager, 'test', 'return await sdk.sendWalk(3201, 3200);', 100); + expect(next.isError).toBeUndefined(); + expect(h.packets).toHaveLength(2); + }); + + test('blocks dispatch from a detached continuation after its execution finishes', async () => { + const h = harness(); + const controller = new AbortController(); + let release!: () => void; + const paused = new Promise(resolve => { release = resolve; }); + const old = executionContext.run({ signal: controller.signal }, async () => { + await paused; + return h.sdk.sendWalk(3210, 3200); + }); + controller.abort(new Error('old execution cancelled')); + release(); + await expect(old).rejects.toThrow('old execution cancelled'); + expect(h.packets).toHaveLength(0); + }); + + test('cancellation disposes state and dispatch waits without waiting for their timers', async () => { + const h = harness(); + h.internal.send = (message: unknown) => h.packets.push(message); + const controller = new AbortController(); + const scope = { signal: controller.signal }; + const waits = executionContext.run(scope, () => [h.sdk.waitForTicks(10), h.sdk.waitForCondition(() => false), h.sdk.sendWalk(3201, 3200)]); + const settled = Promise.allSettled(waits); + controller.abort(new Error('stop')); + for (const result of await settled) { + expect(result.status).toBe('rejected'); + if (result.status === 'rejected') expect(result.reason.message).toBe('stop'); + } + expect(h.internal.stateListeners.size).toBe(0); + expect(h.internal.pendingActions.size).toBe(0); + }); + + test('a newer call cancels the running one and starts after it unwinds', async () => { + const executions = new BotExecutions(); + const order: string[] = []; + const first = executions.run('Test', undefined, async controller => { + order.push('first'); + await new Promise(resolve => controller.signal.addEventListener('abort', resolve)); + await Bun.sleep(5); + order.push('first unwound'); + throw controller.signal.reason; + }); + await Bun.sleep(1); + const second = executions.run('test', undefined, async () => { order.push('second'); }); + await expect(first).rejects.toThrow('Superseded'); + await second; + expect(order).toEqual(['first', 'first unwound', 'second']); + }); + + test('explicit client cancellation stops a running action', async () => { + const h = harness(); + const controller = new AbortController(); + const work = executeCode(h.manager, 'test', 'await bot.walkTo(3208, 3200);', 1000, controller.signal); + await new Promise(resolve => setTimeout(resolve, 5)); + controller.abort(new Error('client interrupted')); + const result = await work; + expect(result.isError).toBeTrue(); + expect(result.content[0]!.text).toContain('client interrupted'); + expect(h.internal.stateListeners.size).toBe(0); + expect(h.packets).toHaveLength(1); + }); + + test('a newer execute_code supersedes a running walk without overlapping it', async () => { + const h = harness(); + const executions = new BotExecutions(); + const first = executeCode(h.manager, 'test', 'await bot.walkTo(3208, 3200);', 1000, undefined, executions); + await Bun.sleep(5); + const second = executeCode(h.manager, 'test', 'return await sdk.sendWalk(3210, 3200);', 1000, undefined, executions); + const [old, next] = await Promise.all([first, second]); + expect(old.isError).toBeTrue(); + expect(old.content[0]!.text).toContain('Superseded'); + expect(next.isError).toBeUndefined(); + expect(h.packets).toHaveLength(2); + expect(h.internal.stateListeners.size).toBe(0); + }); + + test('disconnect supersedes the running execution', async () => { + const h = harness(); + const executions = new BotExecutions(); + const work = executeCode(h.manager, 'test', 'await bot.walkTo(3208, 3200);', 1000, undefined, executions); + await Bun.sleep(5); + let disconnected = false; + await executions.run('TEST', undefined, async () => { disconnected = true; }); + const result = await work; + expect(disconnected).toBeTrue(); + expect(result.isError).toBeTrue(); + expect(result.content[0]!.text).toContain('Superseded'); + }); + + test('cancelled code that swallows errors parks instead of starving the event loop', async () => { + const h = harness(); + const started = Date.now(); + let timerLag = -1; + setTimeout(() => { timerLag = Date.now() - started; }, 20); + const result = await executeCode(h.manager, 'test', + 'while (true) { try { await sdk.waitForTicks(1); } catch {} }', 10); + expect(result.content[0]!.text).toContain('timed out'); + await Bun.sleep(40); + expect(timerLag).toBeGreaterThan(0); + expect(timerLag).toBeLessThan(200); + }); + + test('concurrent bots keep their SDK logs in their own execution', async () => { + const a = harness(); + const b = harness(); + a.sdk.sendWait = async () => { console.log('SDK alpha'); await Bun.sleep(10); return { success: true, message: 'alpha' }; }; + b.sdk.sendWait = async () => { console.log('SDK beta'); await Bun.sleep(5); return { success: true, message: 'beta' }; }; + const restore = installExecutionConsoleCapture(); + try { + const [first, second] = await Promise.all([ + executeCode(a.manager, 'alpha', 'await sdk.sendWait();', 100), + executeCode(b.manager, 'beta', 'await sdk.sendWait();', 100), + ]); + expect(first.content[0]!.text).toContain('SDK alpha'); + expect(first.content[0]!.text).not.toContain('SDK beta'); + expect(second.content[0]!.text).toContain('SDK beta'); + expect(second.content[0]!.text).not.toContain('SDK alpha'); + } finally { restore(); } + }); + + test('TypeScript annotations are stripped and user-code errors retain their logs', async () => { + const h = harness(); + const result = await executeCode(h.manager, 'test', "const pattern: RegExp = /copper ore/i; console.log(pattern); throw new Error('diagnostic failure');", 100); + expect(result.isError).toBeTrue(); + expect(result.content[0]!.text).toContain('/copper ore/i'); + expect(result.content[0]!.text).toContain('diagnostic failure'); + }); + + test('cleans a wait even if its setup completes synchronously', async () => { + let cleaned = false; + expect(await executionWait(resolve => { resolve(7); return () => { cleaned = true; }; })).toBe(7); + expect(cleaned).toBeTrue(); + }); + + test('formats regexes and redacts credentials without losing useful diagnostics', () => { + expect(formatLogArgument(/copper ore/i)).toBe('/copper ore/i'); + const log = formatLogArgument({ pattern: /^iron ore$/, url: 'https://example.com/bot?bot=test&password=abc123' }); + expect(log).toContain('/^iron ore$/'); + expect(log).toContain('password=[REDACTED]'); + expect(log).not.toContain('abc123'); + }); + + test('walk deadlines terminate an otherwise stalled helper', async () => { + const h = harness(); + const result = await h.bot.walkTo(3208, 3200, 0, { timeout: 15 }); + expect(result).toMatchObject({ success: false, reason: 'timeout' }); + expect(h.internal.stateListeners.size).toBe(0); + expect(h.packets).toHaveLength(1); + }); + + test('does not send movement packets when no fresh state can be obtained', async () => { + const h = harness(); + h.internal.stateReceivedAt = Date.now() - 20_000; + h.sdk.waitForStateChange = async () => { throw new Error('No state'); }; + const result = await h.bot.walkTo(3208, 3200); + expect(result).toMatchObject({ success: false, reason: 'stale_state' }); + expect(h.packets).toHaveLength(0); + }); + + test('MCP warns about stale state but still runs the code', async () => { + const h = harness(); + h.internal.stateReceivedAt = Date.now() - 20_000; + h.sdk.waitForStateChange = async () => { throw new Error('No fresh state'); }; + const result = await executeCode(h.manager, 'test', 'return sdk.getStateAge() > 10_000;', 100); + expect(result.isError).toBeUndefined(); + expect(result.content[0]!.text).toContain('[warn] No fresh in-game state (No fresh state)'); + expect(result.content[0]!.text).toContain('── Result ──\ntrue'); + }); + + test('MCP keeps the existing brief world-state output', async () => { + const h = harness(); + h.internal.state.skills = [{ name: 'Attack', level: 1, baseLevel: 1, experience: 0 }]; + const result = await executeCode(h.manager, 'test', 'return "ok";', 100); + expect(result.content[0]!.text).toContain('Position: (3200, 3200)'); + expect(result.content[0]!.text).not.toContain('Attack: 1 (0 xp)'); + }); +}); diff --git a/sdk/test/mine-rock.test.ts b/sdk/test/mine-rock.test.ts new file mode 100644 index 000000000..e0c6a0416 --- /dev/null +++ b/sdk/test/mine-rock.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'bun:test'; +import { BotSDK } from '../index'; +import { BotActions } from '../actions'; + +function harness() { + const sdk = new BotSDK({ botUsername: 'test', password: 'test', autoLaunchBrowser: false }); + const internal = sdk as any; + internal.state = { + tick: 1, player: { animId: 625 }, inventory: [], + skills: [{ name: 'Mining', experience: 0 }], + nearbyLocs: [{ id: 2090, name: 'Rocks copper ore', x: 3282, z: 3367, distance: 1, reachable: true, optionsWithIndex: [{ text: 'Mine', opIndex: 1 }] }], + gameMessages: [], dialog: { isOpen: false }, interface: { isOpen: false }, + }; + const bot = new BotActions(sdk); + sdk.sendInteractLoc = async () => ({ success: true, message: 'dispatched' }); + return { sdk, bot, internal }; +} + +describe('mineRock completion evidence', () => { + test('does not complete on animation, XP alone, or an unrelated ore', async () => { + const h = harness(); + let settled = false; + const work = h.bot.mineRock(/copper ore/i, { timeout: 1000 }).then(result => { settled = true; return result; }); + await new Promise(resolve => setTimeout(resolve, 0)); + const publish = () => { for (const listener of h.internal.stateListeners) listener(h.internal.state); }; + publish(); + await Promise.resolve(); + expect(settled).toBeFalse(); + h.internal.state.skills[0].experience = 437; + h.internal.state.inventory = [{ id: 438, name: 'Tin ore', count: 1, slot: 0 }]; + publish(); + await Promise.resolve(); + expect(settled).toBeFalse(); + h.internal.state.inventory.push({ id: 436, name: 'Copper ore', count: 1, slot: 1 }); + publish(); + const result = await work; + expect(result).toMatchObject({ success: true, oresMined: 1, xpGained: 437, ore: { name: 'Copper ore' } }); + expect(h.internal.stateListeners.size).toBe(0); + }); + + test('times out when a mining animation never produces ore', async () => { + const result = await harness().bot.mineRock(/copper ore/i, { timeout: 10 }); + expect(result).toMatchObject({ success: false, reason: 'timeout', oresMined: 0 }); + }); + + test('gives a clear failure for an inventory-only selector', async () => { + expect(await harness().bot.mineRock(/^copper ore$/i)).toMatchObject({ success: false, reason: 'loc_not_found' }); + }); + + test('refuses a full inventory before clicking the rock', async () => { + const h = harness(); + h.internal.state.inventory = Array.from({ length: 28 }, (_, slot) => ({ id: 436, name: 'Copper ore', count: 1, slot })); + h.sdk.sendInteractLoc = async () => { throw new Error('must not click'); }; + expect(await h.bot.mineRock(/copper ore/i)).toMatchObject({ success: false, reason: 'inventory_full' }); + }); + + test('observes a fast ore completion even when no animation is published', async () => { + const h = harness(); + h.sdk.sendInteractLoc = async () => { + h.internal.state.player.animId = -1; + h.internal.state.skills[0].experience = 437; + h.internal.state.inventory = [{ id: 436, name: 'Copper ore', count: 1, slot: 0 }]; + return { success: true, message: 'dispatched' }; + }; + expect(await h.bot.mineRock(/copper ore/i)).toMatchObject({ success: true, oresMined: 1, xpGained: 437 }); + }); +}); diff --git a/sdk/types.ts b/sdk/types.ts index 530751302..9cce62f2a 100644 --- a/sdk/types.ts +++ b/sdk/types.ts @@ -755,6 +755,16 @@ export interface ChopTreeResult { message: string; } +/** One ore observed entering the inventory, with Mining XP as corroboration. */ +export interface MineRockResult { + success: boolean; + message: string; + ore?: InventoryItem; + oresMined: number; + xpGained: number; + reason?: 'loc_not_found' | 'no_matching_option' | 'cant_reach' | 'inventory_full' | 'rejected' | 'timeout'; +} + export interface BurnLogsResult { success: boolean; xpGained: number; @@ -920,6 +930,7 @@ export interface UseItemOnNpcResult { } export interface InteractLocResult { + /** Activity observed; for mining this can be an animation before ore arrives. Use mineRock for ore completion. */ success: boolean; message: string; reason?: 'loc_not_found' | 'no_matching_option' | 'cant_reach' | 'timeout'; diff --git a/wiki/skills/mining.md b/wiki/skills/mining.md index c9e7933fe..46c069889 100644 --- a/wiki/skills/mining.md +++ b/wiki/skills/mining.md @@ -20,14 +20,14 @@ Mining is used to extract ores and metals from rocks. Requires a pickaxe (availa | 1 | Rune essence | Varrock, Yanille rune essence mines | | 1 | Tin | SW Varrock, SE Varrock, Rimmington | | 1 | Copper | SW Varrock, SE Varrock, Rimmington | -| 10 | Iron | Asgarnian Ice Dungeon, Al Kharid | -| 15 | Silver | SE Varrock, Fight Arena, Al Kharid | -| 20 | Coal | Al Kharid, SW Varrock, Crafting Guild | -| 30 | Gold | Dwarven Mine, Coal Trucks, Barbarian Village, Mining Guild | -| 40 | Mithril | North Brimhaven, Crafting Guild | +| 15 | Iron | SE Varrock, Asgarnian Ice Dungeon, Al Kharid | +| 20 | Silver | SE Varrock, Fight Arena, Al Kharid | +| 30 | Coal | Al Kharid, SW Varrock, Crafting Guild | +| 40 | Gold | Dwarven Mine, Coal Trucks, Barbarian Village, Mining Guild | +| 55 | Mithril | North Brimhaven, Crafting Guild | | 40 | Gems | Shilo Village (requires Shilo Village quest) | -| 55 | Adamantite | Al Kharid, Fight Arena, West Lumbridge Swamp, Mining Guild | -| 70 | Runite | Al Kharid, West Lumbridge Swamp, Grand Tree, Desert Mining Camp | +| 70 | Adamantite | Al Kharid, Fight Arena, West Lumbridge Swamp, Mining Guild | +| 85 | Runite | Al Kharid, West Lumbridge Swamp, Grand Tree, Desert Mining Camp | ## Mining Guild