package context import ( "time" "github.com/yaoapp/gou/llm" "github.com/yaoapp/yao/agent/output" "github.com/yaoapp/yao/agent/output/message" ) // Send sends a message via the output module // Automatically manages BlockID, ThreadID, lifecycle events, and metadata for delta operations // - For delta operations: inherits BlockID and ThreadID from original message, increments chunk count // - For new messages: auto-sets ThreadID from Stack, sends message_start event // - Sends block_start event when a new BlockID is first encountered // - Records metadata for all sent messages to enable delta inheritance func (ctx *Context) Send(msg *message.Message) error { // Call OnMessage callback if provided (for ctx.agent.Call with onChunk) if ctx.Stack != nil && ctx.Stack.Options != nil && ctx.Stack.Options.OnMessage != nil { if ret := ctx.Stack.Options.OnMessage(msg); ret != 0 { return nil // Callback requested stop } } out, err := ctx.getOutput() if err != nil { return err } // Skip lifecycle events for event-type messages (prevent recursion) isEventMessage := msg.Type == message.TypeEvent // === Handle message_start event: record metadata for future delta chunks === if isEventMessage && msg.Props != nil { if event, ok := msg.Props["event"].(string); ok && event == message.EventMessageStart { if data, ok := msg.Props["data"].(message.EventMessageStartData); ok { // Record metadata from message_start event if data.MessageID != "" && ctx.messageMetadata != nil { ctx.messageMetadata.setMessage(data.MessageID, &MessageMetadata{ MessageID: data.MessageID, ThreadID: data.ThreadID, Type: data.Type, StartTime: time.Now(), ChunkCount: 0, // Will be incremented by delta chunks }) } } } } // === Delta operations: Auto-inherit and update metadata === if msg.Delta && msg.MessageID != "" && ctx.messageMetadata != nil { if metadata := ctx.getMessageMetadata(msg.MessageID); metadata != nil { // Inherit BlockID if not specified if msg.BlockID == "" { msg.BlockID = metadata.BlockID } // Inherit ThreadID if not specified if msg.ThreadID == "" { msg.ThreadID = metadata.ThreadID } // Increment chunk count for this message metadata.ChunkCount++ // Update Buffer content for streaming messages (for storage) if ctx.Buffer != nil && msg.Props != nil { if content, ok := msg.Props["content"].(string); ok { ctx.Buffer.AppendMessageContent(msg.MessageID, content) } } } } // === Auto-generate ChunkID (always) === if msg.ChunkID == "" && !isEventMessage { if ctx.IDGenerator != nil { msg.ChunkID = ctx.IDGenerator.GenerateChunkID() } else { msg.ChunkID = message.GenerateNanoID() } } // === Non-delta operations: New message logic === if !msg.Delta && !isEventMessage { // Auto-set ThreadID for non-root Stack (nested agent calls) if msg.ThreadID == "" && ctx.Stack != nil && !ctx.Stack.IsRoot() { msg.ThreadID = ctx.Stack.ID } // BlockID is NOT auto-generated by default (only manually specified in special cases) // Example: Send a web card after LLM output, group them in the same Block // Developers can specify via ctx.Send(message, blockId) or message.block_id // === Send block_start event if this is a new block === if msg.BlockID != "" && ctx.messageMetadata != nil { if ctx.messageMetadata.getBlock(msg.BlockID) == nil { // New block, send block_start event blockStartData := message.EventBlockStartData{ BlockID: msg.BlockID, Type: "mixed", // Default type, can be enhanced later Timestamp: time.Now().UnixMilli(), } blockStartEvent := output.NewEventMessage(message.EventBlockStart, "Block started", blockStartData) if err := ctx.sendRaw(blockStartEvent); err != nil { return err } // Record block metadata ctx.messageMetadata.setBlock(msg.BlockID, &BlockMetadata{ BlockID: msg.BlockID, Type: "mixed", StartTime: time.Now(), MessageCount: 0, }) } // Increment message count for this block ctx.messageMetadata.updateBlock(msg.BlockID, func(block *BlockMetadata) { block.MessageCount++ }) } // === Generate MessageID if not provided === if msg.MessageID == "" { if ctx.IDGenerator != nil { msg.MessageID = ctx.IDGenerator.GenerateMessageID() } else { msg.MessageID = message.GenerateNanoID() // Use NanoID generator } } // === Send message_start event === messageStartData := message.EventMessageStartData{ MessageID: msg.MessageID, Type: msg.Type, Timestamp: time.Now().UnixMilli(), ThreadID: msg.ThreadID, // Include ThreadID for concurrent stream identification } messageStartEvent := output.NewEventMessage(message.EventMessageStart, "Message started", messageStartData) if err := ctx.sendRaw(messageStartEvent); err != nil { return err } // === Record message metadata with start time === if ctx.messageMetadata != nil { ctx.messageMetadata.setMessage(msg.MessageID, &MessageMetadata{ MessageID: msg.MessageID, BlockID: msg.BlockID, ThreadID: msg.ThreadID, Type: msg.Type, StartTime: time.Now(), ChunkCount: 1, // Initial chunk }) } } // === Actually send the message === if err := out.Send(msg); err != nil { return err } // === Buffer message for batch saving (non-delta, non-event messages only) === // Delta messages are streaming chunks; only final content should be saved // Event messages are transient lifecycle signals, not stored // Skip if History is disabled in options if !msg.Delta && !isEventMessage && ctx.Buffer != nil && !ctx.shouldSkipHistory() { assistantID := "" if ctx.Stack != nil { assistantID = ctx.Stack.AssistantID } ctx.Buffer.AddAssistantMessage( msg.MessageID, // Use the same MessageID as sent to client msg.Type, msg.Props, msg.BlockID, msg.ThreadID, assistantID, nil, // metadata can be added if needed ) } // === Auto-send message_end for non-delta messages (complete messages) === if !msg.Delta && !isEventMessage && msg.MessageID != "" && ctx.messageMetadata != nil { metadata := ctx.messageMetadata.getMessage(msg.MessageID) if metadata != nil { // Calculate duration durationMs := time.Since(metadata.StartTime).Milliseconds() // Extract content for the extra field var content interface{} if msg.Props != nil { if c, ok := msg.Props["content"]; ok { content = c } } // Build message_end event data endData := message.EventMessageEndData{ MessageID: msg.MessageID, Type: msg.Type, Timestamp: time.Now().UnixMilli(), ThreadID: metadata.ThreadID, // Include ThreadID for concurrent stream identification DurationMs: durationMs, ChunkCount: metadata.ChunkCount, Status: "completed", } // Add content to extra if available if content != nil { endData.Extra = map[string]interface{}{ "content": content, } } // Send message_end event messageEndEvent := output.NewEventMessage(message.EventMessageEnd, "Message completed", endData) ctx.sendRaw(messageEndEvent) } } return nil } // SendStream sends a streaming message that can be appended to later // Unlike Send(), this does NOT automatically send message_end event // Use ctx.Append() to add content, then ctx.End() to finalize // Returns the message ID for use with Append/End func (ctx *Context) SendStream(msg *message.Message) (string, error) { out, err := ctx.getOutput() if err != nil { return "", err } // Skip lifecycle events for event-type messages isEventMessage := msg.Type == message.TypeEvent if isEventMessage { // Event messages should use Send(), not SendStream() return "", ctx.Send(msg) } // === Auto-generate ChunkID === if msg.ChunkID == "" { if ctx.IDGenerator != nil { msg.ChunkID = ctx.IDGenerator.GenerateChunkID() } else { msg.ChunkID = message.GenerateNanoID() } } // === Auto-set ThreadID for non-root Stack === if msg.ThreadID == "" && ctx.Stack != nil && !ctx.Stack.IsRoot() { msg.ThreadID = ctx.Stack.ID } // === Handle BlockID and block_start event === if msg.BlockID != "" && ctx.messageMetadata != nil { if ctx.messageMetadata.getBlock(msg.BlockID) == nil { blockStartData := message.EventBlockStartData{ BlockID: msg.BlockID, Type: "mixed", Timestamp: time.Now().UnixMilli(), } blockStartEvent := output.NewEventMessage(message.EventBlockStart, "Block started", blockStartData) if err := ctx.sendRaw(blockStartEvent); err != nil { return "", err } ctx.messageMetadata.setBlock(msg.BlockID, &BlockMetadata{ BlockID: msg.BlockID, Type: "mixed", StartTime: time.Now(), MessageCount: 0, }) } ctx.messageMetadata.updateBlock(msg.BlockID, func(block *BlockMetadata) { block.MessageCount++ }) } // === Generate MessageID if not provided === if msg.MessageID == "" { if ctx.IDGenerator != nil { msg.MessageID = ctx.IDGenerator.GenerateMessageID() } else { msg.MessageID = message.GenerateNanoID() } } // === Send message_start event === messageStartData := message.EventMessageStartData{ MessageID: msg.MessageID, Type: msg.Type, Timestamp: time.Now().UnixMilli(), ThreadID: msg.ThreadID, } messageStartEvent := output.NewEventMessage(message.EventMessageStart, "Message started", messageStartData) if err := ctx.sendRaw(messageStartEvent); err != nil { return "", err } // === Record message metadata === if ctx.messageMetadata != nil { ctx.messageMetadata.setMessage(msg.MessageID, &MessageMetadata{ MessageID: msg.MessageID, BlockID: msg.BlockID, ThreadID: msg.ThreadID, Type: msg.Type, StartTime: time.Now(), ChunkCount: 1, }) } // === Actually send the message === if err := out.Send(msg); err != nil { return "", err } // === Buffer streaming message (will be completed by End()) === if ctx.Buffer != nil && !ctx.shouldSkipHistory() { assistantID := "" if ctx.Stack != nil { assistantID = ctx.Stack.AssistantID } ctx.Buffer.AddStreamingMessage( msg.MessageID, msg.Type, msg.Props, msg.BlockID, msg.ThreadID, assistantID, nil, ) } // NOTE: No message_end event here - will be sent by End() return msg.MessageID, nil } // End finalizes a streaming message started with SendStream // Optionally appends final content before sending message_end event // This also saves the complete message to the buffer for storage func (ctx *Context) End(messageID string, finalContent ...string) error { if messageID == "" { return nil } // Append final content if provided if len(finalContent) > 0 && finalContent[0] != "" { // Create a delta message for the final content deltaMsg := &message.Message{ MessageID: messageID, Type: message.TypeText, Delta: true, DeltaAction: message.DeltaAppend, Props: map[string]interface{}{ "content": finalContent[0], }, } if err := ctx.Send(deltaMsg); err != nil { return err } } // Get complete content from buffer var completeContent string if ctx.Buffer != nil { completeContent, _ = ctx.Buffer.CompleteStreamingMessage(messageID) } // Get metadata for duration calculation var durationMs int64 var threadID string var chunkCount int var msgType string = message.TypeText if ctx.messageMetadata != nil { if metadata := ctx.messageMetadata.getMessage(messageID); metadata != nil { durationMs = time.Since(metadata.StartTime).Milliseconds() threadID = metadata.ThreadID chunkCount = metadata.ChunkCount msgType = metadata.Type } } // Build message_end event data endData := message.EventMessageEndData{ MessageID: messageID, Type: msgType, Timestamp: time.Now().UnixMilli(), ThreadID: threadID, DurationMs: durationMs, ChunkCount: chunkCount, Status: "completed", } // Add complete content to extra if completeContent != "" { endData.Extra = map[string]interface{}{ "content": completeContent, } } // Send message_end event messageEndEvent := output.NewEventMessage(message.EventMessageEnd, "Message completed", endData) return ctx.sendRaw(messageEndEvent) } // EndMessage sends a message_end event for a completed message // Note: For non-delta messages, message_end is automatically sent by Send() // This method is primarily for delta streaming scenarios: // - After all delta chunks are sent for a message, call EndMessage() to finalize it // - For LLM streaming, this is typically called after receiving ChunkMessageEnd func (ctx *Context) EndMessage(messageID string, content interface{}) error { if messageID == "" || ctx.messageMetadata == nil { return nil } metadata := ctx.messageMetadata.getMessage(messageID) if metadata == nil { return nil // Message not found, skip } // Calculate duration durationMs := time.Since(metadata.StartTime).Milliseconds() // Build message_end event data endData := message.EventMessageEndData{ MessageID: messageID, Type: metadata.Type, Timestamp: time.Now().UnixMilli(), ThreadID: metadata.ThreadID, // Include ThreadID for concurrent stream identification DurationMs: durationMs, ChunkCount: metadata.ChunkCount, Status: "completed", } // Add content to extra if provided if content != nil { endData.Extra = map[string]interface{}{ "content": content, } } // Send message_end event messageEndEvent := output.NewEventMessage(message.EventMessageEnd, "Message completed", endData) return ctx.sendRaw(messageEndEvent) } // EndBlock sends a block_end event for a completed block // This should be called explicitly when all messages in a block are complete func (ctx *Context) EndBlock(blockID string) error { if blockID == "" || ctx.messageMetadata == nil { return nil } blockMetadata := ctx.messageMetadata.getBlock(blockID) if blockMetadata == nil { return nil // Block not found, skip } // Calculate duration durationMs := time.Since(blockMetadata.StartTime).Milliseconds() // Build block_end event data endData := message.EventBlockEndData{ BlockID: blockID, Type: blockMetadata.Type, Timestamp: time.Now().UnixMilli(), DurationMs: durationMs, MessageCount: blockMetadata.MessageCount, Status: "completed", } // Send block_end event blockEndEvent := output.NewEventMessage(message.EventBlockEnd, "Block completed", endData) return ctx.sendRaw(blockEndEvent) } // SendGroup sends a group of messages via the output module // Deprecated: This method is deprecated and will be removed in future versions func (ctx *Context) SendGroup(group *message.Group) error { output, err := ctx.getOutput() if err != nil { return err } return output.SendGroup(group) } // Flush flushes the output writer func (ctx *Context) Flush() error { output, err := ctx.getOutput() if err != nil { return err } return output.Flush() } // CloseOutput closes the output writer func (ctx *Context) CloseOutput() error { output, err := ctx.getOutput() if err != nil { return err } return output.Close() } // sendRaw sends a message directly without triggering lifecycle events // Used internally to send event messages without recursion func (ctx *Context) sendRaw(msg *message.Message) error { out, err := ctx.getOutput() if err != nil { return err } return out.Send(msg) } // getWriter gets the effective Writer for the current context // Priority: Skip.Output > Stack.Options.Writer > ctx.Writer // Note: The Writer returned is always a SafeWriter (wrapped at context creation) // to ensure thread-safe concurrent writes for SSE streaming. func (ctx *Context) getWriter() Writer { // Check if output is explicitly skipped (for internal A2A calls) if ctx.Stack != nil && ctx.Stack.Options != nil && ctx.Stack.Options.Skip != nil && ctx.Stack.Options.Skip.Output { return nil // Explicitly disable output } // Check if current Stack has a Writer override if ctx.Stack != nil && ctx.Stack.Options != nil && ctx.Stack.Options.Writer != nil { return ctx.Stack.Options.Writer } return ctx.Writer } // getOutput gets the output writer for the context func (ctx *Context) getOutput() (*output.Output, error) { // Check if current Stack has cached output if ctx.Stack != nil && ctx.Stack.output != nil { return ctx.Stack.output, nil } // Ensure Writer is wrapped in SafeWriter for concurrent-safe SSE writes // This is essential for ctx.agent.All where multiple sub-agents // write to the same SSE stream concurrently. // We wrap once at the context level so all forked contexts share the same SafeWriter. writer := ctx.getWriter() if writer != nil { // Check if it's already a SafeWriter if _, ok := writer.(*output.SafeWriter); !ok { // Wrap in SafeWriter with context for automatic cleanup on client disconnect // This prevents goroutine leaks in enterprise applications var safeWriter *output.SafeWriter if ctx.Context != nil { // Use request context to detect client disconnection safeWriter = output.NewSafeWriterWithContext(ctx.Context, writer) } else { // Fallback to basic SafeWriter if no context available safeWriter = output.NewSafeWriter(writer) } ctx.Writer = safeWriter writer = safeWriter } } trace, _ := ctx.Trace() var options message.Options = message.Options{ BaseURL: "/", Writer: writer, Trace: trace, Locale: ctx.Locale, Accept: string(ctx.Accept), } if ctx.Capabilities != nil { caps := llm.Capabilities(*ctx.Capabilities) options.Capabilities = &caps } out, err := output.NewOutput(options) if err != nil { return nil, err } // Cache to current Stack (each Stack has its own output with its own Writer) if ctx.Stack != nil { ctx.Stack.output = out } return out, nil } // CloseSafeWriter closes the SafeWriter if one was created // This should be called at the end of the root request to flush any pending writes // and stop the background goroutine. func (ctx *Context) CloseSafeWriter() { if ctx.Writer == nil { return } if sw, ok := ctx.Writer.(*output.SafeWriter); ok { sw.Close() } }