UNPKG

@tanstack/ai

Version:

Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.

63 lines (59 loc) 3.38 kB
/** * Internal seam the chat engine PROVIDES so middleware can learn that the * DELIVERY socket closed while the run is still going — and, crucially, learn it * WITHOUT the run being cancelled. * * WHY THIS EXISTS. Before it, the only way a disconnect reached middleware was for * the application to mirror `request.signal` into `chat()`'s `abortController`, * which ABORTS THE RUN. For a durable run that is precisely wrong, and wrong in * the most expensive direction: `chat()` returns at its `isCancelled()` check * immediately after middleware `setup`, so the harness adapter's `chatStream` is * never called and the agent in the sandbox that `setup` just spent minutes * creating is NEVER LAUNCHED. The user switched away during "starting the * sandbox", came back, and found an empty log belonging to a run that had done * nothing — with no takeover able to recover it, because an agent that never * started wrote no journal to replay. Applications were forced to choose between * "the middleware learns about the disconnect" and "the run survives it". * * So a disconnect is delivered as a NOTIFICATION. `withSandbox` uses it to stamp * `detachedSince`/`sandboxKey` and publish the detach verdict while the run keeps * producing into its still-open durable log — which is exactly what a re-attaching * client tails to catch up. * * A SUBSCRIPTION RATHER THAN A MIDDLEWARE HOOK, deliberately. `ChatMiddleware` is * public API and a new lifecycle hook there is a permanent commitment — including * the obligation to explain that it is the one hook that is NOT terminal. This * concern has exactly one consumer in the tree (`withSandbox`) and it reaches it * through the same internal channel the sandbox layer already uses for its runtime * (`SandboxRuntimeCapability`), so it ships with no public surface at all. * * DISPATCHED FROM THE TRANSPORT, NOT FROM THE RUN'S UNWINDING. That is what makes * it prompt. A run suspended inside a minutes-wide `setup` cannot dispatch anything * from its own `finally`, because the `finally` is reached only once the generator * unwinds — which is what made `detachedSince` land three minutes late. */ import { createCapability } from './capabilities' /** * Registry of disconnect listeners for one run. * * Listeners must do BOOKKEEPING ONLY. The run is still executing, so releasing * anything it depends on — stopping a file watcher, destroying a sandbox — breaks * a healthy run. Teardown belongs in the terminal hooks, which still run exactly * once afterwards. * * A listener may return a promise; the engine awaits all of them before the run * finishes, so bookkeeping cannot be lost to a race with the run's own completion. */ export interface RunDisconnect { /** * Register `listener`, called at most once per run when the delivery socket * closes. Registering after the socket has ALREADY closed calls `listener` * immediately — otherwise a middleware whose `setup` was still running during * the disconnect would silently never hear about it, which is the exact window * the common disconnect lands in. */ subscribe: (listener: () => void | Promise<void>) => void } export const RunDisconnectCapability = createCapability<RunDisconnect>()('run-disconnect') export const [getRunDisconnect, provideRunDisconnect] = RunDisconnectCapability