fix: media tag enrichment, Gemini file polling, credential merge (#158)

1. Media tag enrichment (audio/video/document):
   - Add enrichVideoIDs() — video media_id was never injected into
     <media:video> tags, causing LLM to hallucinate UUIDs
   - Fix all enrich functions to replace the LAST bare tag instead of
     the first. When group history prepends older media tags, the first
     occurrence belongs to history — injecting the current turn's ID
     there causes the LLM to reference the wrong file

2. Gemini File API polling:
   - Upload response returns fileURI immediately but file may still be
     in PROCESSING state. Check state field; only skip polling when
     file is already ACTIVE. Fixes "not in an ACTIVE state" errors

3. Channel instance credential merge:
   - Partial credential updates (e.g. updating just token) now merge
     with existing credentials instead of wiping other fields
   - Loads, decrypts, merges, re-encrypts in a single Update() call

Co-authored-by: Luvu182 <208665161+Luvu182@users.noreply.github.com>
This commit is contained in:
Luan VuandLuvu182 authored and GitHub committed 2026-03-12 09:35:04 +07:00
1 parent e5eca5d0f0
commit b488ef44d6
4 files changed
+116 -26

No files matched your search

+2
View File
@@ -279,6 +279,8 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
}
if len(videoRefs) > 0 {
ctx = tools.WithMediaVideoRefs(ctx, videoRefs)
// Embed media IDs into <media:video> tags so LLM can reference them.
l.enrichVideoIDs(messages, mediaRefs)
}
// 2e. Cross-session recovery: notify team leads about orphaned pending tasks
+54 -14
View File
@@ -139,24 +139,25 @@ func (l *Loop) enrichDocumentPaths(messages []providers.Message, refs []provider
pathAttr := fmt.Sprintf(" path=%q", p)
old1 := "<media:document>"
new1 := "<media:document" + pathAttr + ">"
if strings.Contains(content, old1) {
content = strings.Replace(content, old1, new1, 1)
// Replace the LAST bare tag (current message, not group history).
if idx := strings.LastIndex(content, old1); idx >= 0 {
content = content[:idx] + new1 + content[idx+len(old1):]
continue
}
// For named variant, inject path attribute
if idx := strings.Index(content, "<media:document name="); idx >= 0 {
// For named variant, inject path attribute (last occurrence)
if idx := strings.LastIndex(content, "<media:document name="); idx >= 0 {
closeIdx := strings.Index(content[idx:], ">")
if closeIdx >= 0 {
tag := content[idx : idx+closeIdx]
content = strings.Replace(content, tag+">", tag+pathAttr+">", 1)
content = content[:idx] + tag + pathAttr + ">" + content[idx+closeIdx+1:]
}
}
// For Slack variant with file= attribute
if idx := strings.Index(content, "<media:document file="); idx >= 0 {
// For Slack variant with file= attribute (last occurrence)
if idx := strings.LastIndex(content, "<media:document file="); idx >= 0 {
closeIdx := strings.Index(content[idx:], ">")
if closeIdx >= 0 {
tag := content[idx : idx+closeIdx]
content = strings.Replace(content, tag+">", tag+pathAttr+">", 1)
content = content[:idx] + tag + pathAttr + ">" + content[idx+closeIdx+1:]
}
}
}
@@ -166,6 +167,8 @@ func (l *Loop) enrichDocumentPaths(messages []providers.Message, refs []provider
// enrichAudioIDs updates the last user message to embed persisted media IDs
// in <media:audio> and <media:voice> tags so the LLM can reference them.
// Without this, the LLM sees plain <media:audio> and cannot pass a valid media_id.
// Replaces the LAST bare tag (current message) rather than the first (which may be
// in group history context), so the current turn's media gets the correct ID.
func (l *Loop) enrichAudioIDs(messages []providers.Message, refs []providers.MediaRef) {
if len(messages) == 0 {
return
@@ -188,16 +191,53 @@ func (l *Loop) enrichAudioIDs(messages []providers.Message, refs []providers.Med
}
idAttr := fmt.Sprintf(" id=%q", ref.ID)
// Replace bare <media:audio> with <media:audio id="uuid">
// Replace the LAST bare <media:audio> with <media:audio id="uuid">
bare := "<media:audio>"
if strings.Contains(content, bare) {
content = strings.Replace(content, bare, "<media:audio"+idAttr+">", 1)
if idx := strings.LastIndex(content, bare); idx >= 0 {
content = content[:idx] + "<media:audio" + idAttr + ">" + content[idx+len(bare):]
continue
}
// Replace bare <media:voice> with <media:voice id="uuid">
// Replace the LAST bare <media:voice> with <media:voice id="uuid">
bareVoice := "<media:voice>"
if strings.Contains(content, bareVoice) {
content = strings.Replace(content, bareVoice, "<media:voice"+idAttr+">", 1)
if idx := strings.LastIndex(content, bareVoice); idx >= 0 {
content = content[:idx] + "<media:voice" + idAttr + ">" + content[idx+len(bareVoice):]
continue
}
}
messages[lastIdx].Content = content
}
// enrichVideoIDs updates the last user message to embed persisted media IDs
// in <media:video> tags so the LLM can reference them via read_video tool.
// Without this, the LLM sees plain <media:video> and hallucinates a media_id.
// Replaces the LAST bare tag (current message) rather than the first (which may be
// in group history context), so the current turn's media gets the correct ID.
func (l *Loop) enrichVideoIDs(messages []providers.Message, refs []providers.MediaRef) {
if len(messages) == 0 {
return
}
lastIdx := -1
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role == "user" {
lastIdx = i
break
}
}
if lastIdx < 0 {
return
}
content := messages[lastIdx].Content
for _, ref := range refs {
if ref.Kind != "video" {
continue
}
idAttr := fmt.Sprintf(" id=%q", ref.ID)
// Replace the LAST bare <media:video> with <media:video id="uuid">
bare := "<media:video>"
if idx := strings.LastIndex(content, bare); idx >= 0 {
content = content[:idx] + "<media:video" + idAttr + ">" + content[idx+len(bare):]
continue
}
}
+52 -9
View File
@@ -145,19 +145,43 @@ func (s *PGChannelInstanceStore) scanInstances(rows *sql.Rows) ([]store.ChannelI
}
func (s *PGChannelInstanceStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error {
// Encrypt credentials if present
// Merge and encrypt credentials if present
if credsVal, ok := updates["credentials"]; ok && credsVal != nil {
var credsBytes []byte
var newCreds map[string]any
switch v := credsVal.(type) {
case []byte:
credsBytes = v
case string:
credsBytes = []byte(v)
case map[string]any:
newCreds = v
default:
// Object/map from JSON — marshal to []byte
if b, err := json.Marshal(v); err == nil {
credsBytes = b
var raw []byte
switch vv := v.(type) {
case []byte:
raw = vv
case string:
raw = []byte(vv)
default:
if b, err := json.Marshal(v); err == nil {
raw = b
}
}
if len(raw) > 0 {
if err := json.Unmarshal(raw, &newCreds); err != nil {
newCreds = nil
}
}
}
// Merge with existing credentials so partial updates don't wipe other fields
if len(newCreds) > 0 {
existing, _ := s.loadExistingCreds(ctx, id)
for k, v := range newCreds {
existing[k] = v
}
newCreds = existing
}
var credsBytes []byte
if len(newCreds) > 0 {
credsBytes, _ = json.Marshal(newCreds)
}
if len(credsBytes) > 0 && s.encKey != "" {
encrypted, err := crypto.Encrypt(string(credsBytes), s.encKey)
@@ -172,6 +196,25 @@ func (s *PGChannelInstanceStore) Update(ctx context.Context, id uuid.UUID, updat
return execMapUpdate(ctx, s.db, "channel_instances", id, updates)
}
// loadExistingCreds reads and decrypts the current credentials for merging.
func (s *PGChannelInstanceStore) loadExistingCreds(ctx context.Context, id uuid.UUID) (map[string]any, error) {
var raw []byte
err := s.db.QueryRowContext(ctx, "SELECT credentials FROM channel_instances WHERE id = $1", id).Scan(&raw)
if err != nil || len(raw) == 0 {
return make(map[string]any), err
}
if s.encKey != "" {
if dec, err := crypto.Decrypt(string(raw), s.encKey); err == nil {
raw = []byte(dec)
}
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return make(map[string]any), nil
}
return m, nil
}
func (s *PGChannelInstanceStore) Delete(ctx context.Context, id uuid.UUID) error {
_, err := s.db.ExecContext(ctx, "DELETE FROM channel_instances WHERE id = $1", id)
return err
+8 -3
View File
@@ -81,15 +81,20 @@ func geminiFileUpload(ctx context.Context, apiKey, displayName string, data []by
var uploadResult struct {
File struct {
Name string `json:"name"`
URI string `json:"uri"`
Name string `json:"name"`
URI string `json:"uri"`
State string `json:"state"`
} `json:"file"`
}
if err := json.Unmarshal(respBody, &uploadResult); err != nil {
return "", "", fmt.Errorf("parse upload response: %w", err)
}
return uploadResult.File.Name, uploadResult.File.URI, nil
// Only return URI if file is already ACTIVE; otherwise caller must poll.
if uploadResult.File.State == "ACTIVE" {
return uploadResult.File.Name, uploadResult.File.URI, nil
}
return uploadResult.File.Name, "", nil
}
// geminiFilePoll polls the Gemini File API until the file reaches ACTIVE state.