agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
182 lines • 9.2 kB
JavaScript
;
/**
* adapters/hosting/agentCoreA2A — an agentfootprint agent as an AWS Bedrock
* **AgentCore A2A** server, callable by other agents.
*
* ── The split, again ─────────────────────────────────────────────────────────
* Two different things are needed, and only one is Amazon's:
*
* • The **A2A protocol** — JSON-RPC 2.0, `message/send`, artifacts, the agent
* card. An open protocol several runtimes speak, so it lives in
* `a2aWire.ts` with no vendor in it.
* • The **container contract** — port 9000 (not 8080, and not 8000: each
* protocol gets its own), the agent mounted at `/`, `GET /ping` answering
* `{"status":"Healthy"}`, the session id arriving in
* `X-Amzn-Bedrock-AgentCore-Runtime-Session-Id`, and a table of JSON-RPC
* error codes that are this runtime's exceptions rather than A2A's. That
* IS Amazon's, and it is what this file supplies.
*
* ── One deviation you must know about ────────────────────────────────────────
* The A2A specification delivers JSON-RPC errors over HTTP 200. **AgentCore
* does not** — it returns the real status code (409, 404, …) with the JSON-RPC
* error body. That is a fact about the platform in front of your container, so
* a CLIENT must parse the error body even on a non-2xx response; see
* `agentCoreA2AErrorCode` for the table this host mirrors on the way out.
*
* ── What is NOT provided ─────────────────────────────────────────────────────
* `message/stream` and the task lifecycle. The agent card this host serves
* therefore declares `streaming: false` unless you overrule it, because a card
* that advertises streaming to a wire that cannot stream is a caller left
* waiting. AWS's own sample card says `true`; ours says what is true of ours.
*
* @example An agent other agents can call
* import { agentCoreA2AHost, memorySessions, standingAgent } from 'agentfootprint/hosting';
*
* const handle = await standingAgent({
* agent,
* sessions: memorySessions(),
* host: agentCoreA2AHost({
* card: { name: 'triage', description: 'Triages SAN alerts.', version: '1.0.0' },
* }),
* });
* process.on('SIGTERM', () => void handle.close());
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.agentCoreA2AHost = exports.agentCoreA2AErrorCode = exports.AGENTCORE_SESSION_HEADER = exports.AGENTCORE_PING_PATH = exports.AGENTCORE_A2A_INVOKE_PATH = exports.DEFAULT_AGENTCORE_A2A_PORT = void 0;
const doorGuard_js_1 = require("../../hosting/doorGuard.js");
const httpHost_js_1 = require("../../hosting/httpHost.js");
const a2aWire_js_1 = require("./a2aWire.js");
/** The port an AgentCore A2A server is expected on — its own, not HTTP's or MCP's. */
exports.DEFAULT_AGENTCORE_A2A_PORT = 9000;
/** A2A mounts the agent at the root. */
exports.AGENTCORE_A2A_INVOKE_PATH = '/';
/** The runtime's health path for every protocol. */
exports.AGENTCORE_PING_PATH = '/ping';
/** Where the platform puts the caller's session. */
exports.AGENTCORE_SESSION_HEADER = 'X-Amzn-Bedrock-AgentCore-Runtime-Session-Id';
/**
* AgentCore's own JSON-RPC error codes, mapped from this library's refusal codes.
*
* These numbers are the RUNTIME's exception table, not A2A's: `-32051`
* ResourceNotFound, `-32052` Validation, `-32053` Throttling and
* ServiceQuotaExceeded, `-32054` Conflict and RetryableConflict, `-32055`
* RuntimeClientError, `-32603` anything else. A client reads them to decide
* whether to retry — `-32054` with "Session operation in progress" is the one
* that must be retried with backoff, and A2A clients do not do that on their
* own.
*
* Exported so a client built against this host can share one table with it
* rather than keep a second copy that drifts.
*/
function agentCoreA2AErrorCode(code) {
switch (code) {
case 'ERR_SESSION_NOT_FOUND':
case 'ERR_ARTIFACT_NOT_FOUND':
return -32051;
// ERR_RESUME_DECISION is a Validation, not -32054: a client retries a
// Conflict with backoff, and the same decision is refused the same way
// every time.
case 'ERR_INVALID_WIRE_OP':
case 'ERR_DECISION_REQUIRED':
case 'ERR_RESUME_DECISION':
case 'ERR_ARTIFACT_SESSION_REQUIRED':
case 'invalid_request':
case 'invalid_params':
case 'unsupported_part':
return -32052;
case 'ERR_ADMISSION_REFUSED':
case 'ERR_REQUEST_TOO_LARGE':
return -32053;
case 'ERR_CONCURRENT_RUN':
case 'ERR_HOST_CLOSED':
case 'ERR_AWAITING_DECISION':
case 'ERR_PAUSE_NOT_CARRIED':
return -32054;
case 'method_not_found':
// JSON-RPC's own "method not found" is -32601, but this runtime reports a
// route it cannot resolve as ResourceNotFound. The platform's table wins
// inside the platform's container.
return -32051;
default:
return -32603;
}
}
exports.agentCoreA2AErrorCode = agentCoreA2AErrorCode;
const DEFAULT_MAX_BODY_BYTES = 1_048_576;
/**
* An {@link HttpHost} serving AgentCore's A2A container contract.
*
* `httpHost` keeps its own promises — draining, aborting on disconnect, failing
* a handler that throws or answers nothing — and this supplies the protocol,
* the paths, the port, the session header and the error table.
*/
function agentCoreA2AHost(options) {
const { server, onUnhandled, hostname, card } = options;
const cardDocument = JSON.stringify((0, a2aWire_js_1.a2aAgentCardDocument)(card));
/** The discovery document, served before anything falls through to the caller. */
const serveCard = (req, res) => {
const path = (req.url ?? '').split('?')[0];
if (req.method === 'GET' && path === a2aWire_js_1.A2A_AGENT_CARD_PATH) {
res.writeHead(200, {
'content-type': 'application/json',
'content-length': Buffer.byteLength(cardDocument),
});
res.end(cardDocument);
return;
}
// Not the card: the caller's own routes, if they have any. Chaining rather
// than consuming the slot — an adapter that took `onUnhandled` for itself
// would silently remove a seam the host documents as the consumer's.
if (onUnhandled) {
onUnhandled(req, res);
return;
}
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: `no route for ${req.method ?? '?'} ${path}` }));
};
const socket = server
? { server }
: {
port: options.port ?? exports.DEFAULT_AGENTCORE_A2A_PORT,
...(hostname === undefined ? {} : { hostname }),
onUnhandled: serveCard,
};
const protocol = (0, a2aWire_js_1.a2aWire)({
card,
// The runtime's health contract, not A2A's — A2A has no /ping at all.
health: { status: 'Healthy' },
errorCodeFor: agentCoreA2AErrorCode,
name: 'agentCoreA2AHost',
});
return (0, httpHost_js_1.httpHost)({
name: 'agentCoreA2AHost',
wire: {
...protocol,
// The one thing the protocol does not carry and the platform does: which
// conversation this call belongs to, in a header of the runtime's own.
readRequest(facts) {
const base = protocol.readRequest(facts);
const sessionId = (0, httpHost_js_1.headerValue)(facts, exports.AGENTCORE_SESSION_HEADER);
return { ...base, ...(sessionId !== undefined && { sessionId }) };
},
},
invokePath: exports.AGENTCORE_A2A_INVOKE_PATH,
healthPath: exports.AGENTCORE_PING_PATH,
// NOT `['streaming']`, which is this file's default. `message/send` has
// nowhere to put a chunk: a caller gets one JSON-RPC reply and nothing
// before it. Declaring streaming anyway would make `requireCapability`
// pass for a host that cannot honour it, and would contradict the agent
// card, which says `streaming: false` for the same reason. The host
// conformance suite catches exactly this — it asserts chunks IF AND ONLY IF
// the capability is declared, and it caught it here.
capabilities: [],
maxBodyBytes: options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES,
// The platform fronts this port: browser rules off unless the deployment
// sets them (see the options' doc) — except on a LOOPBACK bind, which no
// platform fronts, where every plain-host default applies.
...(0, doorGuard_js_1.platformFrontedDoor)(options, server === undefined ? hostname : undefined),
...socket,
});
}
exports.agentCoreA2AHost = agentCoreA2AHost;
//# sourceMappingURL=agentCoreA2A.js.map