Files
rs-sdk/mcp/console-capture.ts
max bittkerandClaude Opus 5.5 df3d8888ae 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 <noreply@anthropic.com>
2026-10-12 14:30:19 -04:00

97 lines
3.7 KiB
TypeScript

/**
* Bounded console capture for execute_code.
*
* Captured output used to be an unbounded array. A script that logs from a
* tight loop (e.g. a catch block with no await, retrying a call that throws
* synchronously) never yields, so the execution timeout can't fire and the
* array grows until the process - and the sandbox with it - runs out of
* memory. Keep the first `headMax` and last `tailMax` lines, clip long lines,
* 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;
export class ConsoleCapture {
private readonly head: string[] = [];
private readonly tail: string[] = []; // ring buffer once full
private tailStart = 0;
private omitted = 0;
constructor(
private readonly headMax = CONSOLE_HEAD_LINES,
private readonly tailMax = CONSOLE_TAIL_LINES,
private readonly maxLineChars = CONSOLE_MAX_LINE_CHARS,
) {}
push(line: string): void {
if (line.length > this.maxLineChars) {
line = `${line.slice(0, this.maxLineChars)} … [${line.length - this.maxLineChars} more chars]`;
}
if (this.head.length < this.headMax) {
this.head.push(line);
} else if (this.tail.length < this.tailMax) {
this.tail.push(line);
} else if (this.tailMax === 0) {
this.omitted++;
} else {
this.tail[this.tailStart] = line;
this.tailStart = (this.tailStart + 1) % this.tailMax;
this.omitted++;
}
}
/** Number of lines retained (not counting omitted ones). */
get length(): number {
return this.head.length + this.tail.length;
}
/** Total lines logged, including omitted ones. */
get total(): number {
return this.length + this.omitted;
}
lines(): string[] {
const tail = this.tail.slice(this.tailStart).concat(this.tail.slice(0, this.tailStart));
if (this.omitted === 0) return this.head.concat(tail);
const marker = `… ${this.omitted} line(s) omitted - console output keeps the first ${this.headMax} and last ${this.tailMax} lines …`;
return [...this.head, marker, ...tail];
}
}