agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
178 lines • 8.84 kB
JavaScript
/**
* 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.
*/
import { bodyChunks } from '../../../lib/abort.js';
import { awsErrorType } from './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.
*/
export const 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".
*/
export const 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`. */
export const 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.
*/
export async function sessionBusy(response, signal) {
if (response.status === 409) {
return 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(SESSION_BUSY_CODE)))
return undefined;
let body;
try {
body = JSON.parse(text);
}
catch {
return undefined;
}
return isSessionBusyReply(body) ? {} : undefined;
}
/**
* 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.
*/
export 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 === SESSION_BUSY_CODE && typeof message === 'string' && SESSION_BUSY_MESSAGE.test(message));
}
/**
* 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 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