UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

203 lines • 9.74 kB
import { defineTool } from './ai-tool-ref.js'; import { confirmTokenExpiry, DEFAULT_CONFIRM_TTL_MS, hashConfirmToken, signConfirmToken, verifyConfirmToken, } from './confirm-token.js'; const DEFAULT_MESSAGES = { confirm: 'Nothing was written. Show this preview to the user and ask them to confirm. To commit, call this tool again with the SAME arguments plus "confirm": true and this "confirmToken" (valid for {minutes} minutes).', invalid: 'Invalid or expired confirmation: nothing was written. Call the tool without "confirm" to get a fresh preview, then confirm with the "confirmToken" it returns, without changing the arguments.', used: 'This preview was already confirmed: nothing was written again. To repeat the operation, ask for a new preview.', }; /** Thrown by a confirmed tool that refused a confirmation. Nothing was written. */ export class ConfirmTokenError extends Error { reason; constructor( /** `invalid`: missing, tampered, mismatched or expired. `used`: it already committed once. */ reason, message) { super(message); this.reason = reason; this.name = 'ConfirmTokenError'; } } /** The JSON Schema of {@link ConfirmFields}, merged into the schema the model is shown. */ export const CONFIRM_JSON_SCHEMA_PROPERTIES = { confirm: { type: 'boolean', description: 'Omit to get a preview (nothing is written). Send true, with confirmToken, to commit.', }, confirmToken: { type: 'string', description: 'The confirmToken the preview returned. Required when confirm is true.', }, }; function isRecord(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } /** Split a call's input into the app's arguments and the two confirmation fields. */ function splitConfirm(input) { if (!isRecord(input)) return { args: input, fields: {} }; const { confirm, confirmToken, ...args } = input; return { args, fields: { ...(typeof confirm === 'boolean' ? { confirm } : {}), ...(typeof confirmToken === 'string' ? { confirmToken } : {}), }, }; } /** The schema's Standard JSON Schema `input` converter, when it has one. */ function jsonSchemaInputOf(schema) { const standard = schema['~standard']; const input = standard.jsonSchema?.input; return typeof input === 'function' ? (options) => input.call(standard.jsonSchema, options) : undefined; } /** * Wrap a tool's input schema so it also accepts {@link ConfirmFields}, without the app declaring * them. `validate` takes the two fields off, checks them itself, hands the REST to the app's schema * (so a strict schema never sees a key it did not declare) and puts them back on the validated value. * * The wrapper always carries the Standard JSON Schema extension, because that is the only form the * model gets to see: the app schema's own JSON Schema plus the two properties. A schema without the * extension (Zod 3, a bare Standard Schema) degrades to a permissive object that still declares the * two fields — `validate` stays the authority either way. */ export function withConfirmFields(schema) { const inner = schema['~standard']; const innerJsonSchema = jsonSchemaInputOf(schema); const describe = (options) => { const base = innerJsonSchema?.(options) ?? { additionalProperties: true }; return { ...base, type: 'object', properties: { ...(isRecord(base.properties) ? base.properties : {}), ...CONFIRM_JSON_SCHEMA_PROPERTIES, }, }; }; const wrapped = { '~standard': { version: 1, vendor: '@adonis-agora/agent', validate: async (value) => { if (!isRecord(value)) { return { issues: [{ message: 'expected an object' }] }; } const issues = []; // `null` is how some models say "absent"; it is read as absent rather than refused. if (value.confirm != null && typeof value.confirm !== 'boolean') { issues.push({ message: 'expected a boolean', path: ['confirm'] }); } if (value.confirmToken != null && typeof value.confirmToken !== 'string') { issues.push({ message: 'expected a string', path: ['confirmToken'] }); } const { args, fields } = splitConfirm(value); const result = await inner.validate(args); if (result.issues !== undefined) { return { issues: [...issues, ...result.issues] }; } if (issues.length > 0) { return { issues }; } return { value: isRecord(result.value) ? { ...result.value, ...fields } : result.value }; }, jsonSchema: { input: describe, output: describe }, }, }; return wrapped; } /** * A write with a human gate that lives INSIDE the tool: preview first, commit on confirmation. * * - Called without `confirm`: runs `prepare`, writes nothing, and returns the `preview` with a signed * `confirmToken`. * - Called again with the SAME arguments, `confirm: true` and that token: runs `prepare` again, then * `commit`. * * The token is an HMAC over the tool, `ctx.actor.id`, `ctx.actor.tenantRef`, the expiry and the * canonical arguments, so it cannot be replayed by another actor, in another tenant, on another * tool or with a changed argument. With a `store` it is also single use: it is claimed right before * `commit` (a refusal in `prepare` does not spend it) and released if `commit` throws. * * Registered as `kind: 'read'` on purpose. An `action` parks on the loop's HITL approval, which an * MCP caller cannot answer — `createMcpServer` keeps `action` tools off the surface, and MCP clients * without elicitation have no other channel. The gate being in the handler is what lets the same * tool serve the chat loop and MCP; the user sees the preview because the model has to relay it to * get a token it can confirm with. * * ```ts * export const refundOrder = defineConfirmedTool( * { name: 'refund_order', description: '...', input: z.object({ orderId: z.string() }), * secret: () => env.get('APP_KEY').release(), store: new LucidConfirmTokenStore(db) }, * { * prepare: async ({ orderId }, ctx) => loadRefundableOrder(orderId, ctx.actor), * preview: (order) => ({ summary: `Refund ${order.total} to ${order.customer}?`, data: order }), * commit: async (order) => ({ summary: 'Refunded.', data: await refund(order) }), * }, * ) * ``` * * `input` is the app's own schema: `confirm` and `confirmToken` are added by * {@link withConfirmFields}, and `prepare` receives the arguments without them. */ export function defineConfirmedTool(options, steps) { const { secret, ttlMs = DEFAULT_CONFIRM_TTL_MS, store, messages, ...toolOptions } = options; const text = { ...DEFAULT_MESSAGES, ...messages }; const howToConfirm = text.confirm.replaceAll('{minutes}', String(Math.round(ttlMs / 60_000))); const secretNow = () => (typeof secret === 'function' ? secret() : secret); return defineTool({ ...toolOptions, kind: 'read', input: withConfirmFields(options.input) }, async (input, ctx) => { const { args, fields } = splitConfirm(input); const subject = { tool: options.name, actorId: ctx.actor.id, ...(ctx.actor.tenantRef !== undefined ? { tenantRef: ctx.actor.tenantRef } : {}), args, }; const confirming = fields.confirm === true; const token = fields.confirmToken; if (confirming && !verifyConfirmToken(token, subject, { secret: secretNow() })) { throw new ConfirmTokenError('invalid', text.invalid); } const prepared = await steps.prepare(args, ctx); if (!confirming || token === undefined) { const preview = await steps.preview(prepared, ctx); const expiresAt = Date.now() + ttlMs; return { status: 'preview', summary: preview.summary, ...(preview.data !== undefined ? { data: preview.data } : {}), confirmToken: signConfirmToken(subject, { secret: secretNow(), expiresAt }), expiresAt: new Date(expiresAt).toISOString(), confirm: howToConfirm, }; } // Claimed only now: everything `prepare` refuses leaves the token usable, and a commit that // throws gives it back so the same preview can be confirmed again. const hash = hashConfirmToken(token); if (store !== undefined) { const claimed = await store.claim({ hash, actorRef: ctx.actor.id, tool: options.name, expiresAt: confirmTokenExpiry(token) ?? Date.now() + ttlMs, }); if (!claimed) { throw new ConfirmTokenError('used', text.used); } } try { const done = await steps.commit(prepared, ctx); return { status: 'done', summary: done.summary, ...(done.data !== undefined ? { data: done.data } : {}), }; } catch (error) { // The commit's own error is the one worth reporting, whatever the release does. await store?.release(hash).catch(() => undefined); throw error; } }); } //# sourceMappingURL=confirmed-tool.js.map