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
JavaScript
/**
* 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