@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
JavaScript
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