UNPKG

@tanstack/ai-sandbox

Version:

Provider-agnostic sandbox layer for TanStack AI — run harness adapters inside isolated sandboxes (defineSandbox, defineWorkspace, withSandbox) with a uniform SandboxHandle, workspace bootstrap, policy, and resumable lifecycle.

119 lines (113 loc) 4.14 kB
/** * Helpers that let a harness adapter surface custom events emitted by BRIDGED * tools (via {@link ToolBridgeCoreOptions.emitCustomEvent}) on its live output * stream. * * The bridge runs out-of-band from the harness's own event stream, so a bridged * tool's progress/console events have no path to the client on their own. An * adapter creates a {@link BridgeEventChannel}, hands its `emitCustomEvent` to * the bridge provisioner, and {@link mergeChunkStreams | merges} the channel's * stream into its translated output — so events interleave live while the agent * runs (e.g. code mode's `code_mode:console` logs during a long execution). */ import { EventType, withTanstackMetadata } from '@tanstack/ai' import type { StreamChunk } from '@tanstack/ai' export interface BridgeEventChannel { /** Pass as the bridge's `emitCustomEvent`; buffers a CUSTOM chunk for the stream. */ emitCustomEvent: (eventName: string, value: Record<string, unknown>) => void /** Live CUSTOM-chunk stream; ends after {@link close} once drained. */ stream: AsyncIterable<StreamChunk> /** Stop the stream (call when the run's main output is done). */ close: () => void } /** Create a channel whose emitted events become CUSTOM {@link StreamChunk}s. */ export function createBridgeEventChannel(meta: { model: string threadId?: string runId?: string }): BridgeEventChannel { const buffer: Array<StreamChunk> = [] let notify: (() => void) | null = null let closed = false async function* stream(): AsyncIterable<StreamChunk> { for (;;) { const next = buffer.shift() if (next !== undefined) { yield next continue } if (closed) return await new Promise<void>((resolve) => { notify = resolve }) notify = null } } return { emitCustomEvent(eventName, value) { if (closed) return buffer.push( withTanstackMetadata( { type: EventType.CUSTOM, name: eventName, value, timestamp: Date.now(), }, { model: meta.model, ...(meta.threadId !== undefined ? { threadId: meta.threadId } : {}), ...(meta.runId !== undefined ? { runId: meta.runId } : {}), }, ) as StreamChunk, ) notify?.() }, close() { closed = true notify?.() }, stream: stream(), } } /** * Merge a `side` chunk stream into a `base` chunk stream, yielding from whichever * settles first. Terminates when `base` ends (the run is over), then releases the * side iterator — so a never-ending channel (until closed) doesn't hang the merge. */ export async function* mergeChunkStreams( base: AsyncIterable<StreamChunk>, side: AsyncIterable<StreamChunk>, ): AsyncIterable<StreamChunk> { const baseIt = base[Symbol.asyncIterator]() const sideIt = side[Symbol.asyncIterator]() let baseNext = baseIt.next().then((r) => ({ from: 'base' as const, r })) let sideNext = sideIt.next().then((r) => ({ from: 'side' as const, r })) let sideLive = true try { for (;;) { const winner = await Promise.race( sideLive ? [baseNext, sideNext] : [baseNext], ) if (winner.from === 'base') { if (winner.r.done) return yield winner.r.value baseNext = baseIt.next().then((r) => ({ from: 'base' as const, r })) } else if (winner.r.done) { sideLive = false } else { yield winner.r.value sideNext = sideIt.next().then((r) => ({ from: 'side' as const, r })) } } } finally { // Fire-and-forget: do NOT await the side return. The channel generator is // suspended on a promise that only `close()` resolves, and `close()` runs in // the adapter's `finally` AFTER this merge completes — awaiting here would // deadlock. The adapter's `close()` lets the generator unwind afterwards. const baseReturn = baseIt.return?.(undefined) if (baseReturn) void baseReturn.catch(() => {}) const sideReturn = sideIt.return?.(undefined) if (sideReturn) void sideReturn.catch(() => {}) } }