UNPKG

agentfootprint

Version:

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

399 lines • 22.1 kB
/** * agentCoreCodeRunner — AWS Bedrock AgentCore Code Interpreter behind the * {@link CodeRunner} port (peer-dep `@aws-sdk/client-bedrock-agentcore`). * * import { agentCoreCodeRunner } from 'agentfootprint/providers'; * const runner = agentCoreCodeRunner({ region: 'us-east-1', identifier: 'aws.codeinterpreter.v1' }); * * A REAL sandbox, which is the point: `localCodeRunner` gives you process * isolation on your own machine and says so; this gives you a managed, * network-and-filesystem-isolated environment, and the tool code is identical * across the swap. * * ── The operations, verified against the SDK ──────────────────────────────── * Pinned in `test/adapters/aws/awsCommandPin.ts`. The three commands were * verified against a real install of `@aws-sdk/client-bedrock-agentcore` * 3.1108.0, and the four `InvokeCodeInterpreter` tool shapes below against * 3.1159.0: * * • `StartCodeInterpreterSessionCommand` * in `{ codeInterpreterIdentifier, name?, sessionTimeoutSeconds? }` * out `{ codeInterpreterIdentifier, sessionId, createdAt }` * • `InvokeCodeInterpreterCommand` — `{ codeInterpreterIdentifier, sessionId, * name, arguments }`, one service tool per call: * `executeCode` `{ code, language? }` → `execute` * `writeFiles` `{ content: [{ path, text } | { path, blob }] }` → `stageInputs` * `readFiles` `{ paths: [path] }` → `readFile` * `executeCommand` `{ command }` → `runCommand` * out `{ sessionId, stream }` — an **event stream**, not a body * • `StopCodeInterpreterSessionCommand` * in `{ codeInterpreterIdentifier, sessionId }` ← the session ID, not a URI * * **`Invoke` answers with an `AsyncIterable`** whose members are `{ result }` * or a modelled EXCEPTION, and `./agentcoreClient.ts` drains it: exception * members raise BY NAME, result members fold into one answer — and nothing * the stream did not carry is filled in. * * ── Results say only what the service reported ────────────────────────────── * `CodeResult.ok` is `true` or `false` only when the service said which: an * exit status, or its own `isError` verdict. When it reported neither, `ok` * is ABSENT — unknown. (It used to be `true`: success from silence.) A * command's `exitCode` is the reported one or absent, never read off `isError`. * * ── Files in and out, exactly and bounded ─────────────────────────────────── * `stageInputs` writes TEXT as text and BYTES as a blob — never base64 inside * text with a decode step — in ONE `writeFiles` call, refused before it is * sent when its body would pass AgentCore's 100 MB per-call limit. AgentCore * takes no environment, so `AF_STAGED_INPUTS` reaches code by a setup * execution before EVERY run (confirmed by the line it prints; the runtime is * mutable by the code it runs, so an earlier setup proves nothing now) and * reaches a command as an `export` line ahead of it. `readFile` reads ONE path * and pairs the file to it by the URI the service returned — one `file:///` * form, read one way, a raw query or fragment refused — failing closed on a * missing or different one; text or bytes is whichever field the service * filled, and a file over `maxBytes` is refused, never cut. * * ── Commands are opt-in ───────────────────────────────────────────────────── * `runCommand` runs arbitrary shell, so a session has it only when the runner * is built with `commands: true`. The sandbox's network is whatever the code * interpreter resource was created with — the built-in `aws.codeinterpreter.v1` * runs with AWS's most restrictive defaults (Sandbox mode: limited network * access, Amazon S3 included); a custom interpreter may be Public (internet) * or VPC. This adapter can neither see nor change that. * * ── A session that is gone says so, once ──────────────────────────────────── * A service error naming the session as "not active" becomes * {@link SessionExpiredError} on every verb, and so does a * `ResourceNotFoundException` on a verb that addresses nothing but the session * (code, a command, a write). On a READ, "not found" may mean the file, so it * is `CodeSandboxError` (`'file-not-returned'`), naming both possibilities — * never offered as proof the session is gone. * * ── How this talks to the SDK (the 9.4.0 law) ─────────────────────────────── * Through `client.send(new SomeCommand(input))`, never a method on the client. * A bare `@aws-sdk/client-*` **Client is command-based**: its prototype carries * `send` and `destroy` and nothing else. Three adapters have shipped that bug * in this package. * * ── Credentials ───────────────────────────────────────────────────────────── * Standard AWS credential resolution (the SDK's own chain). A tool holding a * long-lived session must NOT cache a `Credential` object from `ctx.credential` * past the call that produced it — a session outliving a run outlives its * token. Re-resolve per execute through `ctx.credentials`. * * Pattern: Adapter (GoF) + lazy peer-dep load — the SDK is required only when * `start()` first runs (or never, if you inject `_client` / `_sdk`). */ import { CodeSandboxError, readBound, sandboxPath } from '../../provider-contract/codeSandbox.js'; import { SessionExpiredError } from '../../provider-contract/sessionExpired.js'; import { hashSessionKey } from '../../tools/toolSessions.js'; import { abortErrorOf, armDeadline, linkedController, throwIfAborted, untilAborted, } from '../../lib/abort.js'; import { createCodeClient, } from './agentcoreClient.js'; import { AGENTCORE_MAX_REQUEST_BYTES, errorNameOf, MANIFEST_SET_MARKER, STAGED_INPUTS_DIR, fileContent, manifestRuntime, manifestSetupCode, pairReadFile, serviceRefusal, sessionExpiryEvidence, withManifestExport, writeFilesRequestBytes, } from './agentcoreWire.js'; import { safeFileName } from './fileName.js'; import { boundOutput, commandBounds, commandResult, payloadBytes, printedLine } from './output.js'; const DEFAULT_MAX_OUTPUT_CHARS = 8_000; /** The adapter's own name, on every refusal it raises. */ const BACKEND = 'agentCoreCodeRunner'; export function agentCoreCodeRunner(options) { const id = options.id ?? 'agentcore-code-runner'; const maxOutputChars = options.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS; let client; const resolve = () => { client ??= options._client ?? createCodeClient(options); return client; }; return { id, async start(req) { const api = resolve(); const started = await api.startSession({ codeInterpreterIdentifier: options.identifier, // The DIGEST of the isolation key labels the session on AWS's side, so // an operator reading the console sees the same partition the runtime // enforces — and sees it under the same digest the trace publishes. // The key itself never leaves this process; see `sessionName`. name: sessionName(req.key), ...(options.sessionTimeoutSeconds !== undefined && { sessionTimeoutSeconds: options.sessionTimeoutSeconds, }), }); const sessionId = started.sessionId; if (!sessionId) { throw new Error('agentCoreCodeRunner: StartCodeInterpreterSession returned no sessionId. ' + 'Nothing can be invoked or stopped without one, so this is raised rather than ' + 'carried forward as an unusable session.'); } return openSession({ api, identifier: options.identifier, sessionId, language: req.language ?? options.language, maxOutputChars, commands: options.commands === true, }); }, }; } /** The session behind {@link agentCoreCodeRunner}: the verbs, in the order a caller uses them. */ function openSession(plan) { const { api, identifier, sessionId, maxOutputChars } = plan; const target = { codeInterpreterIdentifier: identifier, sessionId }; let stopped = false; /** name → path, exactly as the manifest carries it. */ const staged = new Map(); const refuseIfStopped = (toDo) => { if (stopped) { throw new Error(`agentCoreCodeRunner: session ${sessionId} was stopped; open a new one to ${toDo}.`); } }; const manifestJson = () => staged.size > 0 ? JSON.stringify(Object.fromEntries(staged)) : undefined; /** * One deadline and one cancellation for a whole verb. A verb that makes two * calls — the manifest setup, then the code — spends ONE `timeoutMs`, not * one each. A deadline or the caller's own signal stops the WAIT at once * (`lib/abort.ts`); what the service does with a request it already * received is its own business, and the timeout error says so. */ const withinBounds = async (verb, bounds, work) => { throwIfAborted(bounds.signal); const { controller, unlink } = linkedController(bounds.signal); const disarm = bounds.timeoutMs === undefined ? undefined : armDeadline(controller, bounds.timeoutMs, () => new CodeSandboxError('timed-out', `agentCoreCodeRunner: ${verb} did not answer within ${bounds.timeoutMs}ms ` + 'and was abandoned. The session was not stopped, so what was sent may still ' + 'be running in it — whether it finished is not known.')); try { return await work(controller.signal); } finally { disarm?.(); unlink(); } }; /** * One invoke under `signal`. A gone session becomes `SessionExpiredError`; * a modelled service error on a file or command call is rewritten without * the service's words (they can quote what was sent). */ const invoke = async (input, signal) => { throwIfAborted(signal); try { return await untilAborted(api.invoke(input, { abortSignal: signal }), signal); } catch (err) { if (signal.aborted) throw abortErrorOf(signal); const evidence = sessionExpiryEvidence(err, input.name); if (evidence === 'session') { throw new SessionExpiredError(BACKEND, sessionId, errorNameOf(err)); } if (evidence === 'file-or-session' && input.name === 'readFiles') { // A read names a FILE as well as the session, so "not found" can mean // either. It is not taken as proof the session is gone. throw new CodeSandboxError('file-not-returned', `agentCoreCodeRunner: reading '${input.arguments.paths.join(', ')}' — the service ` + 'answered ResourceNotFoundException, which can mean the file is missing or the ' + 'session is gone; which one is not known here.', { path: input.arguments.paths[0] ?? '' }); } if (input.name === 'executeCode') throw err; throw serviceRefusal(err, input.name) ?? err; } }; /** * Set {@link STAGED_INPUTS_ENV} in the runtime `language` runs in — before * EVERY execution once anything is staged, and confirmed each time. * * Never skipped because this process set it before: the runtime is mutable * by the very code it runs, so a previous execution may have changed or * deleted the variable, and a record of what was once sent is not an * observation of what the runtime holds. Re-applying it costs one call per * execution, inside the same deadline. Nothing when nothing was staged — a * session that never stages sends exactly what it always sent. */ const exposeManifest = async (language, signal) => { const json = manifestJson(); if (json === undefined) return; const runtime = manifestRuntime(language); if (runtime === undefined) { throw new CodeSandboxError('staged-inputs-unset', `agentCoreCodeRunner: inputs were staged, and this adapter does not know how to set ` + `AF_STAGED_INPUTS for language '${String(language)}', so the code is not run against ` + 'a manifest it could not read. AgentCore runs python, javascript and typescript.'); } const answer = await invoke({ ...target, name: 'executeCode', arguments: { code: manifestSetupCode(runtime, json), ...(language ? { language } : {}) }, }, signal); // The setup prints one line once the variable is set — it proves that // checkpoint and nothing else, which is all that is asked of it. if (!printedLine(answer.stdout, MANIFEST_SET_MARKER)) { throw new CodeSandboxError('staged-inputs-unset', `agentCoreCodeRunner: the staged inputs are in the session, but setting AF_STAGED_INPUTS ` + `in its ${runtime} runtime was not confirmed, so the code is not run against a ` + 'manifest it may not be able to read.'); } }; const session = { id: sessionId, async execute(exec) { refuseIfStopped('run more code'); const language = exec.language ?? plan.language; const bounds = { ...(exec.timeoutMs !== undefined && { timeoutMs: exec.timeoutMs }), ...(exec.signal && { signal: exec.signal }), }; const answer = await withinBounds('executeCode', bounds, async (signal) => { await exposeManifest(language, signal); return invoke({ ...target, name: 'executeCode', arguments: { code: exec.code, ...(language ? { language } : {}) }, }, signal); }); const bounded = boundOutput(answer.stdout, answer.stderr, maxOutputChars); const ok = reportedOutcome(answer); // An error flag with no exit status is the SERVICE's verdict, not the // program's: whether the code ran is unconfirmed, and the result says // which kind of `false` it is. const serviceError = answer.isError === true && answer.exitCode === undefined; return { ...(ok !== undefined && { ok }), stdout: bounded.stdout, stderr: bounded.stderr, ...(answer.exitCode !== undefined && { exitCode: answer.exitCode }), ...(serviceError && { serviceError: true }), ...(bounded.truncated !== undefined && { truncated: bounded.truncated }), }; }, async stageInputs(inputs) { refuseIfStopped('stage inputs'); if (inputs.length === 0) return []; // The name is CALLER data landing in a filesystem, so it is ENCODED // into one inert segment — the same encoder `localCodeRunner` uses, so // a name lands under the same file name on either backend. const landing = inputs.map((input) => ({ input, path: `${STAGED_INPUTS_DIR}/${safeFileName(input.fileName ?? input.name)}`, })); const content = landing.map(({ input, path }) => fileContent(path, input.data)); const bytes = writeFilesRequestBytes(content); if (bytes > AGENTCORE_MAX_REQUEST_BYTES) { throw new CodeSandboxError('request-too-large', `agentCoreCodeRunner: staging these ${inputs.length} input(s) would send a ` + `${bytes}-byte request, and AgentCore takes at most ${AGENTCORE_MAX_REQUEST_BYTES} ` + 'bytes in one call. Nothing was sent. Stage fewer or smaller inputs per call, or ' + 'have the code fetch large data itself (from Amazon S3, for example).', { bytes, limit: AGENTCORE_MAX_REQUEST_BYTES }); } const answer = await withinBounds('writeFiles', {}, (signal) => invoke({ ...target, name: 'writeFiles', arguments: { content } }, signal)); if (answer.isError === true) { throw new CodeSandboxError('write-failed', 'agentCoreCodeRunner: the service flagged the write of the staged inputs as an error, ' + 'so which of them landed is not known. Its words are not repeated here.'); } for (const { input, path } of landing) staged.set(input.name, path); return landing.map(({ input, path }) => ({ name: input.name, path, bytes: payloadBytes(input.data), })); }, async readFile(path, readOptions) { refuseIfStopped('read files'); const maxBytes = readBound(readOptions); const relative = sandboxPath(path); const answer = await withinBounds('readFiles', { ...(readOptions.signal && { signal: readOptions.signal }) }, (signal) => invoke({ ...target, name: 'readFiles', arguments: { paths: [relative] } }, signal)); return pairReadFile(relative, answer, maxBytes); }, ...(plan.commands && { async runCommand(command, commandOptions) { refuseIfStopped('run commands'); const bounds = commandBounds(command, commandOptions, maxOutputChars); const json = manifestJson(); const answer = await withinBounds('executeCommand', { ...(bounds.timeoutMs !== undefined && { timeoutMs: bounds.timeoutMs }), ...(bounds.signal && { signal: bounds.signal }), }, (signal) => invoke({ ...target, name: 'executeCommand', arguments: { command: json === undefined ? command : withManifestExport(command, json), }, }, signal)); return commandResult(answer, bounds); }, }), async stop() { if (stopped) return; stopped = true; staged.clear(); try { await api.stopSession({ codeInterpreterIdentifier: identifier, sessionId }); } catch (err) { // "Already gone" is the EXPECTED shape here, not an incident: the // session TTL is enforced by AWS, so a run that idled past it finds // the session collected. Anything else is re-raised, and the tier // reports it on `tools.session_close_failed`. if (!isAlreadyGone(err)) throw err; } }, }; return session; } /** * Did the code complete — as far as the service SAID? * * Its own error verdict first, then an exit status, then its explicit * "not an error". When it said none of those, the answer is `undefined`: * unknown, never success from silence. */ function reportedOutcome(answer) { if (answer.isError === true) return false; if (answer.exitCode !== undefined) return answer.exitCode === 0; if (answer.isError === false) return true; return undefined; } /** * A label for the remote session — derived FROM the isolation key, carrying * none of it. * * **Why not the key itself.** The isolation key is * `t=<tenant>/p=<principal>/s=<sessionId>` (see `toolSessionKey`), so it holds * a tenant name, a principal and the hosting session id. The previous version * of this function folded that string's illegal characters to `-` and sent the * result to AWS as `StartCodeInterpreterSession`'s `name` — which put * `af-t-acme-p-alice-s-s1` into a vendor control plane, its console, and its * audit log, for every session opened. One module over, * {@link hashSessionKey} exists precisely so the same identifiers never reach * the event wire ("enough to JOIN two rows and not enough to name whose they * are"). Two opposite decisions about one string; this is the one that agrees * with the rest of the library. * * **What it buys beyond not leaking.** The label is now byte-identical to the * `keyHash` on `agentfootprint.tools.session_closed`, so an operator with a * session in the AWS console can actually FIND its rows in the trace. The fold * could not do that: `alice:1` and `alice-1` produced one label, and any key * over 100 characters was truncated onto its neighbours, so the console name * an operator read did not identify one partition. * * **Not an address.** AWS documents `name` as a non-unique friendly handle; * nothing — here or in the SDK — ever looks a session up by it. The address is * the service-assigned `sessionId`, which `start()` reads back and passes to * every `Invoke` and `Stop` verbatim. So no stored state is keyed on this * string and relabelling migrates nothing — and no session is ever reattached * by it. */ function sessionName(key) { return `af-${hashSessionKey(key)}`; } function isAlreadyGone(err) { const name = err instanceof Error ? err.name : ''; return name === 'ResourceNotFoundException' || name === 'ValidationException'; } //# sourceMappingURL=agentcore.js.map