UNPKG

agentfootprint

Version:

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

316 lines • 14.1 kB
/** * adapters/hosting/responsesWire — the Responses protocol, as an `HttpWire`. * * ── What this is, and what it deliberately is not ──────────────────────────── * This file speaks the **Responses** wire protocol: a request carries `input` * as text or as message items, a reply is a `response` object with an id and a * status, and a stream is a LIFECYCLE of named events that open the response, * announce output, carry deltas, and close each part before closing the * response itself. * * That protocol is nobody's product. It is the shape a Responses-speaking * client sends and expects, and several hosted runtimes speak it. So it lives * here as a dialect on its own, and the runtimes that CONFIGURE it — their * paths, their ports, their probe bodies, their session aliases — live in their * own files beside this one. Keeping the split means the next runtime that * speaks Responses is a configuration rather than a copy, and it means no * vendor's spelling ends up in the protocol. * * ── The subset ─────────────────────────────────────────────────────────────── * A text turn, streamed or not, with a session. Deliberately not the whole * Responses API: tool calls, reasoning items, image/file input, structured * output and function-call output are NOT carried, and each is REFUSED by name * rather than dropped — a request whose content this dialect cannot represent * is answered 400 before a turn is paid for, never answered 200 with the parts * it happened to understand. * * @example A host that speaks Responses on paths of its own choosing * httpHost({ * name: 'myResponsesHost', * wire: responsesWire({ defaultModel: 'my-agent' }), * invokePath: '/responses', * healthPath: '/health', * }); */ import { randomUUID } from 'node:crypto'; import { WireRequestRefusal } from '../../hosting/errors.js'; /** Body fields that may carry a session id, in the order they are consulted. */ export const DEFAULT_SESSION_FIELDS = ['conversation', 'session_id']; /** * What a caller is told when a handler THREW. * * A thrown error's message is the author's note to their own logs — a query, a * path, a token in a connection string — and none of it is the caller's. A * handler that CHOSE to fail chose its words for the caller, so those travel * unchanged. The host tells this dialect which happened; it never guesses from * the message. */ export const PUBLIC_FAILURE_MESSAGE = 'The agent could not complete this request.'; const DEFAULT_MODEL = 'agentfootprint'; function isRecord(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } function nonEmptyString(value) { return typeof value === 'string' && value.trim() !== '' ? value : undefined; } /** * The text of one message's content, or a refusal naming what was in it. * * Every non-text part is refused rather than skipped: a resume with an attached * image, silently reduced to the text around it, is a wrong answer delivered * confidently. */ function inputTextFromContent(content) { if (typeof content === 'string') return content; if (!Array.isArray(content)) { throw new WireRequestRefusal('unsupported_input', 'message content must be text or an array of input_text parts'); } const text = []; for (const part of content) { if (!isRecord(part) || part.type !== 'input_text' || typeof part.text !== 'string') { const kind = isRecord(part) && typeof part.type === 'string' ? part.type : 'unknown'; throw new WireRequestRefusal('unsupported_input', `this dialect carries text input only; '${kind}' input is not supported`); } text.push(part.text); } return text.join(''); } /** The turn's text: a bare string, or the user message items that carry it. */ export function readResponsesInput(input) { if (typeof input === 'string') return input; if (input === undefined || input === null) { throw new WireRequestRefusal('invalid_input', 'input is required'); } if (!Array.isArray(input)) { throw new WireRequestRefusal('invalid_input', 'input must be text or an array of Responses message items'); } const messages = []; for (const item of input) { if (!isRecord(item)) { throw new WireRequestRefusal('invalid_input', 'each input item must be an object'); } // `type` is optional in the Responses grammar for a plain message. if (item.type !== undefined && item.type !== 'message') { throw new WireRequestRefusal('unsupported_input', `this dialect carries message items only; '${String(item.type)}' items are not supported`); } if (item.role !== 'user') { throw new WireRequestRefusal('unsupported_input', `this dialect carries user messages only; role '${String(item.role)}' is not supported`); } messages.push(inputTextFromContent(item.content)); } return messages.join('\n\n'); } /** The session this request claims, by whichever of its aliases it used. */ export function readResponsesSession(body, fields) { for (const field of fields) { const value = body[field]; // `conversation` is an object in the Responses grammar and a bare string in // several dialects of it. Both are read, because refusing the spelling a // client already sends teaches nothing. const found = isRecord(value) ? nonEmptyString(value.id) : nonEmptyString(value); if (found !== undefined) return found; } return undefined; } function identityFor(facts, options) { const body = facts?.body ?? {}; const nonce = randomUUID().replaceAll('-', ''); return { responseId: `resp_${nonce}`, messageId: `msg_${nonce}`, createdAt: Math.floor(Date.now() / 1000), model: nonEmptyString(body.model) ?? options.defaultModel, sessionId: readResponsesSession(body, options.sessionFields), }; } function outputPart(text) { return { type: 'output_text', text, annotations: [], logprobs: [] }; } function outputItem(identity, text, status) { return { id: identity.messageId, type: 'message', status, role: 'assistant', content: status === 'completed' ? [outputPart(text)] : [], }; } function responseObject(identity, status, text = '', error = null) { return { id: identity.responseId, object: 'response', created_at: identity.createdAt, status, error, incomplete_details: null, instructions: null, parallel_tool_calls: true, model: identity.model, output: status === 'completed' ? [outputItem(identity, text, 'completed')] : [], conversation: identity.sessionId === undefined ? null : { id: identity.sessionId }, usage: null, }; } /** * What a caller may be told about a failure. * * A refusal this library authored carries a `code` and says what it says. A * handler that threw carries none, and its words do not travel. */ function errorBody(message, code, origin) { if (origin === 'threw') { return { code: 'server_error', message: PUBLIC_FAILURE_MESSAGE, type: 'server_error' }; } return code === undefined ? { code: 'server_error', message, type: 'server_error' } : { code, message, type: 'invalid_request_error' }; } /** * The nine-event lifecycle one streamed response is made of. * * Holds this response's identity and its sequence counter, which is why it is * built per request: two callers sharing a counter would each see a stream that * skips numbers, and two sharing an id would see each other's response. */ function responsesFraming(identity) { let sequence = 0; let outputStarted = false; let streamed = ''; let sawDelta = false; const frame = (event, fields) => ({ event, data: { type: event, sequence_number: sequence++, ...fields }, }); /** * The two frames that must precede any text. Emitted on the first delta * rather than at open, because a response that fails before producing a word * never opened an output item and should not claim it did. */ const begin = () => { if (outputStarted) return []; outputStarted = true; return [ frame('response.output_item.added', { output_index: 0, item: outputItem(identity, '', 'in_progress'), }), frame('response.content_part.added', { item_id: identity.messageId, output_index: 0, content_index: 0, part: outputPart(''), }), ]; }; const delta = (text) => { if (text === '') return []; const frames = begin(); sawDelta = true; streamed += text; frames.push(frame('response.output_text.delta', { item_id: identity.messageId, output_index: 0, content_index: 0, delta: text, logprobs: [], })); return frames; }; return { open: () => [ frame('response.created', { response: responseObject(identity, 'in_progress') }), frame('response.in_progress', { response: responseObject(identity, 'in_progress') }), ], chunk: (text) => delta(text), complete: (output) => { const frames = []; // A handler that streamed nothing still owes the caller its text, and one // that streamed a PREFIX of it owes the rest. Anything else — a final // output that contradicts what was streamed — is left as it was streamed: // frames already on the wire cannot be taken back, and re-sending the // whole text would duplicate what the caller already rendered. if (!sawDelta) frames.push(...delta(output)); else if (output.startsWith(streamed) && output.length > streamed.length) { frames.push(...delta(output.slice(streamed.length))); } frames.push(...begin()); // THE COMPLETION IS AUTHORITATIVE — this library's law, and it outranks // the protocol's own tidiness. When a handler's chunks were a preview of // different final text rather than a prefix of it, the deltas already on // the wire cannot be recalled, and this reports the ANSWER rather than // the preview. The two then disagree, which is a documented limit of // streaming a preview; reporting the preview as the answer would be a // wrong answer, which is not. const text = output; frames.push(frame('response.output_text.done', { item_id: identity.messageId, output_index: 0, content_index: 0, text, logprobs: [], }), frame('response.content_part.done', { item_id: identity.messageId, output_index: 0, content_index: 0, part: outputPart(text), }), frame('response.output_item.done', { output_index: 0, item: outputItem(identity, text, 'completed'), }), frame('response.completed', { response: responseObject(identity, 'completed', text) })); return frames; }, failure: (message, code, origin) => [ frame('response.failed', { response: responseObject(identity, 'failed', '', errorBody(message, code, origin)), }), ], }; } /** * An `HttpWire` speaking the Responses protocol. * * Pair it with `httpHost` and the paths your deployment contract names. The * host keeps its own promises — draining, aborting on disconnect, failing a * handler that throws or answers nothing — and this supplies only the dialect. */ export function responsesWire(options = {}) { const resolved = { defaultModel: options.defaultModel ?? DEFAULT_MODEL, sessionFields: options.sessionFields ?? DEFAULT_SESSION_FIELDS, }; const health = options.health ?? { status: 'ok' }; return { readRequest(facts) { const input = readResponsesInput(facts.body.input); if (input.trim() === '') { throw new WireRequestRefusal('invalid_input', 'input text must not be empty'); } const sessionId = readResponsesSession(facts.body, resolved.sessionFields); return { input, ...(sessionId === undefined ? {} : { sessionId }) }; }, // This dialect's callers choose in the body and never touch `Accept`. wantsStream: (facts) => facts.body.stream === true, stream: (facts) => responsesFraming(identityFor(facts, resolved)), health: () => health, output: (output, facts) => responseObject(identityFor(facts, resolved), 'completed', output), chunk: (text) => ({ type: 'response.output_text.delta', delta: text }), failure: (message, code, facts, origin) => { // A terminal that ended an ACCEPTED response is reported as a failed // response object — that is what the protocol says a response that got // as far as running and then stopped looks like. A coded refusal ended // the REQUEST before any of that, and gets the error envelope a client // reads off a 4xx. if (code === undefined) { return responseObject(identityFor(facts, resolved), 'failed', '', errorBody(message, undefined, origin)); } return { error: errorBody(message, code, origin) }; }, }; } //# sourceMappingURL=responsesWire.js.map