Changeset 0.23.0 (#153)
This commit is contained in:
223
server/handlers/completion_chain.go
Normal file
223
server/handlers/completion_chain.go
Normal file
@@ -0,0 +1,223 @@
|
||||
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(),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user