/** * March Agent SDK - Streamer * Port of Python march_agent/streamer.py */ import type { Message } from './message.js' import type { GatewayClient } from './gateway-client.js' import type { ConversationClient } from './conversation-client.js' import type { StructuralStreamer } from './structural/base.js' import type { StreamOptions } from './types.js' /** * Handles streaming responses back to the conversation via the gateway. */ export class Streamer { private readonly agentName: string private readonly originalMessage: Message private readonly gatewayClient: GatewayClient private readonly conversationClient?: ConversationClient private readonly sendTo: string private awaiting: boolean private responseSchema?: Record private messageMetadata?: Record private streamedContent: string = '' private finished: boolean = false constructor(options: { agentName: string originalMessage: Message gatewayClient: GatewayClient conversationClient?: ConversationClient awaiting?: boolean sendTo?: string }) { this.agentName = options.agentName this.originalMessage = options.originalMessage this.gatewayClient = options.gatewayClient this.conversationClient = options.conversationClient this.awaiting = options.awaiting ?? false this.sendTo = options.sendTo ?? 'user' } /** * Set response schema for form rendering (fluent API). */ setResponseSchema(schema: Record): this { this.responseSchema = schema return this } /** * Set message metadata (fluent API). */ setMessageMetadata(metadata: Record): this { this.messageMetadata = metadata return this } /** * Bind a structural streamer to this streamer for event sending. * * Returns the structural object itself with streaming capability enabled. * * @param structural - StructuralStreamer instance (Artifact, Surface, etc.) * @returns The same structural object, now bound to this streamer * * @example * ```typescript * const artifact = new Artifact() * s.streamBy(artifact).generating("Creating...") * s.streamBy(artifact).done({ url: "...", type: "image" }) * ``` */ streamBy(structural: T): T { return structural._bindStreamer(this) } /** * Stream a content chunk. */ stream(content: string, options: StreamOptions = {}): void { const { persist = true, eventType } = options if (this.finished) { console.warn('Streamer.stream() called after finish()') return } if (persist) { this.streamedContent += content } this._send(content, false, persist, eventType) } /** * Alias for stream() - write a content chunk. */ write(content: string, persist: boolean = true): void { this.stream(content, { persist }) } /** * Finish streaming with done=true signal. */ async finish(awaitingOverride?: boolean): Promise { if (this.finished) { return } this.finished = true // Determine final awaiting value let finalAwaiting = awaitingOverride ?? this.awaiting // If response schema was set and awaiting not explicitly false, set awaiting if (this.responseSchema && awaitingOverride !== false) { finalAwaiting = true } // Send final done message this._send('', true, false) // Set pending response schema on conversation if (this.responseSchema && this.conversationClient) { await this.setPendingResponseSchema() } // Set awaiting route if (finalAwaiting && this.conversationClient) { await this.setAwaitingRoute() } } /** * Send message to router via gateway. * This method is used internally and by structural streamers. */ _send( content: string, done: boolean, persist: boolean = true, eventType?: string ): void { // Build headers (matching Python implementation) const headers: Record = { conversationId: this.originalMessage.conversationId, userId: this.originalMessage.userId, from_: this.agentName, to_: this.sendTo, nextRoute: this.sendTo, } if (eventType) { headers.eventType = eventType } // Include metadata and schema on first chunk (only once) if (this.streamedContent.length === 0 && !this.finished) { if (this.messageMetadata) { headers.messageMetadata = JSON.stringify(this.messageMetadata) } if (this.responseSchema) { headers.responseSchema = JSON.stringify(this.responseSchema) } } // Build body (matching Python implementation - includes persist) const body: Record = { content, done, persist, } if (eventType) { body.eventType = eventType } // Produce message via gateway this.gatewayClient.produce( 'router.inbox', this.originalMessage.conversationId, headers, body ) } /** * Store response schema on conversation for form validation. */ private async setPendingResponseSchema(): Promise { if (!this.conversationClient || !this.responseSchema) return try { await this.conversationClient.updateConversation( this.originalMessage.conversationId, { pendingResponseSchema: this.responseSchema } as never ) } catch (error) { console.error('Failed to set pending response schema:', error) } } /** * Set awaiting_route to this agent's name. */ private async setAwaitingRoute(): Promise { if (!this.conversationClient) return try { await this.conversationClient.updateConversation( this.originalMessage.conversationId, { awaitingRoute: this.agentName } as never ) } catch (error) { console.error('Failed to set awaiting route:', error) } } /** * Get the accumulated streamed content. */ getStreamedContent(): string { return this.streamedContent } /** * Support for async disposal (TypeScript 5.2+ "using" syntax). */ async [Symbol.asyncDispose](): Promise { if (!this.finished) { await this.finish() } } }