The error was passed as a positional argument without a key. logf drops
the last field when the field count is odd (handleLog: "If there are odd
number of fields, ignore the last"), so the actual error was silently
discarded and the log line carried no diagnostic detail when the
unsnooze query failed. Pass it as an "error" key/value like everywhere
else in the codebase.
Message attachments and inline images are now linked with a single UPDATE
that runs in the same transaction as the message insert, so a failure can no
longer leave media orphaned. The inline content_id is stamped by the query
itself, and inlineContentID lowercases the uuid to match it - an uppercase
hex uuid in an image URL used to save a cid the DB never matched, so the
image rendered broken. Drops the now unused Attach and SetContentID.
Tag suggestion used to send the first 300 tags to the model, so anything past
that was invisible. Tags are now embedded into the same index as the knowledge
base, and only the tags most similar to the conversation get sent. The reconcile
loop embeds new and renamed tags and drops vectors for deleted ones, and a
provider change purges them so they are rebuilt with the new model.
Two AI agent limits were hardcoded in Go: the tool-calling budget per reply
(6) and the number of conversation messages sent to the model as history
(30). Large installs need to tune these, so both are now read from
config.toml as ai_agent.max_steps and ai_agent.max_history_messages.
Defaults are unchanged, so an install that does not set the keys behaves
exactly as before. Values are clamped at the config read layer, 1-20 for
max_steps and 5-100 for history, so a typo cannot burn tokens on a runaway
loop or drop the model's context to nothing.
Also drop the dead re-slice in buildHistory. The message fetch already
limits to max_history_messages and the filter above it only removes
messages, so the second clamp could never fire.
Final review pass before taking the AI agent branch live.
Knowledge base:
- Text not wrapped in a block tag was never collected, so prose around a
table or list never reached the index. The assistant answered "no
relevant information" for questions the snippet covered.
- Blocks over the token limit were truncated and the remainder dropped. They
are split into several chunks now.
- Trimming an oversized block ran one rune at a time and re-tokenized the
whole string each step. A large table took minutes. It uses a binary
search now.
- Overlap text was not escaped, so a sentence containing markup swallowed
the rest of the chunk.
- SVG and template text no longer reaches the index.
AI agent:
- Verification codes are capped per address and per conversation. The cap
was per conversation only, so a customer correcting a mistyped email was
told to check an inbox that never got a code.
- Livechat verification sends synchronously. A queued send returned nil even
when SMTP failed, so a failure counted as a sent code.
- Queued jobs drain on shutdown and hand off to a human instead of being
dropped with no reply.
- Deleting an assistant no longer moves resolved and closed conversations
into the fallback team.
- Image decode is capped at 25 MP. The old bound allowed a 400 MB decode per
attachment.
Auth and admin:
- A blank OIDC client secret no longer overwrites the stored one. Blank id
or secret is rejected instead.
- OIDC token exchange uses the SSRF guarded client with a timeout.
- Renaming a tool auth header no longer attaches the secret of whichever row
now sits at that position.
- Clearing embedding dimensions no longer refills 1536 on the next load,
which pushed a wrong value to the provider on the next save.
- Copilot conversation lookups filter by access before capping at 10.
An invalid custom tool name (bad characters or over 64 chars) used to
skip validation, hit the database check constraint, and return a 500.
The name format and length are now checked up front and return a 400
with the name hint, matching how the reserved-name check already works.
Bad tool URLs and parameter JSON now return their own specific message
instead of a generic "Something went wrong". Snippet create and update
now reject an empty title, like they already do for empty content.
Adds a unit test covering the tool validation cases.
Design system:
- add semantic success and warning tokens (light + dark) and wire them into Tailwind
- move all hardcoded status colors (green/amber/red) onto tokens across main app and widget; keep file-type icons as identity colors
- give light mode real surface depth: gray chrome sidebars vs white content, deeper canvas gutter, crisper borders, wider gray spread so selected/hover states show
- unify radius (cards rounded-lg, controls rounded-md) and elevation (card shadow-sm, menu shadow-md)
- make reports overview colors uniform: neutral numbers, green met / red breached
- normalize the two page-title heading outliers to text-xl font-semibold
Form fixes:
- require content on AI snippets
- only include non-checkbox prechat fields when they have a value
AI context:
- skip continuity and CSAT messages in AI history, mining, and previous-conversation tools
When the agent built conversation history and mined FAQs, it used the
raw message text, which included the full quoted reply chain from every
email. That wasted tokens and confused the model with old back-and-forth.
Add emailquote.go to strip quoted blocks. HTML messages get their quote
containers pruned (gmail, yahoo, protonmail, outlook markers, and
blockquotes); plain text gets trailing ">" lines and "On ... wrote:" /
"Original Message" markers trimmed. If stripping leaves nothing (a
quote-only reply or forward), we fall back to the full text so the
message is not dropped. Add the matching protonmail_quote selector to
the frontend hide-quoted-text styles so the two stay in sync.
Also fix knowledge base chunking: plain text with no block structure was
kept as one chunk and could overflow the model limit. It now packs into
size-bound pieces on sentence boundaries, and oversized atomic blocks are
flushed and truncated on their own so they can never sneak through.
Knowledge base: fold the embedding provider base URL into the snippet
fingerprint so re-pointing the provider triggers a reindex even when the
model name is unchanged. Reject empty knowledge base content. Cap
concurrent background snippet embeds at 4 and tie embedding work to the
app lifecycle context. Reindex when the embedding base URL changes, not
just the model or dimensions.
Prompt injection: neutralize << >> block delimiters in snippets,
transcripts, subjects, and contact fields so untrusted content can't
forge a boundary the model relies on.
OTP: set the verified flag inside the Lua match script so verification
is atomic. Only count codes that were actually emailed toward the resend
cap, and check the cap before sending instead of incrementing up front.
Tools: fetch only the enabled tools among the allowed IDs, and route all
registrations through one helper so custom tools can't shadow built-ins.
Agent queue: hand a dropped response job off to a human instead of
leaving the conversation assigned to the assistant with no reply.
Also let GetAllConversationMessages return every message when limit is
non-positive, populate admin forms without triggering validation, and
default the copilot name to Juno.
Custom HTTP tools now get more identity context. Each call sends the
contact id, contact type, conversation UUID, and inbox id as headers,
and the contact email is read live per call instead of snapshotting it
at run start.
set_contact_email is no longer blocked once an email is known. A
customer who gives a different email (for example after their account
could not be found) can now correct it. Changing the email clears the
verification flag and any pending code first, so a failed clear can
never leave the conversation verified against an unproven address. The
prompt and tool descriptions were updated to guide this flow.
Also fixes some widget and admin UI issues: prechat form validation
now handles required numbers, checkboxes, and links correctly; tool
header rows keep stable keys so removing a row does not shuffle inputs;
the verification toggle uses form state directly; and the livechat
inbox form shows the inbox UUID with a copy button.
Run loop:
- Search now blocks on an indexReady channel until the boot-time embed
index has loaded, so a run right after startup no longer searches an
empty index.
- Track the newest message ID each run's history saw (lastSeen). A
requeued run sees the previous run's own reply as the last message, so
it now checks for an inbound message that earlier run never included
instead of bailing out.
- After a run, drop its reply and status changes if a human agent took
over or resolved the conversation while it was running.
- Register the OTP verification tools on all OTP-based channels even when
the run starts verified, since the 30-min window can expire mid-run.
- Give PreviewReply a real run timeout.
Admin lifecycle:
- Deleting an assistant now reassigns its conversations to the fallback
team (or the unassigned queue) so they are not stranded.
- Extract getAssistantRow, validateToolInput and toolParametersOrEmpty
to share create/update checks.
Misc:
- Surface capped provider error messages to the UI, not just on the admin
connection test.
- Hard-cap tag suggestions at 3 whatever the model returns.
- Track background reindex goroutines in the wait group so shutdown drains
them.
- FAQ mining and history fetch now page via GetConversationMessages.
- generateOTP uses stringutil.RandomNumeric.
The AI agent and embedding goroutines only stopped on ctx cancellation.
main() never waited for them, so a worker mid-reply could still hit the
database after db.Close() ran.
Give both AI managers a Close() that stops accepting new work and waits for
in-flight workers, matching the existing webhook/sla shutdown pattern. Wire
aiAgent.Close() and ai.Close() into main's shutdown right after the HTTP
server stops, so in-flight work finishes while its dependencies are still up
and before the database closes. Queued-but-unstarted jobs are dropped since
ctx is already cancelled - shutdown stays fast instead of grinding through a
backlog of slow AI calls.
Drop the deprecated webhook.allowed_hosts fallback from the SSRF setup.
It appended hostnames to allowed_cidrs, which only parses CIDRs, so those
entries were silently dropped anyway. Read the [ssrf] block only.
Link AI assistant messages to the specific assistant. postReply now stamps
the assistant id into the message meta, and the message bubble links to that
assistant's edit page, falling back to the assistants list for older messages
that lack the id.
Switched the width*height check to a division-based comparison and reject non-positive dimensions, so a crafted header can't wrap the multiplication and slip past the decode cap.
Incr and Expire were two calls, so if Expire failed the counter key lived forever and the contact could never get another code. A Lua script now does both in one atomic step.
- recover from panics in the AI reply and FAQ-mining workers so one bad run can't crash the process
- guard image decode with a pixel-count cap to block image bombs
- hand off (not silently drop) when the confirmation reply fails to send
- give the model a generic tool-failure message instead of the raw error
- log Redis Expire and assistant-cache refresh failures instead of ignoring them
- drop chunk text from RAG debug logs
- refetch the assistant when the edit route's id changes
Livechat customers verify by pasting the code into chat, not by replying to the email. If the notification sender happens to be the same mailbox as a polled inbox, a stray email reply would get ingested as a new conversation. Setting Reply-To to noreply@<sender-domain> keeps those replies out of any monitored inbox. The email conversation path is unchanged and stays replyable.
The OTP email went out over the notification email channel, which uses a separate SMTP config and a different sender. Now email conversations send the code through their own inbox, reusing the conversation subject so it threads into the same email thread instead of a new one. Livechat still falls back to the notification channel since it can't send email.
Custom tools can now require a verified contact before they run. This gives tools a trustworthy signal about who the customer is without relying on DMARC or the JWT login.
ai_tools gets a requires_verification column, defaulting to true (fail-closed). A flagged tool is blocked in httpTool.Execute until the conversation is verified, and every tool call now carries an X-Libredesk-Contact-Verified header so tool authors can tell an OTP-verified contact from a self-claimed one.
The AI agent gets three native tools: send_email_verification emails a 6-digit code out of band, check_email_verification confirms it, and set_contact_email lets an anonymous visitor add an email to send the code to. Codes and the verified window live in Redis, scoped per conversation, with attempt and resend caps. JWT livechat contacts stay trusted without OTP; email contacts and visitors verify by code.
The tool admin form gets a "require verified contact" toggle (default on) with a confirm dialog when turning it off.
The AI agent's tools act as the primary contact. So any message that entered the history as a trusted user turn could inject instructions that drive those tools under the contact's identity. A CC'd participant on the conversation was one such source.
buildHistory now takes the primary contact's ID. It keeps the contact's own messages and the agent's replies, and drops messages from any other contact before building the prompt.
Copilot and Generate Reply shared one tool builder, so Generate Reply could reach any contact's conversations through the unscoped search tools. That is too broad for a draft that only concerns the current customer.
Split it into two builders. copilotTools keeps the full cross-contact search set. generateReplyTools only exposes fetch_conversation and list_contact_conversations, both scoped to the current contact, and returns no tools when there is no contact so it fails closed.
fetchConversationTool now takes a contactID and returns the same not-found message when a fetched conversation belongs to a different contact. The reply prompt only mentions the history tools when they are actually attached.
Copilot and Generate Reply get four read-only, access-filtered tools to look up
a customer's history: list the contact's other conversations, search
conversations by exact email, fetch one conversation by reference number, and
search contacts. Tool output is marked untrusted and capped so a big response
can't blow the context window.
Copilot panel changes:
- per-agent persona picker that borrows an enabled assistant's voice, language
and instructions without changing the tool set (stored in localStorage)
- answers render as HTML; copy, insert-into-reply, and add-as-private-note
actions per answer
- chat history moves to its own copilot store; server history is persisted only
after a successful reply and read back with a limit
Adds AI tag suggestions: a new endpoint suggests up to 3 existing tags for a
conversation, applied from the sidebar, never auto-applied.
Hardening and fixes:
- OIDC callback rejects non-agent users
- custom tool URLs and params schema validated on save; tool HTTP client no
longer follows redirects; query keys pinned in the tool URL win
- GetAllConversationMessages takes an explicit limit (cap 1000)
- FAQ mining skips a candidate already pending review
- ai agent handoff records its event only after the move actually lands
- share chat message role constants; rename v2.7.0 migration to v2.6.0 and add
the fix_grammar_spelling prompt
Some OpenAI-compatible endpoints reject max_tokens and want
max_completion_tokens. Before, every request paid a 400 round trip to
learn this. Now we cache the swap per base URL and model, so once one
request adapts, later requests send the right param up front. Only adapt
when the error code is unsupported_parameter, not on any max_tokens error.
Parse the usage block from provider responses into a TokenUsage struct and
log prompt/completion/total tokens. Added debug logging of model content,
tool results, and RAG chunk text to make agent runs easier to trace.
Widget side: clean up chat bubble spacing by moving margins off the text
and onto trailing elements, and drop the bottom margin on the last
paragraph/list in rendered HTML so bubbles don't have extra padding.
The frontend used to blank out the masked secret before saving. Now it sends the value as-is and the backend keeps the stored secret when it sees the dummy mask. This matches how webhooks and other secrets already work.
Only append [[confirm]] after a real question is fully answered. Skip it for greetings, small talk, clarifying questions, refusals, and partial answers, or when the customer already signaled they are done.
Tool auth used to be a single header/value pair. It is now a list of
headers, each value encrypted at rest and masked in the API. Editing a
tool keeps an untouched (masked) value's existing secret, matched by
header name. Covered with unit tests.
Agent tuning: a full agent run is now capped (90s for livechat since the
customer is watching, 3min for email), the per-provider-call timeout
drops 90s to 60s with 2 retries, and default worker_count goes 2 to 10
for burst headroom. Idle workers are free; the run cap is what protects
the pool.
Also adds help text and placeholders across the AI admin pages.
Guard the handoff and resolve paths against double handoffs, swallowed reply errors, and a stale team snapshot. Also fail closed on turn-count errors, spend the image budget newest-first, fail boot on an empty assistant set, and keep deleted assistants recognized as AI so FAQ mining never treats their replies as human.
Correctness fixes from the internal/ai code review:
- gate reindex commits on the snippet row still existing, so a delete
racing reconcile can't re-insert its embeddings forever
- cap one logical provider request at 90s across all retries, so a
hanging provider can't stall the reply box for minutes
- return proper error envelopes from the copilot message store instead
of raw sqlx errors that surfaced as "Error interface conversion failed"
- reject blank AI replies in the generate-reply and copilot handlers
- build GET tool URLs with url.Parse so a # in the URL doesn't swallow
the query params
- let a blank api_key clear the stored key (masked keeps it), restoring
a way to disable AI
Cleanups and hardening:
- share one SSRF transport between tool and provider clients instead of
building a new transport per AI call
- copy the provider config struct instead of listing every field
- backfill temperature 0.7 for configured providers upgrading from
releases that hardcoded it
- only pass user/assistant roles from copilot history to the provider
- reject enc:-prefixed secrets that would be stored raw and fail decrypt
- rune-safe truncation of test errors, one-line doc comments