UNPKG

agentfootprint

Version:

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

348 lines • 16.1 kB
"use strict"; /** * What `agentCoreCodeRunner` has to know about AgentCore's wire, as pure * functions: how big a request is, how a returned file is paired with the * path asked for, how the staged-input manifest reaches a runtime that has no * environment parameter, and which service errors mean the session is gone. * * Kept apart from the session so each rule is one small function with its * own test, and so the session reads as the sequence it is. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.errorNameOf = exports.serviceRefusal = exports.sessionExpiryEvidence = exports.withManifestExport = exports.manifestSetupCode = exports.manifestRuntime = exports.MANIFEST_SET_MARKER = exports.STAGED_INPUTS_DIR = exports.uriNames = exports.pairReadFile = exports.fileContent = exports.jsonStringBytes = exports.writeFilesRequestBytes = exports.AGENTCORE_MAX_REQUEST_BYTES = void 0; const types_js_1 = require("../../provider-contract/types.js"); const codeSandbox_js_1 = require("../../provider-contract/codeSandbox.js"); const output_js_1 = require("./output.js"); // ── Request size ───────────────────────────────────────────────────────── /** * AgentCore's per-call limit for `InvokeCodeInterpreter`: "Max payload size — * 100 MB — Maximum size for request/response payloads", not adjustable * (AgentCore Code Interpreter service quotas). * * The service does not say whether that MB is 10^6 or 2^20 bytes, so the * adapter holds a request to the SMALLER reading: a request it sends is never * one the limit could refuse on size, and one between the two readings is * refused here by name rather than by the service in words nobody can act on. */ exports.AGENTCORE_MAX_REQUEST_BYTES = 100_000_000; /** `{"arguments":{"content":[` … `]},"name":"writeFiles"}` around the entries. */ const WRITE_FILES_ENVELOPE_BYTES = JSON.stringify({ arguments: { content: [] }, name: 'writeFiles', }).length; /** * The size, in bytes, of the JSON body one `writeFiles` request sends — * computed, not built: text as its JSON-escaped UTF-8, bytes as base64 (how * the JSON protocol carries a blob), plus the envelope. Building the body to * measure it would hold a second copy of up to a hundred megabytes. */ function writeFilesRequestBytes(content) { let bytes = WRITE_FILES_ENVELOPE_BYTES + Math.max(content.length - 1, 0); // the commas for (const entry of content) { bytes += '{"path":'.length + jsonStringBytes(entry.path) + '}'.length; if (entry.text !== undefined) bytes += ',"text":'.length + jsonStringBytes(entry.text); else bytes += ',"blob":'.length + 2 + base64Length(entry.blob.byteLength); } return bytes; } exports.writeFilesRequestBytes = writeFilesRequestBytes; /** Bytes of `JSON.stringify(text)` encoded as UTF-8, quotes included, without building it. */ function jsonStringBytes(text) { let bytes = 2; for (let i = 0; i < text.length; i++) { const unit = text.charCodeAt(i); if (unit === 0x22 || unit === 0x5c) bytes += 2; // \" and \\ else if (unit < 0x20) bytes += SHORT_ESCAPES.has(unit) ? 2 : 6; // \n … or \u00XX else if (unit < 0x80) bytes += 1; else if (unit < 0x800) bytes += 2; else if ((0, output_js_1.isHighSurrogate)(unit) && (0, output_js_1.isLowSurrogate)(text.charCodeAt(i + 1))) { bytes += 4; i++; } else if ((0, output_js_1.isHighSurrogate)(unit) || (0, output_js_1.isLowSurrogate)(unit)) bytes += 6; // a lone half: \udXXX else bytes += 3; } return bytes; } exports.jsonStringBytes = jsonStringBytes; /** Control characters JSON writes as a two-character escape: \b \t \n \f \r. */ const SHORT_ESCAPES = new Set([0x08, 0x09, 0x0a, 0x0c, 0x0d]); function base64Length(bytes) { return 4 * Math.ceil(bytes / 3); } /** One `writeFiles` entry for a payload: TEXT for a string, BYTES for bytes. */ function fileContent(path, data) { return typeof data === 'string' ? { path, text: data } : { path, blob: data }; } exports.fileContent = fileContent; // ── Reading a file back ────────────────────────────────────────────────── /** How AgentCore names a file it returns: `file:///` + the path asked for, * percent-encoded (read from a published real `readFiles` answer: * `report.csv` → `file:///report.csv`; the shape is not in AWS's docs). */ const FILE_URI_PREFIX = 'file:///'; /** * The one file a `readFiles` answer returned for `path` — or a refusal. * * Paired by the URI the SERVICE put on the file, never by position: a file * with no URI, or a URI naming another path, fails closed, and so does an * answer carrying several files for one path. Text or bytes is read off which * field the service filled — never off the file's name — and an entry with * both, or neither, is refused. A file over `maxBytes` is refused whole. */ function pairReadFile(path, answer, maxBytes) { const refuse = (reason, why) => { throw new codeSandbox_js_1.CodeSandboxError(reason, `agentCoreCodeRunner: reading '${path}' — ${why}`, { path }); }; if (answer.isError === true) { refuse('file-not-returned', 'the service flagged its answer as an error, so no file came back. Its words are not ' + 'repeated here.'); } const resources = answer.resources ?? []; if (resources.length === 0) refuse('file-not-returned', 'the answer held no file.'); for (const resource of resources) { if (typeof resource.uri !== 'string' || resource.uri.length === 0) { refuse('uri-missing', 'a file came back without a URI, so it cannot be paired with the path asked for.'); } if (hasQueryOrFragment(resource.uri)) { refuse('uri-mismatch', 'a file came back under a URI with a query or fragment, which is not part of any file ' + 'name (a literal ? or # in a name arrives as %3F or %23).'); } if (!uriNames(resource.uri, path)) { refuse('uri-mismatch', 'a file came back under a URI that names another path.'); } } if (resources.length > 1) refuse('content-ambiguous', `${resources.length} files came back for one path.`); const resource = resources[0]; const hasText = resource.text !== undefined && resource.text !== null; const hasBlob = resource.blob !== undefined && resource.blob !== null; if (hasText === hasBlob) { refuse('content-ambiguous', hasText ? 'the file came back as both text and bytes.' : 'the file came back with neither text nor bytes.'); } if (hasText && typeof resource.text !== 'string') refuse('content-ambiguous', 'its text is not a string.'); if (hasBlob && !isBytes(resource.blob)) refuse('content-ambiguous', 'its bytes are not bytes.'); const data = hasText ? resource.text : resource.blob; const bytes = (0, output_js_1.payloadBytes)(data); if (bytes > maxBytes) { throw new codeSandbox_js_1.CodeSandboxError('file-too-large', `agentCoreCodeRunner: '${path}' is ${bytes} bytes and this read allows ${maxBytes}. ` + 'Nothing was returned — a file is never handed back cut.', { path, bytes, limit: maxBytes }); } return { path, data, bytes, ...(typeof resource.mimeType === 'string' && resource.mimeType.length > 0 && { mediaType: resource.mimeType, }), }; } exports.pairReadFile = pairReadFile; /** * Does `uri` name `path`? ONE declared form, read ONE way (RFC 8089's file * URI, RFC 3986's path syntax): `file:///` followed by a path whose segments * are percent-encoded. * * - A raw `?` or `#` starts a query or a fragment, which is never part of a * file name, so a URI carrying one is refused outright. A name that really * contains `?` or `#` arrives as `%3F` / `%23`. * - Each segment is decoded on its own. An invalid escape refuses the URI, and * a decoded `/` (an encoded `%2F`) is a character inside one name, so it can * never stand in for the separator. * - Only the decoded path is compared. There is no second, raw reading to fall * back on when the first does not match. */ function uriNames(uri, path) { if (!uri.startsWith(FILE_URI_PREFIX)) return false; const named = uri.slice(FILE_URI_PREFIX.length); if (hasQueryOrFragment(named)) return false; const segments = []; for (const encoded of named.split('/')) { let segment; try { segment = decodeURIComponent(encoded); } catch { return false; } if (segment.includes('/')) return false; segments.push(segment); } return segments.join('/') === path; } exports.uriNames = uriNames; /** A raw `?` or `#` in a URI: a query or a fragment component. */ function hasQueryOrFragment(uriTail) { return uriTail.includes('?') || uriTail.includes('#'); } function isBytes(value) { return (value instanceof Uint8Array || Object.prototype.toString.call(value) === '[object Uint8Array]'); } // ── The staged-input manifest ──────────────────────────────────────────── /** Where staged inputs land, relative to the session's working directory. */ exports.STAGED_INPUTS_DIR = 'af-staged-inputs'; /** The line the manifest setup prints once the variable is set. */ exports.MANIFEST_SET_MARKER = 'af-staged-inputs-set'; /** * Which runtime a language names, for the manifest setup. `undefined` (no * language sent) is the service's default runtime, Python; a language this * adapter does not know is `undefined` — and refused, never guessed. */ function manifestRuntime(language) { if (language === undefined || language === '') return 'python'; const lower = language.toLowerCase(); if (lower === 'python' || lower === 'python3' || lower === 'py') return 'python'; if (lower === 'javascript' || lower === 'js' || lower === 'node') return 'javascript'; if (lower === 'typescript' || lower === 'ts') return 'typescript'; return undefined; } exports.manifestRuntime = manifestRuntime; /** * The code that sets {@link STAGED_INPUTS_ENV} inside one runtime of the * session, then prints {@link MANIFEST_SET_MARKER}. * * WHY A SEPARATE CALL. `executeCode` takes no environment, and prepending a * line to the caller's code would shift every line number its traceback * reports and break a Python `from __future__` import. The session's runtime * keeps its state between executions, so the variable is set once per * runtime and manifest, by this code, before the caller's code runs. * * The value is the manifest JSON as a JSON string literal, which is a valid * string literal in Python and in JavaScript alike. */ function manifestSetupCode(runtime, manifestJson) { const value = JSON.stringify(manifestJson); const name = JSON.stringify(types_js_1.STAGED_INPUTS_ENV); const marker = JSON.stringify(exports.MANIFEST_SET_MARKER); if (runtime === 'python') { return [ 'import os as _af_os', `_af_os.environ[${name}] = ${value}`, 'del _af_os', `print(${marker})`, ].join('\n'); } return [ '{', runtime === 'typescript' ? ' const _afHost: any = globalThis;' : ' const _afHost = globalThis;', ` const _afValue = ${value};`, ' if (_afHost.process && _afHost.process.env) {', ` _afHost.process.env[${name}] = _afValue;`, ' } else {', ` _afHost.Deno.env.set(${name}, _afValue);`, ' }', ` console.log(${marker});`, '}', ].join('\n'); } exports.manifestSetupCode = manifestSetupCode; /** * `command`, with {@link STAGED_INPUTS_ENV} exported ahead of it. * * A command is its own shell process, so a variable set in a code runtime * never reaches it; this line does, every time, and needs no state. The value * is single-quoted, the one shell quoting with no expansions inside it. */ function withManifestExport(command, manifestJson) { return `export ${types_js_1.STAGED_INPUTS_ENV}=${shellQuoted(manifestJson)}\n${command}`; } exports.withManifestExport = withManifestExport; function shellQuoted(text) { return `'${text.split("'").join(`'\\''`)}'`; } // ── Service errors ─────────────────────────────────────────────────────── /** The exceptions AgentCore models for `InvokeCodeInterpreter`, by class name. */ const MODELLED_EXCEPTIONS = new Set([ 'AccessDeniedException', 'ConflictException', 'InternalServerException', 'ResourceNotFoundException', 'ServiceQuotaExceededException', 'ThrottlingException', 'ValidationException', ]); /** Words a service uses for a session that is gone — both halves required, so * a validation message that merely echoes a request containing "inactive" * is not read as a dead session. */ const SAYS_INACTIVE = /\bnot active\b|\binactive\b/i; const NAMES_A_SESSION = /\bsession\b/i; /** * What a service error says about the session: `'session'` when it means the * session itself is gone, `'file-or-session'` when it could mean that or a * missing file, `undefined` otherwise. * * - A modelled exception — a `ValidationException` above all — whose words * name a session and say it is "not active" or "inactive": `'session'`. * - A `ResourceNotFoundException`, thrown by the SDK or carried on the stream: * on a call that addresses nothing but the session (code, a command, a * write) the missing resource is the session, so `'session'`. A READ also * names a file, and AgentCore does not say which resource was missing, so * `'file-or-session'` — never taken as proof the session is gone. */ function sessionExpiryEvidence(err, tool) { const name = errorNameOf(err); const message = errorMessage(err); if (MODELLED_EXCEPTIONS.has(name) && SAYS_INACTIVE.test(message) && NAMES_A_SESSION.test(message)) { return 'session'; } if (name === 'ResourceNotFoundException') { return tool === 'readFiles' ? 'file-or-session' : 'session'; } return undefined; } exports.sessionExpiryEvidence = sessionExpiryEvidence; /** * A modelled service exception from a file or command call, rewritten in * fixed words that name the exception and the call — or `undefined` for any * other error, which passes through as it is. * * The service's own message is NOT repeated: a validation message can quote * what was sent, and what was sent here is a path, a command or a file's * bytes. The exception's name is kept as the error's `name`, so a caller * branches on it exactly as on the SDK's own error. */ function serviceRefusal(err, tool) { const name = errorNameOf(err); if (!MODELLED_EXCEPTIONS.has(name)) return undefined; const refusal = new Error(`agentCoreCodeRunner: the service refused ${tool} (${name}). Its message is not repeated ` + 'here, because it can quote what was sent.'); refusal.name = name; return refusal; } exports.serviceRefusal = serviceRefusal; /** The `name` an error carries — an SDK exception's class name — or `''`. */ function errorNameOf(err) { const name = err?.name; return typeof name === 'string' ? name : ''; } exports.errorNameOf = errorNameOf; function errorMessage(err) { const message = err?.message; return typeof message === 'string' ? message : ''; } //# sourceMappingURL=agentcoreWire.js.map