Разделите возврат на preview, approval и commit
Возврат денег - необратимое действие, и агент не выполняет его сам. Agent loop может подготовить расчёт (preview), пользователь подтверждает точный расчёт (approval), а отдельный service повторно проверяет всю бизнес-политику и выполняет идемпотентную транзакцию (commit). Три шага - три уровня доверия.
Preview - это tool: он проверяет владение и политику и создаёт запись в доверенном хранилище.
type RefundPreview = {
id: string;
actorId: string;
orderId: string;
orderVersion: number;
amountMinor: number;
currency: string;
reasonCode: string;
expiresAt: string;
status: "pending";
};
async function refundPreview(raw: unknown, context: TrustedToolContext) {
const input = refundPreviewInput.parse(raw);
const order = await requireOwnedOrder(input.order_id, context);
const decision = await refundPolicy.evaluate(order, input.reason_code);
if (!decision.allowed) return { eligible: false, reason: decision.publicReason };
const preview = await previews.createFromDecision(order, decision, context.actorId);
return { eligible: true, preview_id: preview.id };
}А commit - это отдельный endpoint, который модель не вызывает вовсе.
export async function POST(request: Request) {
const actor = await requireSession(request);
const body = approvalInput.parse(await request.json());
const result = await db.transaction(async function commit(tx) {
const preview = await tx.refundPreview.lock(body.preview_id);
requireSameActor(preview, actor.id);
requirePendingAndFresh(preview, new Date());
const order = await tx.order.lock(preview.orderId);
requireVersion(order, preview.orderVersion);
await refundPolicy.revalidate(order, preview);
const refund = await payments.refund({
orderId: order.id,
amountMinor: preview.amountMinor,
currency: preview.currency,
idempotencyKey: `refund:${preview.id}`
});
await tx.refundPreview.markCommitted(preview.id, refund.id);
return refund;
});
return Response.json({ status: "completed", refund_id: result.id });
}Посмотрите, что происходит в commit: он блокирует preview и заказ, сверяет actor, срок, версию заказа, ещё раз проверяет политику и выполняет платёж с idempotency key. Ни одно из этих значений не приходит от модели.
"Пользователь согласился" не является аргументом tool. Подтверждение связано с конкретным preview id, actor, суммой, сроком и версией заказа. Изменение любого из этих значений требует нового preview, а не нового "да".
Возврат разделён и защищён. Но не всякий случай агент должен решать сам - иногда правильный исход это передача оператору, и передавать нужно доказанный контекст.
Кто выполняет commit возврата денег?