8f1237d64f
- Updated the AgentEngine to emit final answer events even when the LLM stops without calling the final_answer tool, ensuring the frontend can render responses correctly. - Added fallback logic in the AgentStreamHandler to emit final answers as fallback events when no answer events are streamed, improving robustness in edge cases.
501 lines
15 KiB
Go
501 lines
15 KiB
Go
package session
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Tencent/WeKnora/internal/event"
|
|
"github.com/Tencent/WeKnora/internal/logger"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
|
)
|
|
|
|
// AgentStreamHandler handles agent events for SSE streaming
|
|
// It uses a dedicated EventBus per request to avoid SessionID filtering
|
|
// Events are appended to StreamManager without accumulation
|
|
type AgentStreamHandler struct {
|
|
ctx context.Context
|
|
sessionID string
|
|
assistantMessageID string
|
|
requestID string
|
|
assistantMessage *types.Message
|
|
streamManager interfaces.StreamManager
|
|
|
|
eventBus *event.EventBus
|
|
|
|
// State tracking
|
|
knowledgeRefs []*types.SearchResult
|
|
finalAnswer string
|
|
eventStartTimes map[string]time.Time // Track start time for duration calculation
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// NewAgentStreamHandler creates a new handler for agent SSE streaming
|
|
func NewAgentStreamHandler(
|
|
ctx context.Context,
|
|
sessionID, assistantMessageID, requestID string,
|
|
assistantMessage *types.Message,
|
|
streamManager interfaces.StreamManager,
|
|
eventBus *event.EventBus,
|
|
) *AgentStreamHandler {
|
|
return &AgentStreamHandler{
|
|
ctx: ctx,
|
|
sessionID: sessionID,
|
|
assistantMessageID: assistantMessageID,
|
|
requestID: requestID,
|
|
assistantMessage: assistantMessage,
|
|
streamManager: streamManager,
|
|
eventBus: eventBus,
|
|
knowledgeRefs: make([]*types.SearchResult, 0),
|
|
eventStartTimes: make(map[string]time.Time),
|
|
}
|
|
}
|
|
|
|
// Subscribe subscribes to all agent streaming events on the dedicated EventBus
|
|
// No SessionID filtering needed since we have a dedicated EventBus per request
|
|
func (h *AgentStreamHandler) Subscribe() {
|
|
// Subscribe to all agent streaming events on the dedicated EventBus
|
|
h.eventBus.On(event.EventAgentThought, h.handleThought)
|
|
h.eventBus.On(event.EventAgentToolCall, h.handleToolCall)
|
|
h.eventBus.On(event.EventAgentToolResult, h.handleToolResult)
|
|
h.eventBus.On(event.EventAgentReferences, h.handleReferences)
|
|
h.eventBus.On(event.EventAgentFinalAnswer, h.handleFinalAnswer)
|
|
h.eventBus.On(event.EventAgentReflection, h.handleReflection)
|
|
h.eventBus.On(event.EventError, h.handleError)
|
|
h.eventBus.On(event.EventSessionTitle, h.handleSessionTitle)
|
|
h.eventBus.On(event.EventAgentComplete, h.handleComplete)
|
|
}
|
|
|
|
// handleThought handles agent thought events
|
|
func (h *AgentStreamHandler) handleThought(ctx context.Context, evt event.Event) error {
|
|
data, ok := evt.Data.(event.AgentThoughtData)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
h.mu.Lock()
|
|
|
|
// Track start time on first chunk
|
|
if _, exists := h.eventStartTimes[evt.ID]; !exists {
|
|
h.eventStartTimes[evt.ID] = time.Now()
|
|
}
|
|
|
|
// Calculate duration if done
|
|
var metadata map[string]interface{}
|
|
if data.Done {
|
|
startTime := h.eventStartTimes[evt.ID]
|
|
duration := time.Since(startTime)
|
|
metadata = map[string]interface{}{
|
|
"event_id": evt.ID,
|
|
"duration_ms": duration.Milliseconds(),
|
|
"completed_at": time.Now().Unix(),
|
|
}
|
|
delete(h.eventStartTimes, evt.ID)
|
|
} else {
|
|
metadata = map[string]interface{}{
|
|
"event_id": evt.ID,
|
|
}
|
|
}
|
|
|
|
h.mu.Unlock()
|
|
|
|
// Append this chunk to stream (no accumulation - frontend will accumulate)
|
|
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
|
|
ID: evt.ID,
|
|
Type: types.ResponseTypeThinking,
|
|
Content: data.Content, // Just this chunk
|
|
Done: data.Done,
|
|
Timestamp: time.Now(),
|
|
Data: metadata,
|
|
}); err != nil {
|
|
logger.GetLogger(h.ctx).Error("Append thought event to stream failed", "error", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// handleToolCall handles tool call events
|
|
func (h *AgentStreamHandler) handleToolCall(ctx context.Context, evt event.Event) error {
|
|
data, ok := evt.Data.(event.AgentToolCallData)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
h.mu.Lock()
|
|
// Track start time for this tool call (use tool_call_id as key)
|
|
h.eventStartTimes[data.ToolCallID] = time.Now()
|
|
h.mu.Unlock()
|
|
|
|
metadata := map[string]interface{}{
|
|
"tool_name": data.ToolName,
|
|
"arguments": data.Arguments,
|
|
"tool_call_id": data.ToolCallID,
|
|
}
|
|
|
|
// Append event to stream
|
|
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
|
|
ID: evt.ID,
|
|
Type: types.ResponseTypeToolCall,
|
|
Content: fmt.Sprintf("Calling tool: %s", data.ToolName),
|
|
Done: false,
|
|
Timestamp: time.Now(),
|
|
Data: metadata,
|
|
}); err != nil {
|
|
logger.GetLogger(h.ctx).Error("Append tool call event to stream failed", "error", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// handleToolResult handles tool result events
|
|
func (h *AgentStreamHandler) handleToolResult(ctx context.Context, evt event.Event) error {
|
|
data, ok := evt.Data.(event.AgentToolResultData)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
h.mu.Lock()
|
|
// Calculate duration from start time if available, otherwise use provided duration
|
|
var durationMs int64
|
|
if startTime, exists := h.eventStartTimes[data.ToolCallID]; exists {
|
|
durationMs = time.Since(startTime).Milliseconds()
|
|
delete(h.eventStartTimes, data.ToolCallID)
|
|
} else if data.Duration > 0 {
|
|
// Fallback to provided duration if start time not tracked
|
|
durationMs = data.Duration
|
|
}
|
|
h.mu.Unlock()
|
|
|
|
// Send SSE response (both success and failure)
|
|
responseType := types.ResponseTypeToolResult
|
|
content := data.Output
|
|
if !data.Success {
|
|
responseType = types.ResponseTypeError
|
|
if data.Error != "" {
|
|
content = data.Error
|
|
}
|
|
}
|
|
|
|
// Build metadata including tool result data for rich frontend rendering
|
|
metadata := map[string]interface{}{
|
|
"tool_name": data.ToolName,
|
|
"success": data.Success,
|
|
"output": data.Output,
|
|
"error": data.Error,
|
|
"duration_ms": durationMs,
|
|
"tool_call_id": data.ToolCallID,
|
|
}
|
|
|
|
// Merge tool result data (contains display_type, formatted results, etc.)
|
|
if data.Data != nil {
|
|
for k, v := range data.Data {
|
|
metadata[k] = v
|
|
}
|
|
}
|
|
|
|
// Append event to stream
|
|
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
|
|
ID: evt.ID,
|
|
Type: responseType,
|
|
Content: content,
|
|
Done: false,
|
|
Timestamp: time.Now(),
|
|
Data: metadata,
|
|
}); err != nil {
|
|
logger.GetLogger(h.ctx).Error("Append tool result event to stream failed", "error", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// handleReferences handles knowledge references events
|
|
func (h *AgentStreamHandler) handleReferences(ctx context.Context, evt event.Event) error {
|
|
data, ok := evt.Data.(event.AgentReferencesData)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
|
|
// Extract knowledge references
|
|
// Try to cast directly to []*types.SearchResult first
|
|
if searchResults, ok := data.References.([]*types.SearchResult); ok {
|
|
h.knowledgeRefs = append(h.knowledgeRefs, searchResults...)
|
|
} else if refs, ok := data.References.([]interface{}); ok {
|
|
// Fallback: convert from []interface{}
|
|
for _, ref := range refs {
|
|
if sr, ok := ref.(*types.SearchResult); ok {
|
|
h.knowledgeRefs = append(h.knowledgeRefs, sr)
|
|
} else if refMap, ok := ref.(map[string]interface{}); ok {
|
|
// Parse from map if needed
|
|
searchResult := &types.SearchResult{
|
|
ID: getString(refMap, "id"),
|
|
Content: getString(refMap, "content"),
|
|
Score: getFloat64(refMap, "score"),
|
|
KnowledgeID: getString(refMap, "knowledge_id"),
|
|
KnowledgeTitle: getString(refMap, "knowledge_title"),
|
|
ChunkIndex: int(getFloat64(refMap, "chunk_index")),
|
|
KnowledgeBaseID: getString(refMap, "knowledge_base_id"),
|
|
}
|
|
|
|
if meta, ok := refMap["metadata"].(map[string]interface{}); ok {
|
|
metadata := make(map[string]string)
|
|
for k, v := range meta {
|
|
if strVal, ok := v.(string); ok {
|
|
metadata[k] = strVal
|
|
}
|
|
}
|
|
searchResult.Metadata = metadata
|
|
}
|
|
|
|
h.knowledgeRefs = append(h.knowledgeRefs, searchResult)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update assistant message references
|
|
h.assistantMessage.KnowledgeReferences = h.knowledgeRefs
|
|
|
|
// Append references event to stream
|
|
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
|
|
ID: evt.ID,
|
|
Type: types.ResponseTypeReferences,
|
|
Content: "",
|
|
Done: false,
|
|
Timestamp: time.Now(),
|
|
Data: map[string]interface{}{
|
|
"references": types.References(h.knowledgeRefs),
|
|
},
|
|
}); err != nil {
|
|
logger.GetLogger(h.ctx).Error("Append references event to stream failed", "error", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// handleFinalAnswer handles final answer events
|
|
func (h *AgentStreamHandler) handleFinalAnswer(ctx context.Context, evt event.Event) error {
|
|
data, ok := evt.Data.(event.AgentFinalAnswerData)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
h.mu.Lock()
|
|
// Track start time on first chunk
|
|
if _, exists := h.eventStartTimes[evt.ID]; !exists {
|
|
h.eventStartTimes[evt.ID] = time.Now()
|
|
}
|
|
|
|
// Accumulate final answer locally for assistant message (database)
|
|
h.finalAnswer += data.Content
|
|
if data.IsFallback {
|
|
h.assistantMessage.IsFallback = true
|
|
}
|
|
|
|
// Calculate duration if done
|
|
var metadata map[string]interface{}
|
|
if data.Done {
|
|
startTime := h.eventStartTimes[evt.ID]
|
|
duration := time.Since(startTime)
|
|
metadata = map[string]interface{}{
|
|
"event_id": evt.ID,
|
|
"duration_ms": duration.Milliseconds(),
|
|
"completed_at": time.Now().Unix(),
|
|
}
|
|
delete(h.eventStartTimes, evt.ID)
|
|
} else {
|
|
metadata = map[string]interface{}{
|
|
"event_id": evt.ID,
|
|
}
|
|
}
|
|
if data.IsFallback {
|
|
metadata["is_fallback"] = true
|
|
}
|
|
h.mu.Unlock()
|
|
|
|
// Append this chunk to stream (frontend will accumulate by event ID)
|
|
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
|
|
ID: evt.ID,
|
|
Type: types.ResponseTypeAnswer,
|
|
Content: data.Content, // Just this chunk
|
|
Done: data.Done,
|
|
Timestamp: time.Now(),
|
|
Data: metadata,
|
|
}); err != nil {
|
|
logger.GetLogger(h.ctx).Error("Append answer event to stream failed", "error", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// handleReflection handles agent reflection events
|
|
func (h *AgentStreamHandler) handleReflection(ctx context.Context, evt event.Event) error {
|
|
data, ok := evt.Data.(event.AgentReflectionData)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
// Append this chunk to stream (frontend will accumulate by event ID)
|
|
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
|
|
ID: evt.ID,
|
|
Type: types.ResponseTypeReflection,
|
|
Content: data.Content, // Just this chunk
|
|
Done: data.Done,
|
|
Timestamp: time.Now(),
|
|
}); err != nil {
|
|
logger.GetLogger(h.ctx).Error("Append reflection event to stream failed", "error", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// handleError handles error events
|
|
func (h *AgentStreamHandler) handleError(ctx context.Context, evt event.Event) error {
|
|
data, ok := evt.Data.(event.ErrorData)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
// Build error metadata
|
|
metadata := map[string]interface{}{
|
|
"stage": data.Stage,
|
|
"error": data.Error,
|
|
}
|
|
|
|
// Append error event to stream
|
|
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
|
|
ID: evt.ID,
|
|
Type: types.ResponseTypeError,
|
|
Content: data.Error,
|
|
Done: true,
|
|
Timestamp: time.Now(),
|
|
Data: metadata,
|
|
}); err != nil {
|
|
logger.GetLogger(h.ctx).Error("Append error event to stream failed", "error", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// handleSessionTitle handles session title update events
|
|
func (h *AgentStreamHandler) handleSessionTitle(ctx context.Context, evt event.Event) error {
|
|
data, ok := evt.Data.(event.SessionTitleData)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
// Use background context for title event since it may arrive after stream completion
|
|
bgCtx := context.Background()
|
|
|
|
// Append title event to stream
|
|
if err := h.streamManager.AppendEvent(bgCtx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
|
|
ID: evt.ID,
|
|
Type: types.ResponseTypeSessionTitle,
|
|
Content: data.Title,
|
|
Done: true,
|
|
Timestamp: time.Now(),
|
|
Data: map[string]interface{}{
|
|
"session_id": data.SessionID,
|
|
"title": data.Title,
|
|
},
|
|
}); err != nil {
|
|
logger.GetLogger(h.ctx).Warn("Append session title event to stream failed (stream may have ended)", "error", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// handleComplete handles agent complete events
|
|
func (h *AgentStreamHandler) handleComplete(ctx context.Context, evt event.Event) error {
|
|
data, ok := evt.Data.(event.AgentCompleteData)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
|
|
// Update assistant message with final data
|
|
if data.MessageID == h.assistantMessageID {
|
|
// h.assistantMessage.Content = data.FinalAnswer
|
|
h.assistantMessage.IsCompleted = true
|
|
h.assistantMessage.AgentDurationMs = data.TotalDurationMs
|
|
|
|
// Update knowledge references if provided
|
|
if len(data.KnowledgeRefs) > 0 {
|
|
knowledgeRefs := make([]*types.SearchResult, 0, len(data.KnowledgeRefs))
|
|
for _, ref := range data.KnowledgeRefs {
|
|
if sr, ok := ref.(*types.SearchResult); ok {
|
|
knowledgeRefs = append(knowledgeRefs, sr)
|
|
}
|
|
}
|
|
h.assistantMessage.KnowledgeReferences = knowledgeRefs
|
|
}
|
|
|
|
h.assistantMessage.Content += data.FinalAnswer
|
|
|
|
// Update agent steps if provided
|
|
if data.AgentSteps != nil {
|
|
if steps, ok := data.AgentSteps.([]types.AgentStep); ok {
|
|
h.assistantMessage.AgentSteps = steps
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: if no answer events were streamed but we have a final answer,
|
|
// emit it as answer events so the frontend can render it properly.
|
|
// This guards against edge cases where the LLM stops without calling final_answer.
|
|
if h.finalAnswer == "" && data.FinalAnswer != "" {
|
|
logger.GetLogger(h.ctx).Warnf(
|
|
"No answer events were streamed, emitting fallback answer (len=%d)", len(data.FinalAnswer),
|
|
)
|
|
fallbackID := fmt.Sprintf("answer-fallback-%d", time.Now().UnixMilli())
|
|
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
|
|
ID: fallbackID,
|
|
Type: types.ResponseTypeAnswer,
|
|
Content: data.FinalAnswer,
|
|
Done: false,
|
|
Timestamp: time.Now(),
|
|
Data: map[string]interface{}{
|
|
"event_id": fallbackID,
|
|
"is_fallback": true,
|
|
},
|
|
}); err != nil {
|
|
logger.GetLogger(h.ctx).Errorf("Append fallback answer event failed: %v", err)
|
|
}
|
|
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
|
|
ID: fallbackID,
|
|
Type: types.ResponseTypeAnswer,
|
|
Content: "",
|
|
Done: true,
|
|
Timestamp: time.Now(),
|
|
Data: map[string]interface{}{
|
|
"event_id": fallbackID,
|
|
"is_fallback": true,
|
|
},
|
|
}); err != nil {
|
|
logger.GetLogger(h.ctx).Errorf("Append fallback answer done event failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// Send completion event to stream manager so SSE can detect completion
|
|
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
|
|
ID: evt.ID,
|
|
Type: types.ResponseTypeComplete,
|
|
Content: "",
|
|
Done: true,
|
|
Timestamp: time.Now(),
|
|
Data: map[string]interface{}{
|
|
"total_steps": data.TotalSteps,
|
|
"total_duration_ms": data.TotalDurationMs,
|
|
},
|
|
}); err != nil {
|
|
logger.GetLogger(h.ctx).Errorf("Append complete event to stream failed: %v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|