Changeset 0.23.0 (#153)

This commit is contained in:
2026-03-05 22:40:26 +00:00
parent 40d9834f64
commit 2fc620e1ac
62 changed files with 6214 additions and 362 deletions

View File

@@ -20,7 +20,6 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/health"
"git.gobha.me/xcaliber/chat-switchboard/knowledge"
"git.gobha.me/xcaliber/chat-switchboard/mentions"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/routing"
@@ -201,9 +200,13 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
userID := getUserID(c)
// Verify channel ownership
if !userOwnsChannel(c, channelID, userID) {
return
// Verify participation: check channel_participants first, fall back to
// legacy channels.user_id ownership for direct channels.
isParticipant, _ := h.stores.Channels.IsParticipant(c.Request.Context(), channelID, "user", userID)
if !isParticipant {
if !userOwnsChannel(c, channelID, userID) {
return
}
}
// ── Persona unwrap: persona overrides defaults, explicit request fields win ──
@@ -332,7 +335,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
// Load conversation history
messages, err := h.loadConversation(channelID, userID, personaSystemPrompt, personaID)
messages, err := h.loadConversation(channelID, userID, personaSystemPrompt, personaID, model)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"})
return
@@ -365,7 +368,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
messages = append(messages, userMsg)
// Persist user message (text-only for storage — multimodal parts are ephemeral)
msgID, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil)
msgID, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil, "", "")
if err != nil {
log.Printf("Failed to persist user message: %v", err)
}
@@ -383,17 +386,65 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
// Resolve effective workspace: channel.workspace_id > project.workspace_id
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID)
// ── Multi-model @mention routing (v0.20.0) ──────────
// Check if the message @mentions specific channel models.
// If multiple models are targeted, fan out sequentially.
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if len(roster) > 1 {
parsed := mentions.Parse(req.Content, roster)
targets := mentions.ResolvedModels(parsed)
if len(targets) > 1 {
h.multiModelStream(c, targets, messages, channelID, userID, personaID, personaSystemPrompt, workspaceID, req)
// ── @mention routing (v0.23.0) ──────────────
// Resolve @model-id or @persona-handle directly against the enabled
// model catalog and personas table. Works in ANY chat — no roster needed.
if mentionModel, mentionConfig, mentionPersona := h.resolveMention(c.Request.Context(), userID, req.Content); mentionModel != "" {
req.Model = mentionModel
if mentionConfig != "" {
req.ProviderConfigID = mentionConfig
}
if mentionPersona != nil {
personaID = mentionPersona.ID
personaThinkingBudget = mentionPersona.ThinkingBudget
if mentionPersona.SystemPrompt != "" {
if len(messages) > 0 && messages[0].Role == "system" {
messages[0].Content = mentionPersona.SystemPrompt
} else {
messages = append([]providers.Message{{Role: "system", Content: mentionPersona.SystemPrompt}}, messages...)
}
}
// Context boundary: tell this persona to ignore other personas' styles
boundary := fmt.Sprintf(
"[You are now %s. Previous assistant messages may be from different AI personas with different styles — ignore their tone, character, and mannerisms. Respond only as yourself per your system prompt.]",
mentionPersona.Name,
)
if len(messages) >= 2 {
last := messages[len(messages)-1]
messages[len(messages)-1] = providers.Message{Role: "system", Content: boundary}
messages = append(messages, last)
}
}
// Re-resolve provider config for the @mentioned target
providerCfg, providerID, model, configID, providerScope, err = h.resolveConfig(userID, channelID, req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
caps = h.getModelCapabilities(c, model, configID)
if personaThinkingBudget != nil && *personaThinkingBudget > 0 {
if providerCfg.Settings == nil {
providerCfg.Settings = make(map[string]interface{})
}
providerCfg.Settings["extended_thinking"] = true
providerCfg.Settings["thinking_budget"] = *personaThinkingBudget
}
provider, err = providers.Get(providerID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
} else {
// No @mention: inject a boundary if conversation has persona responses
// to prevent the default model from mimicking persona styles.
if h.conversationHasPersonaMessages(c.Request.Context(), channelID) {
boundary := "[Previous assistant messages in this conversation may include responses from AI personas with specific characters and styles. You are the default assistant — respond in your own natural style, not theirs.]"
if len(messages) >= 2 {
last := messages[len(messages)-1]
messages[len(messages)-1] = providers.Message{Role: "system", Content: boundary}
messages = append(messages, last)
}
}
}
// Build provider request
@@ -540,7 +591,11 @@ func (h *CompletionHandler) multiModelStream(
// Persist assistant message with model attribution
if result.Content != "" {
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity)); err != nil {
pID := ""
if target.PersonaID != nil {
pID = *target.PersonaID
}
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity), configID, pID); err != nil {
log.Printf("Failed to persist multi-model assistant message: %v", err)
}
}
@@ -716,9 +771,20 @@ func (h *CompletionHandler) streamCompletion(
// Persist assistant response
if result.Content != "" {
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity)); err != nil {
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity), configID, personaID); err != nil {
log.Printf("Failed to persist assistant message: %v", err)
}
// AI-to-AI chaining: if the response @mentions another persona participant,
// trigger a follow-up completion asynchronously via WebSocket delivery.
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("[chain] PANIC recovered: %v", r)
}
}()
h.chainIfMentioned(channelID, userID, personaID, result.Content, 0)
}()
}
// Log usage
@@ -765,10 +831,20 @@ func (h *CompletionHandler) syncCompletion(
finalContent = resp.Content
// Persist assistant response
if _, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, totalInput, totalOutput, nil, toolActivityJSON(allToolActivity)); err != nil {
if _, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, totalInput, totalOutput, nil, toolActivityJSON(allToolActivity), configID, personaID); err != nil {
log.Printf("Failed to persist assistant message: %v", err)
}
// AI-to-AI chaining
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("[chain] PANIC recovered: %v", r)
}
}()
h.chainIfMentioned(channelID, userID, personaID, finalContent, 0)
}()
// Log usage
h.logUsage(c, channelID, userID, configID, providerScope, model,
totalInput, totalOutput, totalCacheCreation, totalCacheRead)
@@ -992,6 +1068,199 @@ func isImageContentType(ct string) bool {
return strings.HasPrefix(ct, "image/")
}
// ── @mention Resolution (v0.23.0) ───────────
// conversationHasPersonaMessages checks if any assistant message in the
// channel was generated by a persona (participant_type = 'persona').
// Used to decide whether to inject a context boundary for the default model.
func (h *CompletionHandler) conversationHasPersonaMessages(ctx context.Context, channelID string) bool {
var id string
err := database.DB.QueryRowContext(ctx, database.Q(`
SELECT id FROM messages
WHERE channel_id = $1 AND role = 'assistant' AND participant_type = 'persona'
LIMIT 1
`), channelID).Scan(&id)
return err == nil && id != ""
}
// buildParticipantHint builds a system message listing available @mentionable
// personas so the LLM knows who it can direct responses to.
// Only includes personas accessible to this user. Excludes the current persona.
func (h *CompletionHandler) buildParticipantHint(channelID, userID, currentPersonaID string) string {
ctx := context.Background()
personas, err := h.stores.Personas.ListForUser(ctx, userID)
if err != nil || len(personas) == 0 {
return ""
}
var lines []string
for _, p := range personas {
if !p.IsActive || p.Handle == "" || p.ID == currentPersonaID {
continue
}
desc := p.Description
if len(desc) > 80 {
desc = desc[:80] + "..."
}
if desc != "" {
lines = append(lines, fmt.Sprintf("- @%s (%s) — %s", p.Handle, p.Name, desc))
} else {
lines = append(lines, fmt.Sprintf("- @%s (%s)", p.Handle, p.Name))
}
}
if len(lines) == 0 {
return ""
}
hint := "Available conversation participants you can @mention to direct a response:\n"
for _, l := range lines {
hint += l + "\n"
}
hint += "To ask another participant to respond, include their @handle in your message."
return hint
}
// Resolves @tokens in message content directly against the model catalog
// and personas table. Works in any chat — no channel roster needed.
//
// Resolution order:
// 1. Persona handle (exact match, then prefix)
// 2. Model ID in enabled catalog (exact match, then prefix)
//
// Returns (modelID, providerConfigID, *Persona) — all empty if no @mention found.
func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content string) (string, string, *models.Persona) {
// Extract first @token
token := extractFirstMention(content)
if token == "" {
return "", "", nil
}
// Normalize: lowercase, hyphens
normalized := strings.ToLower(strings.TrimSpace(token))
// 1. Try persona handle (exact)
var personaID, personaHandle string
err := database.DB.QueryRowContext(ctx, database.Q(`
SELECT id, handle FROM personas
WHERE LOWER(handle) = $1 AND is_active = true
`), normalized).Scan(&personaID, &personaHandle)
if err == nil && personaID != "" {
p := ResolvePersona(h.stores, personaID, userID)
if p != nil {
cfgID := ""
if p.ProviderConfigID != nil {
cfgID = *p.ProviderConfigID
}
log.Printf("[mention] @%s → persona %s (%s)", token, p.Name, p.BaseModelID)
return p.BaseModelID, cfgID, p
}
}
// 2. Try persona handle (prefix — unambiguous only)
if personaID == "" {
var count int
database.DB.QueryRowContext(ctx, database.Q(`
SELECT COUNT(*) FROM personas WHERE LOWER(handle) LIKE $1 AND is_active = true
`), normalized+"%").Scan(&count)
if count == 1 {
database.DB.QueryRowContext(ctx, database.Q(`
SELECT id FROM personas WHERE LOWER(handle) LIKE $1 AND is_active = true
`), normalized+"%").Scan(&personaID)
if personaID != "" {
p := ResolvePersona(h.stores, personaID, userID)
if p != nil {
cfgID := ""
if p.ProviderConfigID != nil {
cfgID = *p.ProviderConfigID
}
log.Printf("[mention] @%s → persona prefix %s (%s)", token, p.Name, p.BaseModelID)
return p.BaseModelID, cfgID, p
}
}
}
}
// 3. Try model_id in enabled catalog (exact)
var modelID, provConfigID string
err = database.DB.QueryRowContext(ctx, database.Q(`
SELECT mc.model_id, mc.provider_config_id
FROM model_catalog mc
JOIN provider_configs pc ON pc.id = mc.provider_config_id
WHERE LOWER(mc.model_id) = $1
AND mc.visibility = 'enabled'
AND pc.is_active = true
ORDER BY
CASE pc.scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'personal' THEN 2 END
LIMIT 1
`), normalized).Scan(&modelID, &provConfigID)
if err == nil && modelID != "" {
log.Printf("[mention] @%s → model %s (config %s)", token, modelID, provConfigID)
return modelID, provConfigID, nil
}
// 4. Try model_id prefix (unambiguous)
var prefixCount int
database.DB.QueryRowContext(ctx, database.Q(`
SELECT COUNT(DISTINCT mc.model_id)
FROM model_catalog mc
JOIN provider_configs pc ON pc.id = mc.provider_config_id
WHERE LOWER(mc.model_id) LIKE $1
AND mc.visibility = 'enabled'
AND pc.is_active = true
`), normalized+"%").Scan(&prefixCount)
if prefixCount == 1 {
database.DB.QueryRowContext(ctx, database.Q(`
SELECT mc.model_id, mc.provider_config_id
FROM model_catalog mc
JOIN provider_configs pc ON pc.id = mc.provider_config_id
WHERE LOWER(mc.model_id) LIKE $1
AND mc.visibility = 'enabled'
AND pc.is_active = true
ORDER BY CASE pc.scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'personal' THEN 2 END
LIMIT 1
`), normalized+"%").Scan(&modelID, &provConfigID)
if modelID != "" {
log.Printf("[mention] @%s → model prefix %s (config %s)", token, modelID, provConfigID)
return modelID, provConfigID, nil
}
}
return "", "", nil
}
// extractFirstMention finds the first @token in content.
// Returns the token without the @ prefix, or empty string.
func extractFirstMention(content string) string {
for i := 0; i < len(content); i++ {
if content[i] != '@' {
continue
}
// @ must be at start or preceded by whitespace
if i > 0 && content[i-1] != ' ' && content[i-1] != '\n' && content[i-1] != '\t' {
continue
}
// Extract token
start := i + 1
end := start
for end < len(content) && content[end] != ' ' && content[end] != '\n' && content[end] != '\t' {
end++
}
if end > start {
// Strip trailing punctuation
for end > start && (content[end-1] == ',' || content[end-1] == '.' || content[end-1] == '!' || content[end-1] == '?' || content[end-1] == ':' || content[end-1] == ';') {
end--
}
if end > start {
return content[start:end]
}
}
}
return ""
}
// ── Config Resolution ───────────────────────
// Priority: request.provider_config_id → chat.provider_config_id → user's first active config
@@ -1037,6 +1306,8 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
var apiKeyEnc, keyNonce []byte
var keyScope string
var customHeadersJSON, providerSettingsJSON []byte
var proxyMode string
var proxyURL *string
// $2/userID appears twice in the query; Postgres reuses positional params,
// SQLite needs each ? bound separately.
configArgs := []interface{}{configID, userID}
@@ -1045,14 +1316,14 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
}
err := database.DB.QueryRow(database.Q(`
SELECT provider, endpoint, scope, api_key_enc, key_nonce, key_scope,
model_default, headers, settings
model_default, headers, settings, COALESCE(proxy_mode, 'system'), proxy_url
FROM provider_configs
WHERE id = $1 AND is_active = true
AND (scope = 'global'
OR (scope = 'personal' AND owner_id = $2)
OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $2)))
`), configArgs...).Scan(&providerID, &endpoint, &providerScope, &apiKeyEnc, &keyNonce, &keyScope,
&modelDefault, &customHeadersJSON, &providerSettingsJSON)
&modelDefault, &customHeadersJSON, &providerSettingsJSON, &proxyMode, &proxyURL)
if err == sql.ErrNoRows {
return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("API config not found or not accessible")
@@ -1100,11 +1371,18 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
_ = json.Unmarshal(providerSettingsJSON, &providerSettings)
}
proxyURLStr := ""
if proxyURL != nil {
proxyURLStr = *proxyURL
}
return providers.ProviderConfig{
Endpoint: endpoint,
APIKey: key,
CustomHeaders: customHeaders,
Settings: providerSettings,
ProxyMode: proxyMode,
ProxyURL: proxyURLStr,
}, providerID, model, configID, providerScope, nil
}
@@ -1116,7 +1394,7 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
//
// Summary-aware: if the path contains a summary node (metadata.type = "summary"),
// messages before it are replaced by the summary content as a system message.
func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPrompt, personaID string) ([]providers.Message, error) {
func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPrompt, personaID, currentModel string) ([]providers.Message, error) {
messages := make([]providers.Message, 0)
// ── Admin system prompt (always injected first, no opt out) ──
@@ -1183,6 +1461,16 @@ func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPro
})
}
// ── Participant roster (v0.23.0) ──
// Inject the list of available @mentionable personas so the LLM
// knows who it can direct responses to. This enables LLM→LLM chaining.
if participantHint := h.buildParticipantHint(channelID, userID, personaID); participantHint != "" {
messages = append(messages, providers.Message{
Role: "system",
Content: participantHint,
})
}
// Walk the active path (root → leaf) instead of loading all messages
path, err := getActivePath(channelID, userID)
if err != nil {
@@ -1207,6 +1495,29 @@ func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPro
startIdx = summaryIdx + 1
}
// ── Build persona name cache for history rewrite (v0.23.0) ──
// Collect unique persona IDs from the path so we can attribute
// foreign persona messages by name instead of raw UUID.
personaNameCache := make(map[string]string)
hasForeignPersona := false
{
seen := make(map[string]bool)
for _, m := range path {
if m.ParticipantType == "persona" && m.ParticipantID != "" && m.ParticipantID != personaID {
if !seen[m.ParticipantID] {
seen[m.ParticipantID] = true
}
}
}
for pid := range seen {
var name string
database.DB.QueryRow(database.Q(`SELECT name FROM personas WHERE id = $1`), pid).Scan(&name)
if name != "" {
personaNameCache[pid] = name
}
}
}
for _, m := range path[startIdx:] {
if m.Role == "system" {
continue // system prompts handled above
@@ -1215,10 +1526,59 @@ func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPro
if isSummaryMessage(&m) {
continue
}
messages = append(messages, providers.Message{
// ── Foreign speaker attribution (v0.23.0) ──
// ALL assistant messages get a Name field identifying who generated them.
// Providers that support "name" (OpenAI, OpenRouter, Venice) use it
// to distinguish speakers. Anthropic rewrites named foreign messages
// to user role with attribution.
msg := providers.Message{
Role: m.Role,
Content: m.Content,
})
}
if m.Role == "assistant" {
isForeign := false
if m.ParticipantType == "persona" && m.ParticipantID != "" {
if m.ParticipantID != personaID {
// Foreign persona — tag with name
isForeign = true
name := personaNameCache[m.ParticipantID]
if name != "" {
msg.Name = name
}
}
} else if m.Model != nil && *m.Model != "" {
// Raw model — foreign if model differs or we're a persona
if *m.Model != currentModel || personaID != "" {
isForeign = true
msg.Name = *m.Model
}
}
if isForeign {
hasForeignPersona = true
}
}
messages = append(messages, msg)
}
// If foreign persona messages exist, add a system hint explaining
// the multi-participant context (helps all providers)
if hasForeignPersona {
hint := "This is a multi-participant AI conversation. Messages from other AI participants are marked with their name. You are one of several models — respond only as yourself, ignoring other participants' styles and characters."
// Insert after the last system message, before conversation history
insertIdx := 0
for i, m := range messages {
if m.Role == "system" {
insertIdx = i + 1
} else {
break
}
}
messages = append(messages[:insertIdx+1], messages[insertIdx:]...)
messages[insertIdx] = providers.Message{Role: "system", Content: hint}
}
return messages, nil
@@ -1242,7 +1602,7 @@ func toolActivityJSON(activity []map[string]interface{}) json.RawMessage {
return b
}
func (h *CompletionHandler) persistMessage(channelID, userID, role, content, model string, inputTokens, outputTokens int, parentOverride *string, toolCallsJSON json.RawMessage) (string, error) {
func (h *CompletionHandler) persistMessage(channelID, userID, role, content, model string, inputTokens, outputTokens int, parentOverride *string, toolCallsJSON json.RawMessage, providerConfigID string, personaID string) (string, error) {
var tokensUsed *int
if inputTokens > 0 || outputTokens > 0 {
total := inputTokens + outputTokens
@@ -1260,12 +1620,23 @@ func (h *CompletionHandler) persistMessage(channelID, userID, role, content, mod
// Compute sibling_index
siblingIdx := nextSiblingIndex(channelID, parentID)
// Determine participant
// Determine participant: persona for assistant messages with personaID, otherwise user/model
participantType := "user"
participantID := userID
if role == "assistant" {
participantType = "model"
participantID = model
if personaID != "" {
participantType = "persona"
participantID = personaID
} else {
participantType = "model"
participantID = model
}
}
// provider_config_id: nil if empty
var provCfgVal interface{}
if providerConfigID != "" {
provCfgVal = providerConfigID
}
// Prepare tool_calls JSONB (nil → NULL)
@@ -1280,21 +1651,21 @@ func (h *CompletionHandler) persistMessage(channelID, userID, role, content, mod
newID = store.NewID()
_, err := database.DB.Exec(`
INSERT INTO messages (id, channel_id, role, content, model, tokens_used,
tool_calls, parent_id, participant_type, participant_id, sibling_index)
VALUES (?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?)
tool_calls, parent_id, participant_type, participant_id, provider_config_id, sibling_index)
VALUES (?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?, ?)
`, newID, channelID, role, content, model, tokensUsed,
tcVal, parentID, participantType, participantID, siblingIdx)
tcVal, parentID, participantType, participantID, provCfgVal, siblingIdx)
if err != nil {
return "", err
}
} else {
err := database.DB.QueryRow(`
INSERT INTO messages (channel_id, role, content, model, tokens_used,
tool_calls, parent_id, participant_type, participant_id, sibling_index)
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8, $9, $10)
tool_calls, parent_id, participant_type, participant_id, provider_config_id, sibling_index)
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8, $9, $10, $11)
RETURNING id
`, channelID, role, content, model, tokensUsed,
tcVal, parentID, participantType, participantID, siblingIdx,
tcVal, parentID, participantType, participantID, provCfgVal, siblingIdx,
).Scan(&newID)
if err != nil {
return "", err