@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
99 lines • 4.62 kB
JavaScript
import { ToolPreflightDeniedError } from './tool-registry.js';
/**
* Transient tool-error classification + the retry loop that wraps a tool's own invocation. A
* classified-transient error (a DB deadlock, a lock-wait timeout, a serialization failure) means the
* server rolled the tool's work back — retrying THAT class is safe, unlike a tool's general business
* failure, which stays a one-shot outcome (no durable step retries: a tool may not be idempotent).
*
* `runAgentLoop` wraps `registry.invoke(...)` with {@link invokeWithTransientRetry} INSIDE the
* `tool:<call.id>` step body — so under durable replay the whole step is memoized on its successful
* result and a retry never becomes a new checkpoint; history still shows exactly one step per tool
* call and side effects run once.
*/
/** Narrow, structural check for a MySQL/Postgres/SQLite transient error shape — no casts. */
function hasTransientShape(error) {
if (typeof error !== 'object' || error === null) {
return false;
}
const code = 'code' in error ? error.code : undefined;
const errno = 'errno' in error ? error.errno : undefined;
const sqlState = 'sqlState' in error ? error.sqlState : undefined;
if (code === 1213 || code === 1205 || errno === 1213 || errno === 1205) {
return true; // MySQL ER_LOCK_DEADLOCK / ER_LOCK_WAIT_TIMEOUT numeric codes
}
if (code === 'ER_LOCK_DEADLOCK' || code === 'ER_LOCK_WAIT_TIMEOUT') {
return true; // MySQL driver string codes
}
if (code === '40001' || code === '40P01' || sqlState === '40001' || sqlState === '40P01') {
return true; // Postgres serialization_failure / deadlock_detected SQLSTATEs
}
if (code === 'SQLITE_BUSY') {
return true;
}
const message = 'message' in error ? error.message : undefined;
return (typeof message === 'string' && /deadlock|lock wait timeout|serialization failure/i.test(message));
}
/**
* Default transient-tool-error classifier: true for a recognized MySQL/Postgres/SQLite
* lock-contention shape (by driver `code`/`errno`/`sqlState`, or a matching message), checked on the
* error itself and one level of `cause` (drivers commonly wrap the original error). A plain `Error`
* with none of these markers — any other business failure — is `false`.
*/
export function isTransientToolError(error) {
if (error instanceof ToolPreflightDeniedError)
return false;
if (hasTransientShape(error)) {
return true;
}
if (typeof error === 'object' && error !== null && 'cause' in error) {
const cause = error.cause;
if (cause !== undefined && cause !== error && hasTransientShape(cause)) {
return true;
}
}
return false;
}
/** Total attempts (initial try + retries) when `toolTransientRetry` doesn't set `attempts`. */
export const DEFAULT_TOOL_TRANSIENT_RETRY_ATTEMPTS = 2;
/** Backoff base in ms — the wait between attempt N and N+1 is `backoffMs * N`. */
export const DEFAULT_TOOL_TRANSIENT_RETRY_BACKOFF_MS = 150;
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
/**
* Retries `fn` in place — never a new durable step/checkpoint, just repeated attempts inside
* whichever step body already wraps this call. `setting: false` runs `fn` once, unwrapped (no
* classify/backoff bookkeeping at all). Otherwise: try; on a thrown error, rethrow immediately if
* it's a recognized control-flow signal, else if the (possibly custom) classifier calls it transient
* AND attempts remain, wait `backoffMs * attemptNumber` and retry; otherwise rethrow the error as-is.
*/
export async function invokeWithTransientRetry(fn, setting, options) {
if (setting === false) {
return fn();
}
const attempts = setting.attempts ?? DEFAULT_TOOL_TRANSIENT_RETRY_ATTEMPTS;
const backoffMs = setting.backoffMs ?? DEFAULT_TOOL_TRANSIENT_RETRY_BACKOFF_MS;
const classify = setting.classify ?? isTransientToolError;
let attempt = 1;
for (;;) {
try {
return await fn();
}
catch (error) {
if (error instanceof ToolPreflightDeniedError ||
options?.isControlFlowError?.(error) === true) {
throw error;
}
const attemptsRemain = attempt < attempts;
if (!attemptsRemain || !classify(error)) {
throw error;
}
options?.onRetry?.(attempt, error);
await delay(backoffMs * attempt);
attempt += 1;
}
}
}
//# sourceMappingURL=tool-retry.js.map