224 lines
6.9 KiB
Go
224 lines
6.9 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"time"
|
|
|
|
"git.gobha.me/xcaliber/chat-switchboard/events"
|
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
|
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
|
|
|
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
|
|
)
|
|
|
|
// maxChainDepth limits AI-to-AI chaining to prevent runaway loops.
|
|
const maxChainDepth = 5
|
|
|
|
// chainIfMentioned checks if an assistant response @mentions another
|
|
// persona and triggers a follow-up completion. Uses resolveMention()
|
|
// directly — no roster or participant list needed. Works in any chat.
|
|
//
|
|
// Runs asynchronously (goroutine) after the SSE stream closes.
|
|
// The follow-up response is delivered to the client via WebSocket events.
|
|
func (h *CompletionHandler) chainIfMentioned(
|
|
channelID, userID, currentPersonaID, responseContent string,
|
|
depth int,
|
|
) {
|
|
if depth >= maxChainDepth {
|
|
return
|
|
}
|
|
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("[chain] PANIC in chain (depth=%d): %v", depth, r)
|
|
}
|
|
}()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
|
defer cancel()
|
|
|
|
// Extract the @token for logging
|
|
token := extractFirstMention(responseContent)
|
|
if token == "" {
|
|
log.Printf("[chain] No @mention found in response (len=%d, persona=%s)", len(responseContent), currentPersonaID[:min(8, len(currentPersonaID))])
|
|
return
|
|
}
|
|
log.Printf("[chain] Found @%s in response from persona %s (depth=%d)", token, currentPersonaID[:min(8, len(currentPersonaID))], depth)
|
|
|
|
// Use the same resolveMention() that handles user @mentions
|
|
mentionModel, mentionConfig, mentionPersona := h.resolveMention(ctx, userID, responseContent)
|
|
if mentionModel == "" {
|
|
log.Printf("[chain] @%s did not resolve to any model", token)
|
|
return
|
|
}
|
|
if mentionPersona == nil {
|
|
log.Printf("[chain] @%s resolved to raw model %s (no chain for raw models)", token, mentionModel)
|
|
return // no persona @mention found, or it's a raw model (no chain for those)
|
|
}
|
|
|
|
// Don't chain to self
|
|
if mentionPersona.ID == currentPersonaID {
|
|
log.Printf("[chain] @%s is self-mention, skipping", token)
|
|
return
|
|
}
|
|
|
|
log.Printf("[chain] %s @mentioned %s (depth=%d)", currentPersonaID[:min(8, len(currentPersonaID))], mentionPersona.Name, depth+1)
|
|
|
|
// Emit typing indicator
|
|
if h.hub != nil {
|
|
h.emitTyping(userID, channelID, mentionPersona.ID, mentionPersona.Name, true)
|
|
defer h.emitTyping(userID, channelID, mentionPersona.ID, mentionPersona.Name, false)
|
|
}
|
|
|
|
// Brief pause for UX
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
// Resolve provider config
|
|
configID := mentionConfig
|
|
chainReq := completionRequest{
|
|
ChannelID: channelID,
|
|
Model: mentionModel,
|
|
ProviderConfigID: configID,
|
|
}
|
|
providerCfg, providerID, model, resolvedConfigID, _, err := h.resolveConfig(userID, channelID, chainReq)
|
|
if err != nil {
|
|
log.Printf("[chain] Failed to resolve config for %s: %v", mentionPersona.Name, err)
|
|
return
|
|
}
|
|
configID = resolvedConfigID
|
|
|
|
provider, err := providers.Get(providerID)
|
|
if err != nil {
|
|
log.Printf("[chain] Provider %s unavailable: %v", providerID, err)
|
|
return
|
|
}
|
|
|
|
// Load conversation with persona's system prompt
|
|
messages, err := h.loadConversation(channelID, userID, mentionPersona.SystemPrompt, mentionPersona.ID, model)
|
|
if err != nil {
|
|
log.Printf("[chain] Failed to load conversation: %v", err)
|
|
return
|
|
}
|
|
|
|
// Context boundary
|
|
boundary := fmt.Sprintf(
|
|
"[You are now %s. Previous assistant messages may be from different AI personas — ignore their style and mannerisms. Respond only as yourself per your system prompt.]",
|
|
mentionPersona.Name,
|
|
)
|
|
messages = append(messages, providers.Message{Role: "system", Content: boundary})
|
|
|
|
caps := capspkg.ResolveIntrinsic(model, nil, nil)
|
|
|
|
provReq := providers.CompletionRequest{
|
|
Model: model,
|
|
Messages: messages,
|
|
MaxTokens: capspkg.ResolveMaxOutput(model, caps),
|
|
}
|
|
if mentionPersona.Temperature != nil {
|
|
provReq.Temperature = mentionPersona.Temperature
|
|
}
|
|
if mentionPersona.ThinkingBudget != nil && *mentionPersona.ThinkingBudget > 0 {
|
|
if providerCfg.Settings == nil {
|
|
providerCfg.Settings = make(map[string]interface{})
|
|
}
|
|
providerCfg.Settings["extended_thinking"] = true
|
|
providerCfg.Settings["thinking_budget"] = *mentionPersona.ThinkingBudget
|
|
}
|
|
|
|
// Apply provider-specific hooks
|
|
if hooks := providers.GetHooks(providerID); hooks != nil {
|
|
hooks.PreRequest(providerCfg, &provReq)
|
|
}
|
|
|
|
// Synchronous completion — result delivered via WebSocket
|
|
callStart := time.Now()
|
|
resp, err := provider.ChatCompletion(ctx, providerCfg, provReq)
|
|
latencyMs := int(time.Since(callStart).Milliseconds())
|
|
if err != nil {
|
|
log.Printf("[chain] Completion failed for %s: %v", mentionPersona.Name, err)
|
|
if h.health != nil {
|
|
h.health.RecordError(configID, latencyMs, err.Error())
|
|
}
|
|
return
|
|
}
|
|
if h.health != nil {
|
|
h.health.RecordSuccess(configID, latencyMs)
|
|
}
|
|
|
|
if resp.Content == "" {
|
|
return
|
|
}
|
|
|
|
// Persist
|
|
msgID, err := h.persistMessage(channelID, userID, "assistant", resp.Content, model,
|
|
resp.InputTokens, resp.OutputTokens, nil, nil, configID, mentionPersona.ID)
|
|
if err != nil {
|
|
log.Printf("[chain] Failed to persist chained message: %v", err)
|
|
return
|
|
}
|
|
|
|
// Deliver via WebSocket
|
|
if h.hub != nil {
|
|
payload, _ := json.Marshal(map[string]interface{}{
|
|
"id": msgID,
|
|
"channel_id": channelID,
|
|
"role": "assistant",
|
|
"content": resp.Content,
|
|
"model": model,
|
|
"participant_type": "persona",
|
|
"participant_id": mentionPersona.ID,
|
|
"display_name": mentionPersona.Name,
|
|
"avatar": mentionPersona.Avatar,
|
|
"tokens_used": resp.InputTokens + resp.OutputTokens,
|
|
"chain_depth": depth + 1,
|
|
})
|
|
h.hub.SendToUser(userID, events.Event{
|
|
Label: "message.created",
|
|
Payload: payload,
|
|
Ts: time.Now().UnixMilli(),
|
|
})
|
|
}
|
|
|
|
// Log usage
|
|
if h.stores.Usage != nil {
|
|
entry := &models.UsageEntry{
|
|
ChannelID: &channelID,
|
|
UserID: userID,
|
|
ProviderConfigID: &configID,
|
|
ProviderScope: "global",
|
|
ModelID: model,
|
|
PromptTokens: resp.InputTokens,
|
|
CompletionTokens: resp.OutputTokens,
|
|
}
|
|
_ = h.stores.Usage.Log(ctx, entry)
|
|
}
|
|
|
|
log.Printf("[chain] ✅ %s responded (depth=%d, %d tokens) in channel %s",
|
|
mentionPersona.Name, depth+1, resp.InputTokens+resp.OutputTokens, channelID[:min(8, len(channelID))])
|
|
|
|
// Recursive: check if this response @mentions yet another persona
|
|
h.chainIfMentioned(channelID, userID, mentionPersona.ID, resp.Content, depth+1)
|
|
}
|
|
|
|
// emitTyping sends a typing indicator via WebSocket.
|
|
func (h *CompletionHandler) emitTyping(userID, channelID, participantID, displayName string, start bool) {
|
|
eventType := "typing.start"
|
|
if !start {
|
|
eventType = "typing.stop"
|
|
}
|
|
payload, _ := json.Marshal(map[string]string{
|
|
"channel_id": channelID,
|
|
"participant_id": participantID,
|
|
"participant_type": "persona",
|
|
"display_name": displayName,
|
|
})
|
|
h.hub.SendToUser(userID, events.Event{
|
|
Label: eventType,
|
|
Payload: payload,
|
|
Ts: time.Now().UnixMilli(),
|
|
})
|
|
}
|