import { AIInterface } from '@happyvertical/ai'; import { PrincipalAuditSink, PrincipalTool } from '@happyvertical/smrt-agents'; import { SmrtClassOptions } from '@happyvertical/smrt-core'; import { AgentSession } from './models/AgentSession.js'; import { ConversationPersona, PersonaRecallOptions } from './persona-conversation.js'; import { ChatService } from './services/index.js'; import { VoiceGatewayTurnMetadata } from './voice.js'; /** Max conversation messages accepted on one streaming request. */ export declare const MAX_CHAT_STREAM_MESSAGES = 50; /** Max characters per message (matches the voice gateway text cap). */ export declare const MAX_CHAT_STREAM_CONTENT_LENGTH = 12000; /** * Default SSE keep-alive interval (ms). A persona turn can go quiet for tens of * seconds during a silent tool-calling round (an LLM round-trip + an in-process * tool op emit no tokens), and idle intermediaries (nginx / ALB / Cloudflare — * the expected home for an embedded widget backend) drop a connection with no * traffic. A periodic SSE comment line keeps it warm, mirroring the core * `_events` route's `DEFAULT_EVENTS_HEARTBEAT_MS`. */ export declare const DEFAULT_CHAT_STREAM_HEARTBEAT_MS = 15000; /** Roles carried on the wire (a subset of the internal `ChatMessageRole`). */ export type ChatStreamRole = 'user' | 'assistant' | 'system'; /** * A conversation message on the wire — the shape the client sends in `messages` * and the shape the final `done` frame carries back. Kept self-contained (not * the internal `ChatMessage` model) so the contract is stable and JSON-only. */ export interface ChatStreamMessage { id?: string; role: ChatStreamRole; content: string; /** ISO-8601 timestamp. */ createdAt?: string; } /** * Session metadata the client attaches to a turn — `VoiceGatewayTurnMetadata`- * shaped so voice and chat share one binding vocabulary. It is UNTRUSTED input: * the handler's `authorize` callback is responsible for validating any of these * ids against the authenticated principal before they reach a chat write or the * tool loop. */ export type ChatStreamSession = VoiceGatewayTurnMetadata; /** * A host-page control command (#1921, smrt-ui `control-interaction.ts`) carried * on the optional `control` lane. Kept structural here so the streaming * contract does not hard-couple to `@happyvertical/smrt-ui/forms`' exact union: * the client adapter (the Happy widget) executes it against its own control * registry, where sensitivity gating and the stage→apply consent split stay * enforced registry-side. */ export interface ChatStreamControlCommand { action: string; [key: string]: unknown; } /** * One frame of the stream. `token`/`done`/`error` are emitted today; `emotion` * and `control` are part of the wire contract (so clients can rely on the union * and a future server hook can emit them without a breaking change) but are not * produced by the v1 engine. */ export type ChatStreamEvent = { type: 'token'; text: string; } | { type: 'emotion'; name: string; } | { type: 'control'; command: ChatStreamControlCommand; } | { type: 'done'; message: ChatStreamMessage; } | { type: 'error'; error: string; }; /** The JSON body of a streaming request. */ export interface ChatStreamRequestBody { messages?: unknown; session?: ChatStreamSession; } /** The minimal `AgentSession` surface the persona turn needs. */ type StreamSessionLike = Pick; /** * A resolved, ALREADY-AUTHORIZED persona binding. The `authorize` callback * produces this after validating the request against the authenticated * principal; nothing here is taken from untrusted request metadata. */ export interface ChatStreamPersonaBinding { chatService: ChatService; /** Database handle the persona turn's side-door operations run against. */ db: SmrtClassOptions['db']; persona: ConversationPersona; session: StreamSessionLike; tenantId: string; /** Thread within the bound session room to author into. */ threadId?: string | null; /** Originating user the turn runs on behalf of (audited). */ onBehalfOfUserId?: string | null; /** Recall configuration, or `false` to skip memory recall. */ recall?: PersonaRecallOptions | false; /** * Non-manifest custom tools to offer this streamed turn — e.g. the persona * messaging tool (`messages.send`) or an assistance-request/lead-ticket tool * backed by a `@smrt({ api:false, mcp:false })` service, which can only reach * the loop as `extraTools` (never as a generated manifest tool). Forwarded to * {@link runPersonaConversationTurn} exactly like the non-streaming persona * path, so a streamed persona chat can *act*, not just answer. * * This is resolved by the app's server-side `authorize` callback (trusted), * never taken from untrusted request input. Offering a tool is NOT authorizing * it: each entry is still filtered by the persona's `allowedTools` (the offer * gate) and its `execute` re-asserts the bound principal's RBAC + the * fail-closed `assertToolAllowed` (the execution gate), unchanged. */ extraTools?: PrincipalTool[]; /** Audit sink forwarded to the principal execution. */ audit?: PrincipalAuditSink; /** Opt into Postgres RLS transaction wrapping. */ postgresRls?: boolean; } /** * The trusted context a turn runs in. `binding` present ⇒ persona-bound; absent * ⇒ plain `ai.stream()`. Generation caps live here (server-resolved), never on * the request, so a caller cannot widen `maxTokens`/`maxSteps`. */ export interface ChatStreamContext { ai: AIInterface; binding?: ChatStreamPersonaBinding; /** System prompt for the PLAIN path. Ignored when `binding` is set. */ systemPrompt?: string; model?: string; temperature?: number; maxTokens?: number; maxSteps?: number; } /** Options for {@link runChatConversationStream}. */ export interface RunChatConversationStreamOptions { context: ChatStreamContext; /** The conversation so far; the last user message is this turn's prompt. */ messages: ChatStreamMessage[]; } /** Base error carrying an HTTP status + code for the handler to render. */ export declare class ChatStreamError extends Error { readonly status: number; readonly code: string; constructor(message: string, status: number, code: string); } /** 400 — malformed request body. */ export declare class ChatStreamBadRequestError extends ChatStreamError { constructor(message?: string); } /** 401 — the request could not be authorized. */ export declare class ChatStreamUnauthorizedError extends ChatStreamError { constructor(message?: string); } /** * Run one streaming conversation turn, yielding SSE events. Dispatches on * `context.binding`: persona-bound turns run the full harness; unbound turns * stream `ai.stream()`. Errors surface as an in-band `error` event (the HTTP * response has already committed to 200 once streaming starts), never a throw. */ export declare function runChatConversationStream(options: RunChatConversationStreamOptions): AsyncGenerator; /** * Options for {@link createChatStreamHandler}. */ export interface ChatStreamHandlerOptions { /** * Resolve an ALREADY-AUTHORIZED context from the request. This is the sole * trust boundary: validate the caller (bearer session id / cookie / * same-origin) and the claimed `body.session` ids against the authenticated * principal here, and return the context the turn runs in. Throw a * {@link ChatStreamError} (or any error ⇒ 500) to reject before any byte is * streamed. */ authorize: (request: Request, body: ChatStreamRequestBody) => ChatStreamContext | Promise; /** * Cross-origin allowlist for the embedded widget (#1861 posture): the request * `Origin` is echoed only when a member (never `*`). Empty/omitted ⇒ * same-origin only. */ allowedOrigins?: string[]; /** Emit `Access-Control-Allow-Credentials: true` for an allow-listed origin. */ allowCredentials?: boolean; /** * SSE keep-alive interval (ms). Defaults to * {@link DEFAULT_CHAT_STREAM_HEARTBEAT_MS}. `0` disables the heartbeat. */ heartbeatMs?: number; } /** * Build a Fetch-compatible SSE handler for the streaming contract (mirrors * `createVoiceGatewayTurnHandler`). Returns `text/event-stream`; wire it into a * SvelteKit `+server.ts`, a Bun/Node server, or any Fetch host. */ export declare function createChatStreamHandler(options: ChatStreamHandlerOptions): (request: Request) => Promise; /** SSE serialization of one event: a single `data:` frame. */ export declare function encodeChatStreamEvent(event: ChatStreamEvent): string; export {}; //# sourceMappingURL=chat-stream.d.ts.map