UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

126 lines • 5.97 kB
/** * Transient-error classification for an MCP server, in the shape `toolTransientRetry` already speaks * (`classify: (error) => boolean`). {@link import('../tool-retry.js').isTransientToolError} * recognizes local database lock contention; a remote MCP server fails in a different vocabulary — * JSON-RPC error codes, HTTP statuses, socket errors — which is what this adds. */ /** JSON-RPC codes whose retry is safe: the request never reached the tool, or its answer was lost. */ const TRANSIENT_JSONRPC_CODES = new Set([-32000, -32001]); // ConnectionClosed, RequestTimeout /** HTTP statuses the streamable-HTTP transport surfaces for "try again", not "you asked wrong". */ const TRANSIENT_HTTP_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]); /** Socket/DNS failures from `fetch` or a spawned stdio server that a reconnect can clear. */ const TRANSIENT_SYSCALL_CODES = new Set([ 'ECONNRESET', 'ECONNREFUSED', 'ECONNABORTED', 'EPIPE', 'ETIMEDOUT', 'EAI_AGAIN', 'EHOSTUNREACH', 'ENETUNREACH', ]); /** * The SDK reports "you called while there was no transport" as a plain `Error` with no code, so the * message is the only marker available. Kept narrow on purpose — a message test that matched more * broadly would start retrying a remote tool's ordinary business failures, which are one-shot. */ const TRANSIENT_MESSAGES = /^not connected$|connection closed|socket hang up|fetch failed/i; function hasTransientMcpShape(error) { if (typeof error !== 'object' || error === null) { return false; } // A tool that answered `isError` failed on its own merits, and its message is text the remote // server wrote — matching it against the markers below would let a tool's prose decide whether // this side retries. Matched by name rather than `instanceof`, which a second copy of this // package (pnpm peer multiplexing, dual ESM/CJS) would not survive. if ('name' in error && error.name === 'McpToolCallError') { return false; } const code = 'code' in error ? error.code : undefined; if (typeof code === 'number') { // `McpError` and the streamable-HTTP transport both surface a `code`, and the two vocabularies // never overlap: JSON-RPC codes are negative, HTTP statuses are positive. return code < 0 ? TRANSIENT_JSONRPC_CODES.has(code) : TRANSIENT_HTTP_STATUSES.has(code); } if (typeof code === 'string' && TRANSIENT_SYSCALL_CODES.has(code)) { return true; } const message = 'message' in error ? error.message : undefined; return typeof message === 'string' && TRANSIENT_MESSAGES.test(message); } /** * True for a remote-MCP failure that the same call, made again, may well survive: a dropped * connection, a request timeout, a retryable HTTP status, a socket error. Checked on the error and * one level of `cause` — `fetch` reports a socket failure as a bare `TypeError` wrapping the real * one, the same wrapping core's classifier already unwraps for database drivers. * * A protocol-level refusal (`InvalidParams`, `MethodNotFound`) and a server-side `InternalError` * are deliberately NOT transient: the identical request would be refused identically, so retrying * only spends the turn's time budget. */ export function isTransientMcpError(error) { if (hasTransientMcpShape(error)) { return true; } if (typeof error === 'object' && error !== null && 'cause' in error) { const cause = error.cause; if (cause !== undefined && cause !== error && hasTransientMcpShape(cause)) { return true; } } return false; } /** * Syscall codes that mean the request was never delivered: the connection was refused, or the host * was never resolved or reached. A reset, a broken pipe or a timeout does NOT belong here — each of * those can equally well be a reply that was lost after the tool ran. */ const PRE_EXECUTION_SYSCALL_CODES = new Set([ 'ECONNREFUSED', 'EAI_AGAIN', 'EHOSTUNREACH', 'ENETUNREACH', ]); /** The SDK refusing to send because there is no transport — nothing left this process. */ const PRE_EXECUTION_MESSAGES = /^not connected$/i; function hasPreExecutionShape(error) { if (typeof error !== 'object' || error === null) { return false; } if ('name' in error && error.name === 'McpToolCallError') { return false; } const code = 'code' in error ? error.code : undefined; if (typeof code === 'string') { return PRE_EXECUTION_SYSCALL_CODES.has(code); } if (code !== undefined) { // A JSON-RPC code or an HTTP status means the wire carried the request far enough to be // answered, so neither can establish that the tool did not run. return false; } const message = 'message' in error ? error.message : undefined; return typeof message === 'string' && PRE_EXECUTION_MESSAGES.test(message); } /** * True only for a failure that PROVES the remote tool did not run: the connection was refused, the * host was never reached, or the SDK had no transport to send on. Checked on the error and one level * of `cause`, so `fetch`'s bare `TypeError` wrapper is read through to the socket error underneath. * * Everything {@link isTransientMcpError} also accepts — a request timeout, a dropped connection, a * 500/502/503, a reset mid-flight — is deliberately excluded: in each of those the request may have * arrived and executed, and only the answer was lost. Retrying them would run the tool twice. */ export function isPreExecutionMcpError(error) { if (hasPreExecutionShape(error)) { return true; } if (typeof error === 'object' && error !== null && 'cause' in error) { const cause = error.cause; if (cause !== undefined && cause !== error && hasPreExecutionShape(cause)) { return true; } } return false; } //# sourceMappingURL=transient.js.map