UNPKG

agentfootprint

Version:

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

183 lines • 9.19 kB
"use strict"; /** * sessionBusy — the retry strategy: AgentCore Runtime's "session operation in * progress", recognised. One small function, on purpose. * * ── What the service says, in its own words ────────────────────────────────── * The AgentCore Runtime MCP protocol contract maps the runtime's exceptions onto * JSON-RPC errors, and exactly one of them is retryable by design: * * -32005 RetryableConflictException HTTP 200 "Session operation in progress, please retry" * * returned "when a second operation targets a session while the service is * provisioning or tearing down that session. Because MCP returns HTTP 200 with * the error in the JSON-RPC body, the caller must inspect the response body and * retry with short exponential backoff — MCP clients do not auto-retry it." * * So no status code says it, and the MCP SDK will not wait for it: left alone, * it reaches the model as a failed tool call, a moment after the session it was * waiting for came up. * * ── What asking again relies on ────────────────────────────────────────────── * AWS's contract returns this refusal while the service is provisioning or * tearing down the session: the PLATFORM's answer, about the session, not the * MCP server's. The retry series relies on that, as it relies on a 429 being a * rejection before execution: an assumption with a basis, not proof that * nothing ran (see `mcp/throttleRetry.ts` · `RetryDecision`). The adapter * retries it as its OWN policy ({@link SESSION_BUSY_ASSUMES}): each repeat * records that assumption (`permission: { by: 'adapter', assumes }`), and every * attempt's effect stays `unknown` (`mcp/replay.ts`). * * ── The known limit ────────────────────────────────────────────────────────── * The HTTP 200 form is matched by its code and message, so a server behind the * runtime can raise the same error itself — after it ran the call — and the * match cannot tell the two apart. Neither AWS page says a matching body ran * nothing. That is why the record never says so; the 409 form carries the * platform's own `x-amzn-errortype`. * * ── What it must NOT match ─────────────────────────────────────────────────── * `ConflictException` shares the code -32005 ("Resource conflict - Resource * already exists") and is not retryable; the contract tells the two apart only * by the message, so the message is part of the match. Anything else — another * status, an SSE stream, a batch reply, a body that is not JSON — is delivered * as it is. * * ── The API-layer form ─────────────────────────────────────────────────────── * `InvokeAgentRuntime`'s API reference gives the same exception as an HTTP 409, * its type in the `x-amzn-errortype` header (the REST protocol's error-type * header; the SigV4 strategy rebuilds exactly that from the SDK's exception, * because the transport's SDK client has its own retry off). It is the same * refusal, so it is the same decision — matched by the type, never by the 409 * alone. * * Pattern: Strategy (a `RetryDecision`). Role: outer ring, vendor fact. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.isSessionBusyReply = exports.sessionBusy = exports.SESSION_BUSY_CODE = exports.SESSION_BUSY_DOCUMENTATION = exports.SESSION_BUSY_ASSUMES = void 0; const abort_js_1 = require("../../../lib/abort.js"); const errorType_js_1 = require("./errorType.js"); /** * What the adapter assumes in retrying this refusal as its own policy: that * the platform refused the request before running it. Attached to the * session-busy retry rule (`transport.ts`) and recorded beside each repeat — * an assumption, never evidence. */ exports.SESSION_BUSY_ASSUMES = 'refused-before-execution'; /** * Where AgentCore documents the refusal this file recognises, in both forms — * the basis of the adapter's retry policy. Neither page says that a matching * reply ran nothing, so the record never does: * * 1. The AgentCore Runtime MCP protocol contract, "Error handling": the row * `-32005 · RetryableConflictException · HTTP 200 · "Session operation in * progress, please retry"`, returned "when a second operation targets a * session while the service is provisioning or tearing down that session". * 2. The `InvokeAgentRuntime` API reference, "Errors": * `RetryableConflictException`, HTTP 409 — "a temporary condition that may * resolve itself with retries". */ exports.SESSION_BUSY_DOCUMENTATION = Object.freeze([ 'https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-mcp-protocol-contract.html#mcp-error-handling', 'https://docs.aws.amazon.com/bedrock-agentcore/latest/APIReference/API_InvokeAgentRuntime.html#API_InvokeAgentRuntime_Errors', ]); /** The JSON-RPC error code the contract gives `RetryableConflictException`. */ exports.SESSION_BUSY_CODE = -32005; /** The exception's name, as the API layer and the SDK both spell it. */ const SESSION_BUSY_TYPE = 'RetryableConflictException'; /** * How the contract's message reads. Matched case-insensitively and as a * fragment, so a trailing "please retry" reworded does not stop the match; * the code is still required, which is what keeps `ConflictException` out. */ const SESSION_BUSY_MESSAGE = /session operation in progress/i; /** * Is this response AgentCore Runtime's session-busy refusal — in either form: * the MCP contract's HTTP 200 with JSON-RPC -32005, or the API's 409 * `RetryableConflictException`? * * Reads a CLONE of the body, so the response is delivered intact either way. * Answers `{}` (wait the loop's own backoff, then send the same request again) * or `undefined` (deliver it). * * `signal` is the request's: when it fires mid-read (the call's deadline, the * caller's cancellation), the clone's read is cancelled and released, and this * rejects with the abort — a stalled body never leaves a reader behind. */ async function sessionBusy(response, signal) { if (response.status === 409) { return (0, errorType_js_1.awsErrorType)(response.headers) === SESSION_BUSY_TYPE ? {} : undefined; } if (response.status !== 200) return undefined; if (!isJson(response.headers.get('content-type'))) return undefined; let text; try { text = await cloneText(response, signal); } catch (error) { // A stopped request is the caller's word, never "not busy": pass it on. if (signal?.aborted) throw error; return undefined; } // A cheap gate before parsing: a large tool result does not contain the // code's digits often, and it is the common case. if (!text.includes(String(exports.SESSION_BUSY_CODE))) return undefined; let body; try { body = JSON.parse(text); } catch { return undefined; } return isSessionBusyReply(body) ? {} : undefined; } exports.sessionBusy = sessionBusy; /** * The match itself, over a parsed body: ONE JSON-RPC reply whose error carries * the contract's code and message. A batch is never retried whole — some of * its replies may be the record of work that ran. */ function isSessionBusyReply(body) { if (body === null || typeof body !== 'object' || Array.isArray(body)) return false; const error = body.error; if (error === null || typeof error !== 'object') return false; const { code, message } = error; return (code === exports.SESSION_BUSY_CODE && typeof message === 'string' && SESSION_BUSY_MESSAGE.test(message)); } exports.isSessionBusyReply = isSessionBusyReply; /** * The text of a clone of `response`'s body, read under `signal`: an abort * cancels the clone's reader and releases it (`lib/abort.ts` · `bodyChunks`). * Decoded as `Response.text()` decodes: UTF-8, a leading BOM dropped. */ async function cloneText(response, signal) { const body = response.clone().body; if (body === null) return ''; const chunks = []; let length = 0; for await (const chunk of (0, abort_js_1.bodyChunks)(body, signal)) { chunks.push(chunk); length += chunk.byteLength; } const bytes = new Uint8Array(length); let at = 0; for (const chunk of chunks) { bytes.set(chunk, at); at += chunk.byteLength; } return new TextDecoder().decode(bytes); } /** `application/json`, with or without parameters, in any case. */ function isJson(contentType) { if (contentType === null) return false; const essence = contentType.split(';')[0]?.trim().toLowerCase(); return essence === 'application/json'; } //# sourceMappingURL=sessionBusy.js.map