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
JavaScript
;
/**
* 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