send AI agent confirmation question as a separate widget message

The model marks its trailing confirmation question with a [[confirm]] line.
We split that off and send it as its own chat bubble so the widget reads like
a real conversation. The customer never sees the marker. Email is left as one
combined message since separate bubbles only suit the widget.

Confirmation messages are tagged with is_confirmation in meta so they no
longer inflate the reply count in assistant stats.

Keep the widget typing indicator alive during long agent runs by
re-broadcasting every 3s, under the widget's 5s typing expiry.

Fix the AI assistant and tool edit forms to stay on the page after save.
They now only go back to the list when creating, matching the rest of the
admin forms.
This commit is contained in:
Abhinav Raut
2026-07-13 02:23:35 +05:30
parent 85a38699da
commit e1695b7836
5 changed files with 74 additions and 11 deletions
@@ -188,11 +188,11 @@ const submitForm = async (values) => {
await api.updateAIAssistant(props.id, values)
} else {
await api.createAIAssistant(values)
router.push({ name: 'ai-assistants' })
}
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.savedSuccessfully')
})
router.push({ name: 'ai-assistants' })
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
@@ -40,11 +40,11 @@ const submitForm = async (values) => {
await api.updateAITool(props.id, values)
} else {
await api.createAITool(values)
router.push({ name: 'ai-tools' })
}
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.savedSuccessfully')
})
router.push({ name: 'ai-tools' })
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
+1 -1
View File
@@ -31,7 +31,7 @@ Handling requests:
- Greeting or small talk: reply briefly and warmly, then offer to help.
- A question about the company/product: search the knowledge base first, then answer only from what it returns.
- %s
- When you believe you have fully answered, do not resolve yet. End that reply with a short confirmation question such as "Did that resolve your question?" on its own line, separated from the answer by a blank line, and wait for the customer.
- When you believe you have fully answered, do not resolve yet. After your answer, write a line containing only [[confirm]], then a short confirmation question such as "Did that resolve your question?", and wait for the customer. The customer never sees the [[confirm]] line; it sends the question as its own follow-up message.
- Call resolve only after the customer confirms they are done (for example "yes", "thanks, that's all", or clear agreement). If they raise something new instead, keep helping.`
const handoffToolLine = "- hand_off_to_human: transfer the conversation to a human agent.\n"
+2 -1
View File
@@ -74,7 +74,7 @@ INSERT INTO ai_agent_events (assistant_id, conversation_id, type) VALUES ($1, $2
-- $1 = assistant user id (message sender), $2 = assistant id (events), $3 = window start, $4 = window end.
SELECT
(SELECT count(DISTINCT conversation_id) FROM conversation_messages WHERE sender_id = $1 AND type = 'outgoing' AND private = false AND created_at >= $3 AND created_at < $4 AND NOT COALESCE((meta->>'is_csat')::boolean, false)) AS conversations,
(SELECT count(*) FROM conversation_messages WHERE sender_id = $1 AND type = 'outgoing' AND private = false AND created_at >= $3 AND created_at < $4 AND NOT COALESCE((meta->>'is_csat')::boolean, false)) AS replies,
(SELECT count(*) FROM conversation_messages WHERE sender_id = $1 AND type = 'outgoing' AND private = false AND created_at >= $3 AND created_at < $4 AND NOT COALESCE((meta->>'is_csat')::boolean, false) AND NOT COALESCE((meta->>'is_confirmation')::boolean, false)) AS replies,
(SELECT count(DISTINCT conversation_id) FROM ai_agent_events WHERE assistant_id = $2 AND type = 'handoff' AND created_at >= $3 AND created_at < $4) AS handoffs,
(SELECT count(DISTINCT conversation_id) FROM ai_agent_events WHERE assistant_id = $2 AND type = 'resolve' AND created_at >= $3 AND created_at < $4) AS resolves,
(SELECT count(DISTINCT e.conversation_id) FROM ai_agent_events e JOIN conversations c ON c.id = e.conversation_id JOIN conversation_statuses s ON s.id = c.status_id
@@ -91,6 +91,7 @@ SELECT
SELECT count(*) FROM conversation_messages
WHERE conversation_id = $1 AND sender_id = $2 AND type = 'outgoing' AND private = false
AND NOT COALESCE((meta->>'is_csat')::boolean, false)
AND NOT COALESCE((meta->>'is_confirmation')::boolean, false)
AND created_at > COALESCE((
SELECT max(created_at) FROM conversation_messages
WHERE conversation_id = $1 AND type = 'activity'
+69 -7
View File
@@ -12,6 +12,8 @@ import (
"github.com/abhinavxd/libredesk/internal/attachment"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
imageutil "github.com/abhinavxd/libredesk/internal/image"
"time"
"github.com/abhinavxd/libredesk/internal/stringutil"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
)
@@ -24,6 +26,13 @@ const (
maxImagesPerMessage = 3
maxImagesTotal = 4
maxImageBytes = 8 << 20
// confirmMarker is the line the model emits before its trailing confirmation question so it
// can be split off and sent as a separate message.
confirmMarker = "[[confirm]]"
// typingRefreshInterval must stay under the widget's 5s typing expiry (TYPING_RECEIVE_TIMEOUT).
typingRefreshInterval = 3 * time.Second
)
// terminalStatuses are conversation statuses the assistant does not act on.
@@ -188,9 +197,9 @@ func (m *Manager) handle(ctx context.Context, convID int) {
if conv.Contact.Type == umodels.UserTypeContact {
tctx = ai.ToolContext{ContactExternalID: conv.Contact.ExternalUserID.String, ContactEmail: conv.Contact.Email.String}
}
m.convo.BroadcastTypingToWidgetClientsOnly(conv.UUID, true)
stopTyping := m.keepTyping(conv.UUID)
answer, err := m.ai.RunAgentWithTools(ctx, systemPrompt, history, aiRunMaxSteps, tctx, assistant.ToolIDs, false, tools)
m.convo.BroadcastTypingToWidgetClientsOnly(conv.UUID, false)
stopTyping()
if err != nil {
m.lo.Error("error running ai agent", "conversation_uuid", conv.UUID, "error", err)
m.handoff(conv, assistant, m.i18n.T("ai.agent.handoffError"))
@@ -202,10 +211,17 @@ func (m *Manager) handle(ctx context.Context, convID int) {
return
}
// The model's text answer is the reply to the customer. Handoff and resolve are separate tool actions.
answer = strings.TrimSpace(answer)
answer, confirm := splitConfirmation(strings.TrimSpace(answer))
// Email gets one message; separate chat-style bubbles only suit the widget.
if conv.InboxChannel == channelEmail && confirm != "" {
answer, confirm = answer+"\n\n"+confirm, ""
}
if answer != "" {
m.lo.Debug("ai agent replying", "conversation_uuid", conv.UUID, "reply_len", len(answer), "resolved", outcome.resolved)
m.postReply(conv, assistant, answer)
m.postReply(conv, assistant, answer, nil)
}
if confirm != "" {
m.postReply(conv, assistant, confirm, map[string]any{"is_confirmation": true})
}
if outcome.resolved {
m.resolve(conv, assistant)
@@ -226,12 +242,37 @@ func (m *Manager) resolve(conv cmodels.Conversation, assistant models.Assistant)
m.recordEvent(assistant.ID, conv.ID, "resolve")
}
func (m *Manager) postReply(conv cmodels.Conversation, assistant models.Assistant, text string) {
// keepTyping re-broadcasts typing until the returned stop func runs; the widget clears the indicator 5s after the last event.
func (m *Manager) keepTyping(conversationUUID string) func() {
m.convo.BroadcastTypingToWidgetClientsOnly(conversationUUID, true)
stop := make(chan struct{})
go func() {
t := time.NewTicker(typingRefreshInterval)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
m.convo.BroadcastTypingToWidgetClientsOnly(conversationUUID, true)
}
}
}()
return func() {
close(stop)
m.convo.BroadcastTypingToWidgetClientsOnly(conversationUUID, false)
}
}
func (m *Manager) postReply(conv cmodels.Conversation, assistant models.Assistant, text string, meta map[string]any) {
var to []string
if conv.InboxChannel == channelEmail && conv.Contact.Email.String != "" {
to = []string{conv.Contact.Email.String}
}
if _, err := m.convo.QueueReply(nil, conv.InboxID, assistant.UserID, conv.ContactID, conv.UUID, stringutil.Markdown2HTML(text), to, nil, nil, map[string]interface{}{}); err != nil {
if meta == nil {
meta = map[string]any{}
}
if _, err := m.convo.QueueReply(nil, conv.InboxID, assistant.UserID, conv.ContactID, conv.UUID, stringutil.Markdown2HTML(text), to, nil, nil, meta); err != nil {
m.lo.Error("error sending assistant reply", "conversation_uuid", conv.UUID, "error", err)
}
}
@@ -280,7 +321,15 @@ func (m *Manager) PreviewReply(ctx context.Context, assistantID int, message str
history := []aimodels.ChatMessage{{Role: "user", Content: message}}
tools := []ai.Tool{&searchKnowledgeTool{m: m}}
// Preview is search-only: no custom tools (empty allowed set), no built-in, no side effects.
return m.ai.RunAgentWithTools(ctx, buildSystemPrompt(a), history, aiRunMaxSteps, ai.ToolContext{}, []int{}, false, tools)
answer, err := m.ai.RunAgentWithTools(ctx, buildSystemPrompt(a), history, aiRunMaxSteps, ai.ToolContext{}, []int{}, false, tools)
if err != nil {
return "", err
}
main, confirm := splitConfirmation(strings.TrimSpace(answer))
if confirm != "" {
main += "\n\n" + confirm
}
return main, nil
}
func lastIsInboundContact(msgs []cmodels.Message) bool {
@@ -383,3 +432,16 @@ func unreadableFileMarker(att attachment.Attachment) string {
func unreadableImageMarker(att attachment.Attachment) string {
return fmt.Sprintf("[The customer attached an image %q that you cannot view. Ask them to describe it if it matters.]", att.Name)
}
func splitConfirmation(answer string) (string, string) {
idx := strings.LastIndex(answer, confirmMarker)
if idx == -1 {
return answer, ""
}
main := strings.TrimSpace(strings.ReplaceAll(answer[:idx], confirmMarker, ""))
confirm := strings.TrimSpace(answer[idx+len(confirmMarker):])
if main == "" {
return confirm, ""
}
return main, confirm
}