/** * 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 } from '../agent.js'; import type { MessageEcho } from '../webhook/coexistence.js'; /** Minimum `expirationTtl` Cloudflare KV accepts. */ export declare const KV_MIN_TTL_SECONDS = 60; /** Default handoff window: 15 minutes of silence after the human's last message. */ export declare const DEFAULT_HANDOFF_TTL_SECONDS: number; /** 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 declare class HumanHandoffWindow { readonly kv: HandoffKVLike; readonly ttlSeconds: number; readonly keyPrefix: string; readonly tenantId: string | null; private readonly normalize; private readonly now; constructor(opts: HumanHandoffWindowOptions); /** KV key for a thread. Exposed so apps can clear keys from a dashboard. */ key(whatsapp: string, scope?: HandoffScope): string; /** Start (or extend) the silence window for a thread. */ markActive(whatsapp: string, scope?: HandoffScope): Promise; /** True while the human still holds the thread. */ isActive(whatsapp: string, scope?: HandoffScope): Promise; /** When the human last replied, or null when the window is closed. */ activeSince(whatsapp: string, scope?: HandoffScope): Promise; /** Hand control back to the agent immediately. */ clear(whatsapp: string, scope?: HandoffScope): Promise; /** * 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. */ markFromEchoes(echoes: readonly MessageEcho[], opts?: HandoffScope & { resolveTenantId?: (phoneNumberId: string | null) => Promise | string | null; }): Promise; /** * 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; } //# sourceMappingURL=human_handoff.d.ts.map