Files
Enterprise-Knowledge-Base/internal/handler/session/agent_stream_handler.go
T
wizardchen a4b255a456 feat: implement final_answer tool and enhance agent duration tracking
- Introduced the final_answer tool to ensure agents submit their complete responses as the last action, improving response accuracy and consistency.
- Updated the agent engine to log detailed information about tool calls and responses, including the total duration of agent execution.
- Enhanced the chat message model to include agent_duration_ms, tracking the total execution time from query start to answer delivery.
- Implemented JSON field extraction for streaming responses, allowing for more efficient handling of tool call outputs.

These changes significantly improve the agent's response handling and ensure that final answers are consistently delivered, enhancing overall user experience.
2026-03-09 11:46:56 +08:00

465 lines
14 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
}
}
}
// 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
}