diff --git a/frontend/apps/main/src/views/admin/ai/CreateOrEditAssistant.vue b/frontend/apps/main/src/views/admin/ai/CreateOrEditAssistant.vue index fd82991d..99098888 100644 --- a/frontend/apps/main/src/views/admin/ai/CreateOrEditAssistant.vue +++ b/frontend/apps/main/src/views/admin/ai/CreateOrEditAssistant.vue @@ -141,7 +141,7 @@ diff --git a/internal/ai/agent.go b/internal/ai/agent.go index 3e789a22..6e346f33 100644 --- a/internal/ai/agent.go +++ b/internal/ai/agent.go @@ -105,7 +105,7 @@ func (m *Manager) executeToolCall(ctx context.Context, registry map[string]Tool, out, err := tool.Execute(ctx, tc.Function.Arguments) if err != nil { m.lo.Error("error executing tool", "tool", tc.Function.Name, "error", err) - return "error executing tool: " + err.Error() + return "the tool call failed" } m.lo.Debug("ai run tool result", "tool", tc.Function.Name, "result_len", len(out), "result", out) return out diff --git a/internal/ai/embedding.go b/internal/ai/embedding.go index 1d398aa6..9804a5a6 100644 --- a/internal/ai/embedding.go +++ b/internal/ai/embedding.go @@ -107,7 +107,7 @@ func (m *Manager) Search(ctx context.Context, query string, k int) ([]models.Sea } m.lo.Debug("rag search", "query_len", len(query), "hits", len(results)) for i, r := range results { - m.lo.Debug("rag fetched chunk", "rank", i+1, "score", r.Score, "source_type", r.SourceType, "source_id", r.SourceID, "chunk_len", len(r.ChunkText), "chunk_text", r.ChunkText) + m.lo.Debug("rag fetched chunk", "rank", i+1, "score", r.Score, "source_type", r.SourceType, "source_id", r.SourceID, "chunk_len", len(r.ChunkText)) } return results, nil } diff --git a/internal/aiagent/aiagent.go b/internal/aiagent/aiagent.go index 1f8fca3d..41ce7c56 100644 --- a/internal/aiagent/aiagent.go +++ b/internal/aiagent/aiagent.go @@ -285,7 +285,9 @@ func (m *Manager) CreateAssistant(a models.Assistant) (models.Assistant, error) m.lo.Error("error committing assistant", "error", err) return models.Assistant{}, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil) } - m.refreshAssistantUserIDs() + if err := m.refreshAssistantUserIDs(); err != nil { + m.lo.Error("error refreshing assistant user ids cache", "error", err) + } return m.GetAssistant(id) } @@ -327,7 +329,9 @@ func (m *Manager) UpdateAssistant(id int, a models.Assistant) (models.Assistant, m.lo.Error("error committing assistant update", "error", err) return models.Assistant{}, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil) } - m.refreshAssistantUserIDs() + if err := m.refreshAssistantUserIDs(); err != nil { + m.lo.Error("error refreshing assistant user ids cache", "error", err) + } return m.GetAssistant(id) } @@ -358,7 +362,9 @@ func (m *Manager) DeleteAssistant(id int) (int, error) { m.lo.Error("error committing assistant delete", "error", err) return 0, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil) } - m.refreshAssistantUserIDs() + if err := m.refreshAssistantUserIDs(); err != nil { + m.lo.Error("error refreshing assistant user ids cache", "error", err) + } return a.UserID, nil } diff --git a/internal/aiagent/faq.go b/internal/aiagent/faq.go index 30d97c14..2873d130 100644 --- a/internal/aiagent/faq.go +++ b/internal/aiagent/faq.go @@ -132,12 +132,21 @@ func (m *Manager) miningWorker(ctx context.Context) { case <-ctx.Done(): return case convID := <-m.miningQueue: - m.mine(ctx, convID) + m.mineWithRecover(ctx, convID) m.markMiningDone(convID) } } } +func (m *Manager) mineWithRecover(ctx context.Context, convID int) { + defer func() { + if r := recover(); r != nil { + m.lo.Error("recovered from panic in ai agent mining worker", "conversation_id", convID, "panic", r) + } + }() + m.mine(ctx, convID) +} + func (m *Manager) enqueueMining(convID int) { m.miningMu.Lock() if m.miningInflight[convID] { diff --git a/internal/aiagent/otp.go b/internal/aiagent/otp.go index 01d4fe5d..04c28ef3 100644 --- a/internal/aiagent/otp.go +++ b/internal/aiagent/otp.go @@ -88,7 +88,9 @@ func (m *Manager) incrOTPSends(convUUID string) (bool, error) { return false, err } if n == 1 { - m.redis.Expire(ctx, key, otpVerifiedTTL) + if err := m.redis.Expire(ctx, key, otpVerifiedTTL).Err(); err != nil { + m.lo.Error("error setting ttl on otp sends key", "conversation_uuid", convUUID, "error", err) + } } return n > otpMaxSends, nil } diff --git a/internal/aiagent/worker.go b/internal/aiagent/worker.go index 01b8e3d7..8e5afe71 100644 --- a/internal/aiagent/worker.go +++ b/internal/aiagent/worker.go @@ -83,12 +83,23 @@ func (m *Manager) worker(ctx context.Context) { case <-ctx.Done(): return case convID := <-m.queue: - m.handle(ctx, convID) + m.handleWithRecover(ctx, convID) m.markDone(convID) } } } +// handleWithRecover runs handle and recovers from panics so a single bad run (LLM chain or tool +// execution) can't crash the whole process and take down every channel. +func (m *Manager) handleWithRecover(ctx context.Context, convID int) { + defer func() { + if r := recover(); r != nil { + m.lo.Error("recovered from panic in ai agent worker", "conversation_id", convID, "panic", r) + } + }() + m.handle(ctx, convID) +} + // HandleConversationEvent enqueues a response when the assignee is an AI assistant. func (m *Manager) HandleConversationEvent(conversationID, assigneeUserID int) { if conversationID == 0 || assigneeUserID == 0 { @@ -280,7 +291,10 @@ func (m *Manager) handle(ctx context.Context, convID int) { } } if confirm != "" { - m.postReply(conv, assistant, confirm, map[string]any{"is_confirmation": true}) + if err := m.postReply(conv, assistant, confirm, map[string]any{"is_confirmation": true}); err != nil { + m.handoff(conv, assistant, m.i18n.T("ai.agent.handoffError")) + return + } } if outcome.resolved && (answer != "" || turns > 0) { m.resolve(conv, assistant) diff --git a/internal/image/image.go b/internal/image/image.go index 7e0e3a09..c3b6b5c4 100644 --- a/internal/image/image.go +++ b/internal/image/image.go @@ -5,6 +5,8 @@ package image import ( "bytes" "encoding/base64" + "fmt" + "image" "io" "github.com/disintegration/imaging" @@ -15,6 +17,9 @@ const ( // llmMaxDim caps an image's longest edge before it is sent to a vision model. llmMaxDim = 1568 llmJPEGQuality = 85 + // maxDecodePixels bounds width*height read from the header before decoding, blocking image bombs + // that declare huge dimensions in a small file. + maxDecodePixels = 100_000_000 ) var ( @@ -78,6 +83,13 @@ func CreateThumb(thumbPxSize int, r io.Reader) (*bytes.Reader, error) { // EncodeForLLM decodes an image, downscales its longest edge to at most llmMaxDim, re-encodes it as // JPEG, and returns the base64 payload plus media type for a vision model request. func EncodeForLLM(content []byte) (data string, mediaType string, err error) { + cfg, _, err := image.DecodeConfig(bytes.NewReader(content)) + if err != nil { + return "", "", err + } + if int64(cfg.Width)*int64(cfg.Height) > maxDecodePixels { + return "", "", fmt.Errorf("image dimensions %dx%d exceed decode limit", cfg.Width, cfg.Height) + } img, err := imaging.Decode(bytes.NewReader(content)) if err != nil { return "", "", err