/** * Human handoff window — "when the human takes over, the agent backs off". * * In a coexistence setup (WhatsApp Business app + Cloud API on the same * number) the business owner can answer a customer straight from the phone. * Meta reports that as an `smb_message_echoes` webhook. Without a handoff * window the customer gets answered twice: once by the human, once by the * agent a few seconds later. * * This is the short-lived flag that prevents it. Echo arrives → mark the * thread human-active for N minutes → the registered guard silences the * agent for that thread until the window expires. * * const handoff = new HumanHandoffWindow({ kv: env.KV, tenantId }); * agent.guard(handoff.guard()); * * // in the webhook, via mountWebhook's onCoexistence: * onCoexistence: async ({ echoes }) => { await handoff.markFromEchoes(echoes); } * * KV is the right store here: the value is a timestamp with a TTL, reads * happen once per turn, and expiry is free. Note Cloudflare KV enforces a * 60-second minimum TTL — shorter windows are clamped up to it. * * The window is deliberately time-based rather than "until the human says * done". Nobody remembers to hand control back, so an expiring window is * the only version that stays correct without discipline. */ import type { GuardFn, GuardVerdict } from '../agent.js'; import type { MessageEcho } from '../webhook/coexistence.js'; import { normalizeBrazilianPhone } from '../util/phone_br.js'; /** Minimum `expirationTtl` Cloudflare KV accepts. */ export const KV_MIN_TTL_SECONDS = 60; /** Default handoff window: 15 minutes of silence after the human's last message. */ export const DEFAULT_HANDOFF_TTL_SECONDS = 15 * 60; /** The slice of the KV binding this store needs. */ export interface HandoffKVLike { get(key: string): Promise; put(key: string, value: string, options?: { expirationTtl?: number }): Promise; delete(key: string): Promise; } export interface HumanHandoffWindowOptions { /** KV namespace binding. */ kv: HandoffKVLike; /** Window length in seconds. Default 900 (15 min). Clamped to KV's 60s floor. */ ttlSeconds?: number; /** Key namespace. Default `human_active`. */ keyPrefix?: string; /** * Tenant this instance is bound to. Multi-tenant apps build one instance * per tenant inside `buildAgent`; single-tenant apps leave it null. */ tenantId?: string | null; /** * Phone normalizer applied to every key. Default `normalizeBrazilianPhone` * — pass `digits` or an identity fn for non-BR deployments. */ normalize?: (whatsapp: string) => string; /** Injectable clock for tests. */ now?: () => number; } /** Per-call overrides for the tenant/TTL bound at construction. */ export interface HandoffScope { tenantId?: string | null; ttlSeconds?: number; } export interface HandoffGuardOptions { /** Overrides the instance tenantId for this guard. */ tenantId?: string | null; /** * Text to send while the human is active. Default: none — the agent goes * fully silent, which is the point. Set only if your product needs an * explicit "someone will reply shortly" ack. */ reply?: string; } export class HumanHandoffWindow { readonly kv: HandoffKVLike; readonly ttlSeconds: number; readonly keyPrefix: string; readonly tenantId: string | null; private readonly normalize: (whatsapp: string) => string; private readonly now: () => number; constructor(opts: HumanHandoffWindowOptions) { if (!opts?.kv) throw new Error('HumanHandoffWindow: kv required'); this.kv = opts.kv; this.ttlSeconds = clampTtl(opts.ttlSeconds ?? DEFAULT_HANDOFF_TTL_SECONDS); this.keyPrefix = opts.keyPrefix ?? 'human_active'; this.tenantId = opts.tenantId ?? null; this.normalize = opts.normalize ?? normalizeBrazilianPhone; this.now = opts.now ?? (() => Date.now()); } /** KV key for a thread. Exposed so apps can clear keys from a dashboard. */ key(whatsapp: string, scope: HandoffScope = {}): string { const tenantId = scope.tenantId !== undefined ? scope.tenantId : this.tenantId; return `${this.keyPrefix}:${tenantId ?? 'default'}:${this.normalize(whatsapp)}`; } /** Start (or extend) the silence window for a thread. */ async markActive(whatsapp: string, scope: HandoffScope = {}): Promise { const ttl = scope.ttlSeconds === undefined ? this.ttlSeconds : clampTtl(scope.ttlSeconds); await this.kv.put(this.key(whatsapp, scope), new Date(this.now()).toISOString(), { expirationTtl: ttl, }); } /** True while the human still holds the thread. */ async isActive(whatsapp: string, scope: HandoffScope = {}): Promise { return (await this.kv.get(this.key(whatsapp, scope))) !== null; } /** When the human last replied, or null when the window is closed. */ async activeSince(whatsapp: string, scope: HandoffScope = {}): Promise { const raw = await this.kv.get(this.key(whatsapp, scope)); if (!raw) return null; const parsed = new Date(raw); return Number.isNaN(parsed.getTime()) ? null : parsed; } /** Hand control back to the agent immediately. */ async clear(whatsapp: string, scope: HandoffScope = {}): Promise { await this.kv.delete(this.key(whatsapp, scope)); } /** * Mark every thread touched by a batch of echoes. Returns how many were * marked — echoes without a `to` are skipped (nothing to silence). * * Multi-tenant apps that share one instance across tenants pass * `resolveTenantId` to map the echo's `phone_number_id` to a tenant; * echoes whose tenant can't be resolved are skipped. */ async markFromEchoes( echoes: readonly MessageEcho[], opts: HandoffScope & { resolveTenantId?: (phoneNumberId: string | null) => Promise | string | null; } = {}, ): Promise { let marked = 0; for (const echo of echoes) { if (!echo.to) continue; let tenantId = opts.tenantId !== undefined ? opts.tenantId : this.tenantId; if (opts.resolveTenantId) { tenantId = await opts.resolveTenantId(echo.phoneNumberId); if (!tenantId) continue; } const scope: HandoffScope = { tenantId }; if (opts.ttlSeconds !== undefined) scope.ttlSeconds = opts.ttlSeconds; await this.markActive(echo.to, scope); marked += 1; } return marked; } /** * Guard for `agent.guard(...)`. Silences the turn while the human is * active. Register it FIRST if you also have a crisis/safety guard that * must run regardless — guards short-circuit on the first denial, and * safety should outrank silence. */ guard(opts: HandoffGuardOptions = {}): GuardFn { return async (ctx): Promise => { const whatsapp = ctx.user?.whatsapp; if (!whatsapp) return null; const scope: HandoffScope = {}; if (opts.tenantId !== undefined) scope.tenantId = opts.tenantId; if (!(await this.isActive(whatsapp, scope))) return null; return opts.reply ? { reply: opts.reply } : { silent: true }; }; } } function clampTtl(ttlSeconds: number): number { if (!Number.isFinite(ttlSeconds)) return DEFAULT_HANDOFF_TTL_SECONDS; return Math.max(KV_MIN_TTL_SECONDS, Math.floor(ttlSeconds)); }