UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

184 lines • 8.31 kB
/** * terminate — ending a client's remote session, and saying what the remote side * answered. * * Pattern: a pass-through `fetch` that reads one status + one bounded procedure. * Role: `McpClient.terminate()` (`mcpClient.ts`). The MCP SDK's * `StreamableHTTPClientTransport.close()` only aborts the requests in * flight; `terminateSession()` is the one method that sends the * Streamable HTTP `DELETE` carrying `Mcp-Session-Id`. It treats a 405 * as success, so the status is read here, by a pass-through `fetch` * the library installs on every transport it builds — it changes no * request and reads only a `DELETE`'s answer. * * The procedure, at most once per client: * * 1. no session id was ever issued → `'no-session'`, nothing is sent; * 2. otherwise the transport's own `terminateSession()`, bounded by * `TOOL_TEARDOWN_TIMEOUT_MS` (the tool-session tier's `withTimeout`, shared): * at the deadline the request is aborted and the outcome is `'timed-out'`; * 3. a local close, whatever happened. * * ONE DEADLINE ANSWERS THE CALLER: the remote answer and the local close are * waited for together for at most `TOOL_TEARDOWN_TIMEOUT_MS` of ELAPSED time, * measured by timers started together at the start — never by reading the wall * clock, so a clock stepped back cannot stretch it. A close that finishes in * time is finished before the caller hears; one that hangs does not hold the * answer. The work's own settlement — the `DELETE`, a `fetch` that ignores its * abort, the close — may come later, and is returned apart (`settled`): the MCP * client pool keeps an ending charged until then. The clock (`now`) only * measures `durationMs`. * * Never vendor-aware: the one port is the Streamable HTTP `DELETE`. A transport * that turns it into something else (AgentCore Runtime's `StopRuntimeSession`) * answers it like a server would. */ import { eitherSignal } from '../lib/abort.js'; import { isLocalSessionHandleLost, TOOL_TEARDOWN_TIMEOUT_MS, ToolTeardownTimeoutError, withTimeout, } from '../tools/toolSessions.js'; /** * Can `client` end its remote session? Reads the member, never assumes it — * the feature-detection law (`provider-contract/types.ts` · `canStageCodeInputs`). * True for `mcpClient()` and `mockMcpClient()`; false for a hand-written client * without `terminate`. */ export function canTerminate(client) { if (client === null || typeof client !== 'object') return false; try { return typeof client.terminate === 'function'; } catch { return false; } } /** * What the pass-through `fetch` saw of a transport's `DELETE` requests: the * status of the latest answer, and the signal it links into each one while an * ending is under way. */ export class DeleteWatch { status; cut; /** Arm for one ending: forget the last status, link `cut` into its `DELETE`. */ arm(cut) { this.status = undefined; this.cut = cut; } /** The status the latest `DELETE` was answered with, if one was. */ get answered() { return this.status; } /** * `inner` (the global `fetch` when `undefined`, looked up at call time), * passed through untouched except that a `DELETE`'s answer status is read and * an armed ending's signal is linked into it. */ fetch(inner) { const send = inner ?? ((input, init) => globalThis.fetch(input, init)); return async (input, init) => { if ((init?.method ?? 'GET').toUpperCase() !== 'DELETE') return send(input, init); const signal = this.cut === undefined ? init?.signal : eitherSignal(init?.signal ?? undefined, this.cut); const response = await send(input, signal === undefined ? init : { ...init, signal }); this.status = response.status; return response; }; } } /** The outcome for an ending that sent nothing. */ export function outcomeWithout(remote, durationMs = 0) { return { remote, resolved: remote === 'no-session', durationMs }; } /** * An ending that sends nothing — `remote` says why — only the local close: * the caller is answered when the close finishes or at `timeoutMs`, whichever * comes first; `settled` waits for the close itself. */ export function endLocally(remote, close, now = Date.now, timeoutMs = TOOL_TEARDOWN_TIMEOUT_MS) { const startedAt = now(); const closing = settle(close); return { outcome: within(closing, timeoutMs).then(() => outcomeWithout(remote, Math.max(0, now() - startedAt))), settled: closing, }; } /** * End `transport`'s session, then close locally with `close` — the procedure * in the module note. `watch` is the `DeleteWatch` installed on the transport's * fetch. `now` is the clock the duration is measured with. */ export function endSession(transport, watch, close, now = Date.now, timeoutMs = TOOL_TEARDOWN_TIMEOUT_MS) { if (transport.sessionId === undefined) return endLocally('no-session', close, now, timeoutMs); const startedAt = now(); const deadline = new AbortController(); watch.arm(deadline.signal); // The transport's own termination; its promise is the work this ending // waits for, however long the caller is answered before it. const work = Promise.resolve().then(() => transport.terminateSession()); const remote = withTimeout(() => work, timeoutMs, 'MCP session termination').then(() => answered(watch.answered, startedAt, now), (error) => { if (error instanceof ToolTeardownTimeoutError) { deadline.abort(error); return finished('timed-out', startedAt, now); } return failedWith(error, watch.answered, startedAt, now); }); // Closed locally once the remote side has its answer, whatever it is. const closing = remote.then(() => settle(close)); // The caller's wait for that close starts NOW, beside the remote one: both // end at the same elapsed deadline, whatever the wall clock does meanwhile. const outcome = within(closing, timeoutMs).then(() => remote); return { outcome, settled: Promise.allSettled([work, closing]).then(() => undefined), }; } /** `close()`, never rejecting. */ function settle(close) { return Promise.resolve() .then(close) .then(() => undefined, () => undefined); } /** `work`, or `ms` of elapsed time from now — whichever comes first. Never rejects; the work goes on. */ function within(work, ms) { return withTimeout(() => work, ms, 'MCP local close').then(() => undefined, () => undefined); } /** The SDK resolved: a 2xx, or a 405 it treats as success. */ function answered(status, startedAt, now) { if (status === 405) return finished('not-offered', startedAt, now, status); if (status !== undefined && status >= 200 && status < 300) { return finished('terminated', startedAt, now, status); } // Resolved without an answer this watch saw: nothing was sent. return finished('no-session', startedAt, now, status); } /** The SDK rejected: a 404, another status, a lost handle, or no answer at all. */ function failedWith(error, status, startedAt, now) { if (isLocalSessionHandleLost(error)) { return finished('handle-lost', startedAt, now, undefined, classOf(error)); } if (status === 404) return finished('already-gone', startedAt, now, status); return finished('failed', startedAt, now, status, classOf(error)); } function finished(remote, startedAt, now, status, errorClass) { return { remote, resolved: remote === 'terminated' || remote === 'already-gone' || remote === 'no-session', ...(status !== undefined && { status }), ...(errorClass !== undefined && { errorClass }), durationMs: Math.max(0, now() - startedAt), }; } /** A failure's class name, never its message. */ export function classOf(error) { if (error instanceof Error) { const ctor = error.constructor; if (typeof ctor?.name === 'string' && ctor.name !== '') return ctor.name; return error.name; } return typeof error; } //# sourceMappingURL=terminate.js.map