add custom AI tools to copilot and generate reply with an agent approval step

This commit is contained in:
Abhinav Raut
2026-09-18 20:19:52 +05:30
parent 2221f95804
commit 8d99740a41
20 changed files with 967 additions and 124 deletions
+132 -17
View File
@@ -60,6 +60,11 @@ type snippetImportReq struct {
URL string `json:"url"`
}
type aiToolReq struct {
aimodels.Tool
RequiresAgentApproval *bool `json:"requires_agent_approval"`
}
// handleAICompletion runs a stored prompt over the supplied content (reply-box actions).
func handleAICompletion(r *fastglue.Request) error {
var (
@@ -214,12 +219,14 @@ func handleGetAITool(r *fastglue.Request) error {
func handleCreateAITool(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req aimodels.Tool
req aiToolReq
)
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
}
tool, err := app.ai.CreateTool(req)
toolInput := req.Tool
toolInput.RequiresAgentApproval = req.RequiresAgentApproval == nil || *req.RequiresAgentApproval
tool, err := app.ai.CreateTool(toolInput)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -229,7 +236,7 @@ func handleCreateAITool(r *fastglue.Request) error {
func handleUpdateAITool(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req aimodels.Tool
req aiToolReq
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil {
@@ -238,7 +245,17 @@ func handleUpdateAITool(r *fastglue.Request) error {
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
}
tool, err := app.ai.UpdateTool(id, req)
toolInput := req.Tool
if req.RequiresAgentApproval == nil {
existing, err := app.ai.GetTool(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
toolInput.RequiresAgentApproval = existing.RequiresAgentApproval
} else {
toolInput.RequiresAgentApproval = *req.RequiresAgentApproval
}
tool, err := app.ai.UpdateTool(id, toolInput)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -352,14 +369,29 @@ func handleAIGenerateReply(r *fastglue.Request) error {
}
transcript = conversationTranscript(app, req.ConversationUUID)
}
resp, err := app.ai.GenerateReply(r.RequestCtx, transcript, req.Instruction, ai.ToolContext{}, generateReplyTools(app, user, conv))
toolIDs := []int(nil)
tctx := ai.ToolContext{Verified: func() bool { return true }}
scope := ai.AgentRunScope{AgentID: auser.ID, Surface: aimodels.ToolInvocationReply}
if conv != nil {
toolIDs, err = app.ai.GetEnabledGenerateReplyToolIDs()
if err != nil {
return sendErrorEnvelope(r, err)
}
tctx = agentToolContext(conv)
scope.ConversationID = conv.ID
scope.ConversationUUID = conv.UUID
}
resp, err := app.ai.GenerateReply(r.RequestCtx, transcript, req.Instruction, tctx, generateReplyTools(app, user, conv), toolIDs, scope)
if err != nil {
return sendErrorEnvelope(r, err)
}
if strings.TrimSpace(resp) == "" {
if resp.Status == aimodels.AgentRunCompleted && strings.TrimSpace(resp.Content) == "" {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("ai.emptyResponse"), nil))
}
return r.SendEnvelope(stringutil.Markdown2HTML(resp))
if resp.Status == aimodels.AgentRunCompleted {
resp.Content = stringutil.Markdown2HTML(resp.Content)
}
return r.SendEnvelope(resp)
}
// handleAISummarizeConversation summarizes a conversation and posts the summary as the requesting agent's private note.
@@ -466,22 +498,48 @@ func handleAICopilot(r *fastglue.Request) error {
}
history = append(history, aimodels.ChatMessage{Role: aimodels.RoleUser, Content: req.Message})
convoContext := conversationTranscript(app, req.ConversationUUID)
resp, err := app.ai.Copilot(r.RequestCtx, convoContext, history, ai.ToolContext{}, copilotTools(app, user, conv), persona)
scope := ai.AgentRunScope{AgentID: auser.ID, ConversationID: conv.ID, ConversationUUID: conv.UUID, Surface: aimodels.ToolInvocationCopilot, UserMessage: req.Message}
if _, approval := app.ai.PendingAgentApproval(scope); approval != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.ConflictError, app.i18n.T("ai.toolApprovalPending"), nil))
}
toolIDs, err := app.ai.GetEnabledCopilotToolIDs()
if err != nil {
return sendErrorEnvelope(r, err)
}
if strings.TrimSpace(resp) == "" {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("ai.emptyResponse"), nil))
}
// Persist the exchange only after a successful reply, so a failed or empty call leaves no orphaned turn.
if err := app.ai.SaveCopilotMessage(conv.ID, auser.ID, aimodels.RoleUser, req.Message); err != nil {
convoContext := conversationTranscript(app, req.ConversationUUID)
resp, err := app.ai.Copilot(r.RequestCtx, convoContext, history, agentToolContext(conv), copilotTools(app, user, conv), persona, toolIDs, scope)
if err != nil {
return sendErrorEnvelope(r, err)
}
if err := app.ai.SaveCopilotMessage(conv.ID, auser.ID, aimodels.RoleAssistant, resp); err != nil {
app.lo.Error("error saving copilot reply", "error", err)
if resp.Status == aimodels.AgentRunCompleted && strings.TrimSpace(resp.Content) == "" {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("ai.emptyResponse"), nil))
}
return r.SendEnvelope(stringutil.Markdown2HTML(resp))
if resp.Status == aimodels.AgentRunCompleted {
if err := app.ai.SaveCopilotMessage(conv.ID, auser.ID, aimodels.RoleUser, req.Message); err != nil {
return sendErrorEnvelope(r, err)
}
if err := app.ai.SaveCopilotMessage(conv.ID, auser.ID, aimodels.RoleAssistant, resp.Content); err != nil {
app.lo.Error("error saving copilot reply", "error", err)
}
resp.Content = stringutil.Markdown2HTML(resp.Content)
}
return r.SendEnvelope(resp)
}
func handleApproveAIToolRun(r *fastglue.Request) error {
resp, err := decideAIToolRun(r, true)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(resp)
}
func handleDeclineAIToolRun(r *fastglue.Request) error {
resp, err := decideAIToolRun(r, false)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(resp)
}
// handleGetCopilotMessages returns the requesting agent's persisted copilot chat for a conversation.
@@ -506,6 +564,13 @@ func handleGetCopilotMessages(r *fastglue.Request) error {
msgs[i].Content = stringutil.Markdown2HTML(msgs[i].Content)
}
}
scope := ai.AgentRunScope{AgentID: auser.ID, ConversationID: conv.ID, ConversationUUID: conv.UUID, Surface: aimodels.ToolInvocationCopilot}
if userMessage, approval := app.ai.PendingAgentApproval(scope); approval != nil {
if userMessage != "" {
msgs = append(msgs, aimodels.CopilotMessage{Role: aimodels.RoleUser, Content: userMessage})
}
msgs = append(msgs, aimodels.CopilotMessage{Role: aimodels.RoleApproval, Approval: approval})
}
return r.SendEnvelope(msgs)
}
@@ -524,6 +589,7 @@ func handleClearCopilotMessages(r *fastglue.Request) error {
if err := app.ai.ClearCopilotMessages(conv.ID, auser.ID); err != nil {
return sendErrorEnvelope(r, err)
}
app.ai.ClearPendingAgentRuns(ai.AgentRunScope{AgentID: auser.ID, ConversationID: conv.ID, ConversationUUID: conv.UUID, Surface: aimodels.ToolInvocationCopilot})
return r.SendEnvelope(true)
}
@@ -548,3 +614,52 @@ func conversationTranscript(app *App, uuid string) string {
}
return cmodels.Transcript(msgs, maxTranscriptMessages)
}
func decideAIToolRun(r *fastglue.Request, approved bool) (aimodels.AgentRunResult, error) {
app := r.Context.(*App)
auser := r.RequestCtx.UserValue("user").(amodels.User)
runID := r.RequestCtx.UserValue("id").(string)
scope, err := app.ai.PendingAgentRunScope(runID, auser.ID)
if err != nil {
return aimodels.AgentRunResult{}, err
}
if scope.ConversationUUID != "" {
conv, err := enforceAIConversationAccess(r, scope.ConversationUUID)
if err != nil {
return aimodels.AgentRunResult{}, err
}
if conv.ID != scope.ConversationID {
return aimodels.AgentRunResult{}, envelope.NewError(envelope.PermissionError, app.i18n.T("ai.toolApprovalUnavailable"), nil)
}
}
resp, resumedScope, err := app.ai.ResumeAgentRun(r.RequestCtx, runID, auser.ID, approved)
if err != nil {
return aimodels.AgentRunResult{}, err
}
if resp.Status == aimodels.AgentRunCompleted && strings.TrimSpace(resp.Content) == "" {
return aimodels.AgentRunResult{}, envelope.NewError(envelope.GeneralError, app.i18n.T("ai.emptyResponse"), nil)
}
if resp.Status == aimodels.AgentRunCompleted {
if resumedScope.Surface == aimodels.ToolInvocationCopilot {
if err := app.ai.SaveCopilotMessage(resumedScope.ConversationID, auser.ID, aimodels.RoleUser, resumedScope.UserMessage); err != nil {
app.lo.Error("error saving copilot message", "error", err)
} else if err := app.ai.SaveCopilotMessage(resumedScope.ConversationID, auser.ID, aimodels.RoleAssistant, resp.Content); err != nil {
app.lo.Error("error saving copilot reply", "error", err)
}
}
resp.Content = stringutil.Markdown2HTML(resp.Content)
}
return resp, nil
}
func agentToolContext(conv *cmodels.Conversation) ai.ToolContext {
return ai.ToolContext{
ContactID: conv.Contact.ID,
ContactExternalID: conv.Contact.ExternalUserID.String,
ContactType: conv.Contact.Type,
ConversationUUID: conv.UUID,
InboxID: conv.InboxID,
ContactEmail: func() string { return conv.Contact.Email.String },
Verified: func() bool { return true },
}
}
+2
View File
@@ -282,6 +282,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.POST("/api/v1/ai/summarize", perm(handleAISummarizeConversation, "messages:write_private"))
g.POST("/api/v1/ai/suggest-tags", auth(handleAISuggestTags))
g.POST("/api/v1/ai/copilot", auth(handleAICopilot))
g.POST("/api/v1/ai/tool-runs/{id}/approve", auth(handleApproveAIToolRun))
g.POST("/api/v1/ai/tool-runs/{id}/decline", auth(handleDeclineAIToolRun))
g.GET("/api/v1/ai/copilot/messages", auth(handleGetCopilotMessages))
g.DELETE("/api/v1/ai/copilot/messages", auth(handleClearCopilotMessages))
+6
View File
@@ -517,6 +517,10 @@ const aiGenerateReply = (data) => http.post('/api/v1/ai/generate-reply', data, {
const aiSummarizeConversation = (data) => http.post('/api/v1/ai/summarize', data, { timeout: AI_TIMEOUT })
const aiSuggestTags = (data) => http.post('/api/v1/ai/suggest-tags', data, { timeout: AI_TIMEOUT })
const aiCopilot = (data) => http.post('/api/v1/ai/copilot', data, { timeout: AI_TIMEOUT })
const approveAIToolRun = (id) =>
http.post(`/api/v1/ai/tool-runs/${id}/approve`, {}, { timeout: AI_TIMEOUT })
const declineAIToolRun = (id) =>
http.post(`/api/v1/ai/tool-runs/${id}/decline`, {}, { timeout: AI_TIMEOUT })
const getCopilotMessages = (conversationUUID) =>
http.get('/api/v1/ai/copilot/messages', { params: { conversation_uuid: conversationUUID } })
const clearCopilotMessages = (conversationUUID) =>
@@ -818,6 +822,8 @@ export default {
aiSummarizeConversation,
aiSuggestTags,
aiCopilot,
approveAIToolRun,
declineAIToolRun,
getCopilotMessages,
clearCopilotMessages,
searchConversations,
@@ -31,7 +31,11 @@
<FormItem class="md:col-span-2">
<FormLabel>{{ t('globals.terms.url') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="https://api.example.com/orders/lookup" v-bind="componentField" />
<Input
type="text"
placeholder="https://api.example.com/orders/lookup"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -119,6 +123,44 @@
</FormItem>
</FormField>
<div class="space-y-5 rounded-lg border border-border p-4">
<div>
<h3 class="text-sm font-medium text-foreground">{{ t('admin.ai.tool.agentAccess') }}</h3>
</div>
<FormField v-slot="{ componentField, handleChange }" name="copilot_enabled">
<FormItem>
<SwitchField
:title="t('admin.ai.tool.availableInCopilot')"
:checked="componentField.modelValue"
@update:checked="handleChange"
/>
</FormItem>
</FormField>
<FormField v-slot="{ componentField, handleChange }" name="generate_reply_enabled">
<FormItem>
<SwitchField
:title="t('admin.ai.tool.availableInGenerateReply')"
:checked="componentField.modelValue"
@update:checked="handleChange"
/>
</FormItem>
</FormField>
<FormField v-slot="{ componentField, handleChange }" name="requires_agent_approval">
<FormItem>
<SwitchField
:title="t('admin.ai.tool.requireAgentApproval')"
:description="t('admin.ai.tool.requireAgentApprovalHint')"
:checked="componentField.modelValue"
:disabled="!hasAgentSurface"
@update:checked="handleChange"
/>
</FormItem>
</FormField>
</div>
<div class="flex justify-end mt-10">
<Button type="submit" :isLoading="formLoading">
{{ isEditing ? t('globals.messages.save') : t('globals.messages.create') }}
@@ -156,7 +198,7 @@
</template>
<script setup>
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
@@ -233,7 +275,10 @@ const form = useForm({
}),
parameters: z.string().optional(),
enabled: z.boolean().optional(),
requires_verification: z.boolean().default(true)
requires_verification: z.boolean(),
copilot_enabled: z.boolean(),
generate_reply_enabled: z.boolean(),
requires_agent_approval: z.boolean()
})
),
initialValues: {
@@ -244,10 +289,16 @@ const form = useForm({
headers: [],
parameters: '',
enabled: true,
requires_verification: true
requires_verification: true,
copilot_enabled: false,
generate_reply_enabled: false,
requires_agent_approval: true
}
})
const hasAgentSurface = computed(
() => form.values.copilot_enabled || form.values.generate_reply_enabled
)
const insertParametersExample = () => {
form.setFieldValue('parameters', parametersPlaceholder, false)
}
@@ -286,7 +337,10 @@ watch(
? JSON.stringify(values.parameters, null, 2)
: '',
enabled: values.enabled ?? true,
requires_verification: values.requires_verification ?? true
requires_verification: values.requires_verification ?? true,
copilot_enabled: values.copilot_enabled ?? false,
generate_reply_enabled: values.generate_reply_enabled ?? false,
requires_agent_approval: values.requires_agent_approval ?? true
},
false
)
@@ -319,6 +373,9 @@ const onSubmit = form.handleSubmit(async (values) => {
method: values.method || 'POST',
enabled: !!values.enabled,
requires_verification: values.requires_verification !== false,
copilot_enabled: !!values.copilot_enabled,
generate_reply_enabled: !!values.generate_reply_enabled,
requires_agent_approval: values.requires_agent_approval !== false,
auth: { headers },
parameters
})
@@ -1,4 +1,43 @@
<template>
<AlertDialog
:open="!!pendingToolApproval && pendingToolConversationUUID === currentConversationUUID"
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ $t('ai.toolApprovalTitle') }}</AlertDialogTitle>
<AlertDialogDescription as="div" class="space-y-3">
<p>
<i18n-t keypath="ai.toolApprovalDescription" scope="global">
<template #tool
><code class="inline-flex items-center rounded-md border bg-muted px-1.5 py-0.5 font-mono text-xs font-medium text-foreground">{{ pendingToolApproval?.tool_name }}</code></template
>
</i18n-t>
</p>
<div class="space-y-1 text-foreground">
<p class="text-xs font-medium">{{ $t('ai.toolApprovalArguments') }}</p>
<pre
class="max-h-60 overflow-auto whitespace-pre-wrap rounded-md bg-muted p-3 text-xs [overflow-wrap:anywhere]"
>{{ formatToolArguments(pendingToolApproval?.arguments) }}</pre
>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<Button
type="button"
variant="outline"
:disabled="isGenerating"
@click="resolveGenerateToolApproval(false)"
>
{{ $t('ai.toolApprovalDecline') }}
</Button>
<Button type="button" :disabled="isGenerating" @click="resolveGenerateToolApproval(true)">
{{ $t('ai.toolApprovalApprove') }}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog :open="showContactEmailWarning" @update:open="showContactEmailWarning = $event">
<AlertDialogContent>
<AlertDialogHeader>
@@ -95,7 +134,11 @@
<Pencil class="shrink-0 text-muted-foreground" />
<span v-if="draftPreview" class="truncate">{{ draftPreview }}</span>
<span v-else class="truncate text-muted-foreground">
{{ messageType === 'private_note' ? $t('globals.terms.privateNote') : $t('globals.terms.reply') }}
{{
messageType === 'private_note'
? $t('globals.terms.privateNote')
: $t('globals.terms.reply')
}}
</span>
<span
v-if="attachmentCount"
@@ -257,18 +300,33 @@ const activeContentRef = () =>
isEditorFullscreen.value ? fullscreenContentRef.value : replyBoxContentRef.value
const showContactEmailWarning = ref(false)
const showMissingTagsWarning = ref(false)
const pendingToolApproval = ref(null)
const pendingToolConversationUUID = ref('')
const deferredStatus = ref(null)
const mentions = ref([])
watch(currentConversationUUID, (uuid) => {
if (pendingToolApproval.value && pendingToolConversationUUID.value !== uuid) {
pendingToolApproval.value = null
pendingToolConversationUUID.value = ''
}
})
const runAiGeneration = async (requestFn) => {
if (isGenerating.value) return
if (isGenerating.value || pendingToolApproval.value) return
const uuid = currentConversationUUID.value
if (!uuid) return
isGenerating.value = true
try {
const resp = await requestFn(uuid)
if (uuid !== currentConversationUUID.value) return
htmlContent.value = resp.data.data || ''
const result = resp.data.data
if (result.status === 'approval_required') {
pendingToolApproval.value = result.approval
pendingToolConversationUUID.value = uuid
return
}
htmlContent.value = result.content || ''
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
@@ -284,6 +342,47 @@ const handleGenerateReply = () =>
api.aiGenerateReply({ conversation_uuid: uuid, instruction: textContent.value })
)
const resolveGenerateToolApproval = async (approved) => {
const approval = pendingToolApproval.value
const uuid = pendingToolConversationUUID.value
if (!approval || isGenerating.value) return
isGenerating.value = true
try {
const resp = approved
? await api.approveAIToolRun(approval.run_id)
: await api.declineAIToolRun(approval.run_id)
const result = resp.data.data
if (uuid !== currentConversationUUID.value) return
if (result.status === 'approval_required') {
pendingToolApproval.value = result.approval
pendingToolConversationUUID.value = uuid
return
}
pendingToolApproval.value = null
pendingToolConversationUUID.value = ''
htmlContent.value = result.content || ''
} catch (error) {
if ([404, 409].includes(error?.response?.status)) {
pendingToolApproval.value = null
pendingToolConversationUUID.value = ''
}
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
isGenerating.value = false
}
}
const formatToolArguments = (argumentsText) => {
try {
return JSON.stringify(JSON.parse(argumentsText), null, 2)
} catch {
return argumentsText || ''
}
}
// Copilot's "Insert into reply" replaces the draft with its answer (already HTML from the panel),
// forcing reply mode so a private note in progress does not silently receive customer-facing text.
const handleCopilotInsertReply = (html) => {
@@ -330,7 +429,11 @@ const draftPreview = computed(() => textContent.value.trim())
const attachmentCount = computed(() => mediaFiles.value.length + uploadingFiles.value.length)
const processSend = async (skipContactEmailCheck = false, skipMissingTagsCheck = false, statusToSet = null) => {
const processSend = async (
skipContactEmailCheck = false,
skipMissingTagsCheck = false,
statusToSet = null
) => {
let hasMessageSendingErrored = false
isEditorFullscreen.value = false
@@ -342,9 +445,7 @@ const processSend = async (skipContactEmailCheck = false, skipMissingTagsCheck =
if ((isPrivate && !canSendPrivateNote.value) || (!isPrivate && !canSendReply.value)) return
const currentInbox = inboxStore.inboxes.find(
(i) => i.id === conversationStore.current.inbox_id
)
const currentInbox = inboxStore.inboxes.find((i) => i.id === conversationStore.current.inbox_id)
if (
!isPrivate &&
!skipMissingTagsCheck &&
@@ -519,8 +620,10 @@ watch(
[loadedMacroID, loadedMacroActions],
([id, actions]) => {
conversationStore.resetMacro(MACRO_CONTEXT.REPLY)
if (id > 0) conversationStore.setMacro({ id, actions: [...toRaw(actions)] }, MACRO_CONTEXT.REPLY)
else if (actions.length) conversationStore.setMacroActions([...toRaw(actions)], MACRO_CONTEXT.REPLY)
if (id > 0)
conversationStore.setMacro({ id, actions: [...toRaw(actions)] }, MACRO_CONTEXT.REPLY)
else if (actions.length)
conversationStore.setMacroActions([...toRaw(actions)], MACRO_CONTEXT.REPLY)
},
{ deep: true }
)
@@ -9,7 +9,9 @@
:model-value="selectedAssistantId"
@update:model-value="persistAssistant"
>
<SelectTrigger class="h-8 w-auto gap-1.5 border-0 shadow-none text-muted-foreground focus:ring-0">
<SelectTrigger
class="h-8 w-auto gap-1.5 border-0 shadow-none text-muted-foreground focus:ring-0"
>
<SelectValue :placeholder="COPILOT_NAME" />
</SelectTrigger>
<SelectContent>
@@ -75,6 +77,51 @@
:class="msg.isUser ? 'items-end' : 'items-start'"
>
<div
v-if="msg.approval"
class="w-full space-y-3 rounded-lg border border-border bg-card p-3 text-card-foreground"
role="group"
aria-live="polite"
:aria-label="$t('ai.toolApprovalTitle')"
>
<div class="space-y-1">
<p class="text-sm font-medium">{{ $t('ai.toolApprovalTitle') }}</p>
<p class="text-xs text-muted-foreground">
<i18n-t keypath="ai.toolApprovalDescription" scope="global">
<template #tool
><code class="inline-flex items-center rounded-md border bg-muted px-1.5 py-0.5 font-mono text-xs font-medium text-foreground">{{ msg.approval.tool_name }}</code></template
>
</i18n-t>
</p>
</div>
<div class="space-y-1">
<p class="text-xs font-medium">{{ $t('ai.toolApprovalArguments') }}</p>
<pre
class="max-h-40 overflow-auto whitespace-pre-wrap rounded-md bg-muted p-2 text-xs [overflow-wrap:anywhere]"
>{{ formatToolArguments(msg.approval.arguments) }}</pre
>
</div>
<div class="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
:disabled="isThinking"
@click="resolveToolApproval(msg.approval, false)"
>
{{ $t('ai.toolApprovalDecline') }}
</Button>
<Button
type="button"
size="sm"
:disabled="isThinking"
@click="resolveToolApproval(msg.approval, true)"
>
{{ $t('ai.toolApprovalApprove') }}
</Button>
</div>
</div>
<div
v-else
class="rounded-lg px-3 py-2 text-sm [overflow-wrap:anywhere]"
:class="
msg.isUser
@@ -85,7 +132,7 @@
<Letter v-if="!msg.isUser" :html="msg.content" class="native-html" />
<template v-else>{{ msg.content }}</template>
</div>
<div v-if="!msg.isUser && msg.content" class="flex gap-0.5">
<div v-if="!msg.isUser && !msg.approval && msg.content" class="flex gap-0.5">
<Tooltip>
<TooltipTrigger as-child>
<Button
@@ -150,7 +197,10 @@
>
<Textarea
v-model="input"
:placeholder="$t('copilot.placeholder')"
:placeholder="
hasPendingApproval ? $t('ai.toolApprovalPendingInput') : $t('copilot.placeholder')
"
:disabled="hasPendingApproval"
rows="2"
class="min-h-[44px] resize-none border-0 bg-transparent shadow-none focus-visible:ring-0"
@keydown.enter.exact.prevent="send"
@@ -198,10 +248,7 @@ const { t } = useI18n()
const canSendReply = computed(() => userStore.can(perms.MESSAGES_WRITE))
const canSendPrivateNote = computed(() => userStore.can(perms.MESSAGES_WRITE_PRIVATE))
const presets = computed(() => [
t('copilot.preset.summarize'),
t('copilot.preset.customerAsking')
])
const presets = computed(() => [t('copilot.preset.summarize'), t('copilot.preset.customerAsking')])
// Chat history lives in the store keyed by conversation uuid so it survives tab
// switches (this panel unmounts) and never leaks across conversations.
@@ -215,6 +262,7 @@ const input = ref('')
// block the panel after the agent switches to another.
const thinkingByUUID = ref({})
const isThinking = computed(() => !!thinkingByUUID.value[conversationStore.current?.uuid || ''])
const hasPendingApproval = computed(() => messages.value.some((message) => !!message.approval))
const scrollRef = ref(null)
// Persona selection is global per agent (a stored assistant whose instructions Copilot borrows for
@@ -269,7 +317,11 @@ const hydrate = async (uuid) => {
try {
const resp = await api.getCopilotMessages(uuid)
if (rev !== revision(uuid) || copilotStore.getMessages(uuid).length > 0) return
const loaded = (resp.data.data || []).map((m) => ({ role: m.role, content: m.content }))
const loaded = (resp.data.data || []).map((m) => ({
role: m.role,
content: m.content,
approval: m.approval
}))
if (loaded.length) {
copilotStore.setMessages(uuid, loaded)
await scrollToBottom()
@@ -295,12 +347,15 @@ const scrollToBottom = async () => {
const send = async (preset) => {
const text = (typeof preset === 'string' ? preset : input.value).trim()
if (!text || isThinking.value) return
if (!text || isThinking.value || hasPendingApproval.value) return
const uuid = conversationStore.current?.uuid || ''
if (!uuid) return
const rev = revision(uuid)
copilotStore.setMessages(uuid, [...copilotStore.getMessages(uuid), { role: 'user', content: text }])
copilotStore.setMessages(uuid, [
...copilotStore.getMessages(uuid),
{ role: 'user', content: text }
])
input.value = ''
thinkingByUUID.value[uuid] = true
await scrollToBottom()
@@ -310,10 +365,12 @@ const send = async (preset) => {
if (selectedAssistantId.value > 0) payload.assistant_id = selectedAssistantId.value
const resp = await api.aiCopilot(payload)
if (rev !== revision(uuid)) return
copilotStore.setMessages(uuid, [
...copilotStore.getMessages(uuid),
{ role: 'assistant', content: resp.data.data || '' }
])
const result = resp.data.data
const message =
result.status === 'approval_required'
? { role: 'approval', approval: result.approval }
: { role: 'assistant', content: result.content || '' }
copilotStore.setMessages(uuid, [...copilotStore.getMessages(uuid), message])
} catch (error) {
// A rejected persona (deleted or disabled since selection) comes back as an input error; fall back
// to the default Copilot so the next send works.
@@ -330,6 +387,54 @@ const send = async (preset) => {
}
}
const resolveToolApproval = async (approval, approved) => {
const uuid = conversationStore.current?.uuid || ''
if (!uuid || isThinking.value) return
const rev = revision(uuid)
thinkingByUUID.value[uuid] = true
try {
const resp = approved
? await api.approveAIToolRun(approval.run_id)
: await api.declineAIToolRun(approval.run_id)
if (rev !== revision(uuid)) return
const result = resp.data.data
const replacement =
result.status === 'approval_required'
? { role: 'approval', approval: result.approval }
: { role: 'assistant', content: result.content || '' }
copilotStore.setMessages(
uuid,
copilotStore
.getMessages(uuid)
.map((message) => (message.approval?.run_id === approval.run_id ? replacement : message))
)
} catch (error) {
if ([404, 409].includes(error?.response?.status)) {
copilotStore.setMessages(
uuid,
copilotStore
.getMessages(uuid)
.filter((message) => message.approval?.run_id !== approval.run_id)
)
}
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
delete thinkingByUUID.value[uuid]
await scrollToBottom()
}
}
const formatToolArguments = (argumentsText) => {
try {
return JSON.stringify(JSON.parse(argumentsText), null, 2)
} catch {
return argumentsText
}
}
const copyAnswer = async (content) => {
try {
await navigator.clipboard.writeText(getTextFromHTML(content))
@@ -1,5 +1,6 @@
<script setup>
import { Switch } from '@shared-ui/components/ui/switch'
import { useId } from 'vue'
defineProps({
title: { type: String, default: '' },
@@ -9,18 +10,23 @@ defineProps({
})
defineEmits(['update:checked'])
const titleId = useId()
const descriptionId = useId()
</script>
<template>
<div class="space-y-2">
<p class="text-sm font-medium text-foreground">{{ title }}</p>
<p :id="titleId" class="text-sm font-medium text-foreground">{{ title }}</p>
<div class="flex items-center gap-3">
<Switch
:checked="checked"
:disabled="disabled"
:aria-labelledby="titleId"
:aria-describedby="description ? descriptionId : undefined"
@update:checked="$emit('update:checked', $event)"
/>
<p class="text-sm text-muted-foreground">{{ description }}</p>
<p :id="descriptionId" class="text-sm text-muted-foreground">{{ description }}</p>
</div>
</div>
</template>
+17
View File
@@ -188,6 +188,11 @@
"admin.ai.tool.turnOffVerificationEndpoint": "If the tool accesses sensitive data or performs actions, your endpoint must authenticate the customer itself. Every request carries an X-Libredesk-Contact-Verified header.",
"admin.ai.tool.keepVerificationOn": "Keep authentication on",
"admin.ai.tool.turnOffVerification": "Turn off",
"admin.ai.tool.agentAccess": "Agent access",
"admin.ai.tool.availableInCopilot": "Available in Copilot",
"admin.ai.tool.availableInGenerateReply": "Available in Generate Reply",
"admin.ai.tool.requireAgentApproval": "Require agent approval",
"admin.ai.tool.requireAgentApprovalHint": "The tool pauses before it runs. The agent must review its arguments and approve or decline it.",
"admin.ai.snippet.new": "New snippet",
"admin.ai.snippet.edit": "Edit snippet",
"admin.ai.snippet.deleteConfirmation": "This will permanently delete the snippet.",
@@ -596,6 +601,18 @@
"ai.assistantUnavailable": "The selected assistant is unavailable. It may have been removed or disabled. Pick another assistant or use the default Copilot.",
"ai.noTagsConfigured": "No tags exist yet. Tag suggestions need an existing tag list to choose from. Ask an admin to create tags first.",
"ai.tagSuggestEmptyConversation": "There are no messages to suggest tags from yet.",
"ai.toolApprovalExpired": "This tool request is no longer available. Run the AI action again to create a new request.",
"ai.toolApprovalPending": "A tool request is waiting for approval. Approve or decline it before sending another Copilot message.",
"ai.toolApprovalUnavailable": "This tool request cannot be approved for this conversation. Run the AI action again to create a new request.",
"ai.toolChangedBeforeApproval": "This tool changed after approval was requested and was not run. Run the AI action again to review the current tool.",
"ai.toolApprovalTitle": "Review tool request",
"ai.toolApprovalDescription": "The AI wants to run {tool}. Review the arguments before allowing it.",
"ai.toolApprovalArguments": "Arguments",
"ai.toolApprovalApprove": "Approve and run",
"ai.toolApprovalDecline": "Decline",
"ai.toolRanResponseFailed": "The tool request finished, but the AI could not complete its response. Check the external system before running it again.",
"ai.toolDeclinedResponseFailed": "The tool was declined, but the AI could not complete its response. Run the AI action again if you still need a reply.",
"ai.toolApprovalPendingInput": "Approve or decline the pending tool request first",
"auth.backToLogin": "Back to login",
"auth.checkEmailForReset": "Check your email for the password reset link.",
"auth.confirmPassword": "Confirm password",
+33 -45
View File
@@ -5,7 +5,6 @@ import (
"strings"
"github.com/abhinavxd/libredesk/internal/ai/models"
"github.com/abhinavxd/libredesk/internal/envelope"
)
const defaultMaxSteps = 5
@@ -17,13 +16,34 @@ func (m *Manager) RunAgent(ctx context.Context, systemPrompt string, history []m
// RunAgentWithTools runs the tool-calling loop; allowedToolIDs restricts custom tools to that set (empty loads none), appendWorkspaceInstructions folds the workspace admin instructions into the system prompt.
func (m *Manager) RunAgentWithTools(ctx context.Context, systemPrompt string, history []models.ChatMessage, maxSteps int, tctx ToolContext, allowedToolIDs []int, appendWorkspaceInstructions, includeBuiltinSearch bool, extra []Tool) (string, error) {
run, err := m.newAgentRun(systemPrompt, history, maxSteps, tctx, allowedToolIDs, appendWorkspaceInstructions, includeBuiltinSearch, extra, AgentRunScope{}, false)
if err != nil {
return "", err
}
res, err := m.continueAgentRun(ctx, run)
if err != nil {
return "", err
}
return res.Content, nil
}
// RunAgentWithApprovals runs the tool-calling loop for agent surfaces; a custom tool flagged for approval pauses the run until the agent approves or declines it.
func (m *Manager) RunAgentWithApprovals(ctx context.Context, systemPrompt string, history []models.ChatMessage, maxSteps int, tctx ToolContext, allowedToolIDs []int, extra []Tool, scope AgentRunScope) (models.AgentRunResult, error) {
run, err := m.newAgentRun(systemPrompt, history, maxSteps, tctx, allowedToolIDs, true, true, extra, scope, true)
if err != nil {
return models.AgentRunResult{}, err
}
return m.continueAgentRun(ctx, run)
}
func (m *Manager) newAgentRun(systemPrompt string, history []models.ChatMessage, maxSteps int, tctx ToolContext, allowedToolIDs []int, appendWorkspaceInstructions, includeBuiltinSearch bool, extra []Tool, scope AgentRunScope, approvals bool) (*pendingAgentRun, error) {
if maxSteps <= 0 {
maxSteps = defaultMaxSteps
}
cfg, err := m.getProviderConfig(models.ProviderTypeCompletion)
if err != nil {
return "", err
return nil, err
}
client := NewOpenAIClient(cfg, m.lo, m.providerHTTPClient)
if instructions := strings.TrimSpace(cfg.Instructions); appendWorkspaceInstructions && instructions != "" {
@@ -35,10 +55,10 @@ func (m *Manager) RunAgentWithTools(ctx context.Context, systemPrompt string, hi
registry, defs, err := m.buildToolRegistry(tctx, allowedToolIDs, includeBuiltinSearch, extra)
if err != nil {
return "", err
return nil, err
}
messages := make([]models.ChatMessage, 0, len(history)+2)
messages := make([]models.ChatMessage, 0, len(history)+1)
if systemPrompt != "" {
messages = append(messages, models.ChatMessage{Role: models.RoleSystem, Content: systemPrompt})
}
@@ -55,47 +75,15 @@ func (m *Manager) RunAgentWithTools(ctx context.Context, systemPrompt string, hi
m.lo.Debug("ai run starting", "model", cfg.Model, "vision", cfg.Vision, "max_steps", maxSteps, "history_messages", len(history), "images", imageCount, "tools", len(defs), "tool_names", strings.Join(toolNames, ","))
m.lo.Debug("ai run system prompt", "prompt", systemPrompt)
for step := 0; step < maxSteps; step++ {
m.lo.Debug("ai run step", "step", step, "messages", len(messages))
res, err := m.chatCompletion(ctx, client, models.ChatCompletionPayload{Messages: messages, Tools: defs})
if err != nil {
return "", err
}
m.lo.Debug("ai run model response", "step", step, "content_len", len(res.Content), "tool_calls", len(res.ToolCalls), "content", res.Content, "prompt_tokens", res.Usage.PromptTokens, "completion_tokens", res.Usage.CompletionTokens)
if len(res.ToolCalls) == 0 {
m.lo.Debug("ai run final answer", "answer", res.Content)
return stripCodeFence(res.Content), nil
}
messages = append(messages, models.ChatMessage{
Role: models.RoleAssistant,
Content: res.Content,
ToolCalls: res.ToolCalls,
})
for _, tc := range res.ToolCalls {
result := m.executeToolCall(ctx, registry, tc)
messages = append(messages, models.ChatMessage{
Role: models.RoleTool,
ToolCallID: tc.ID,
Name: tc.Function.Name,
Content: result,
})
}
}
// Step budget exhausted, force a final answer by omitting tools.
res, err := m.chatCompletion(ctx, client, models.ChatCompletionPayload{Messages: messages})
if err != nil {
return "", err
}
if strings.TrimSpace(res.Content) == "" {
m.lo.Warn("agent produced no answer within the step budget", "max_steps", maxSteps)
return "", envelope.NewError(envelope.GeneralError, m.i18n.T("ai.noAnswerWithinSteps"), nil)
}
m.lo.Debug("ai run final answer", "answer", res.Content, "forced", true)
return stripCodeFence(res.Content), nil
return &pendingAgentRun{
Scope: scope,
Client: client,
Definitions: defs,
Registry: registry,
Messages: messages,
MaxSteps: maxSteps,
Approvals: approvals,
}, nil
}
func (m *Manager) executeToolCall(ctx context.Context, registry map[string]Tool, tc models.ToolCall) string {
+242
View File
@@ -0,0 +1,242 @@
package ai
import (
"context"
"strings"
"time"
"github.com/abhinavxd/libredesk/internal/ai/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/google/uuid"
)
const pendingAgentRunTTL = 10 * time.Minute
type approvalTool interface {
Tool
approvalRequired() bool
toolID() int
toolUpdatedAt() time.Time
}
type AgentRunScope struct {
AgentID int
ConversationID int
ConversationUUID string
Surface string
UserMessage string
}
type pendingAgentRun struct {
ID string
Scope AgentRunScope
Client ProviderClient
Definitions []models.ToolDef
Registry map[string]Tool
Messages []models.ChatMessage
ToolCalls []models.ToolCall
NextToolCall int
Step int
MaxSteps int
ToolID int
ToolUpdatedAt time.Time
CreatedAt time.Time
ExpiresAt time.Time
// Approvals is off for customer-facing runs: no agent is present to approve a tool call.
Approvals bool
}
func (m *Manager) ResumeAgentRun(ctx context.Context, runID string, agentID int, approved bool) (models.AgentRunResult, AgentRunScope, error) {
run, err := m.claimAgentRun(runID, agentID)
if err != nil {
return models.AgentRunResult{}, AgentRunScope{}, err
}
scope := run.Scope
toolCall := run.ToolCalls[run.NextToolCall]
result := "The agent declined this tool call."
m.lo.Info("agent reviewed custom tool call", "run_id", runID, "agent_id", agentID, "conversation_uuid", run.Scope.ConversationUUID, "surface", run.Scope.Surface, "tool", toolCall.Function.Name, "approved", approved)
if approved {
if err := m.validateApprovedTool(run); err != nil {
return models.AgentRunResult{}, scope, err
}
result = m.executeToolCall(ctx, run.Registry, toolCall)
}
m.appendToolResult(run, toolCall, result)
run.NextToolCall++
response, err := m.continueAgentRun(ctx, run)
if err != nil {
m.lo.Error("error completing agent run after tool decision", "run_id", runID, "agent_id", agentID, "tool", toolCall.Function.Name, "approved", approved, "error", err)
messageKey := "ai.toolDeclinedResponseFailed"
if approved {
messageKey = "ai.toolRanResponseFailed"
}
return completedAgentRun(m.i18n.T(messageKey)), scope, nil
}
return response, scope, err
}
func (m *Manager) PendingAgentRunScope(runID string, agentID int) (AgentRunScope, error) {
m.pendingRunMu.Lock()
defer m.pendingRunMu.Unlock()
m.deleteExpiredAgentRunsLocked(time.Now())
run, ok := m.pendingRuns[runID]
if !ok {
return AgentRunScope{}, envelope.NewError(envelope.NotFoundError, m.i18n.T("ai.toolApprovalExpired"), nil)
}
if run.Scope.AgentID != agentID {
return AgentRunScope{}, envelope.NewError(envelope.PermissionError, m.i18n.T("ai.toolApprovalUnavailable"), nil)
}
return run.Scope, nil
}
func (m *Manager) PendingAgentApproval(scope AgentRunScope) (string, *models.ToolApproval) {
m.pendingRunMu.Lock()
defer m.pendingRunMu.Unlock()
m.deleteExpiredAgentRunsLocked(time.Now())
var newest *pendingAgentRun
for _, run := range m.pendingRuns {
if !sameAgentRunScope(run.Scope, scope) || (newest != nil && !run.CreatedAt.After(newest.CreatedAt)) {
continue
}
newest = run
}
if newest == nil {
return "", nil
}
return newest.Scope.UserMessage, agentRunApproval(newest)
}
func (m *Manager) ClearPendingAgentRuns(scope AgentRunScope) {
m.pendingRunMu.Lock()
defer m.pendingRunMu.Unlock()
for id, run := range m.pendingRuns {
if sameAgentRunScope(run.Scope, scope) {
delete(m.pendingRuns, id)
}
}
}
func (m *Manager) continueAgentRun(ctx context.Context, run *pendingAgentRun) (models.AgentRunResult, error) {
if result := m.processAgentToolCalls(ctx, run); result != nil {
return *result, nil
}
for run.Step < run.MaxSteps {
m.lo.Debug("ai run step", "step", run.Step, "messages", len(run.Messages))
res, err := m.chatCompletion(ctx, run.Client, models.ChatCompletionPayload{Messages: run.Messages, Tools: run.Definitions})
if err != nil {
return models.AgentRunResult{}, err
}
m.lo.Debug("ai run model response", "step", run.Step, "content_len", len(res.Content), "tool_calls", len(res.ToolCalls), "content", res.Content, "prompt_tokens", res.Usage.PromptTokens, "completion_tokens", res.Usage.CompletionTokens)
if len(res.ToolCalls) == 0 {
m.lo.Debug("ai run final answer", "answer", res.Content)
return completedAgentRun(res.Content), nil
}
run.Messages = append(run.Messages, models.ChatMessage{Role: models.RoleAssistant, Content: res.Content, ToolCalls: res.ToolCalls})
run.ToolCalls = res.ToolCalls
run.NextToolCall = 0
run.Step++
if result := m.processAgentToolCalls(ctx, run); result != nil {
return *result, nil
}
}
res, err := m.chatCompletion(ctx, run.Client, models.ChatCompletionPayload{Messages: run.Messages})
if err != nil {
return models.AgentRunResult{}, err
}
if strings.TrimSpace(res.Content) == "" {
m.lo.Warn("agent produced no answer within the step budget", "max_steps", run.MaxSteps)
return models.AgentRunResult{}, envelope.NewError(envelope.GeneralError, m.i18n.T("ai.noAnswerWithinSteps"), nil)
}
m.lo.Debug("ai run final answer", "answer", res.Content, "forced", true)
return completedAgentRun(res.Content), nil
}
func (m *Manager) processAgentToolCalls(ctx context.Context, run *pendingAgentRun) *models.AgentRunResult {
for run.NextToolCall < len(run.ToolCalls) {
toolCall := run.ToolCalls[run.NextToolCall]
if run.Approvals {
if customTool, ok := run.Registry[toolCall.Function.Name].(approvalTool); ok && customTool.approvalRequired() {
run.ID = uuid.NewString()
run.ToolID = customTool.toolID()
run.ToolUpdatedAt = customTool.toolUpdatedAt()
run.CreatedAt = time.Now()
run.ExpiresAt = run.CreatedAt.Add(pendingAgentRunTTL)
m.storeAgentRun(run)
return &models.AgentRunResult{Status: models.AgentRunApprovalRequired, Approval: agentRunApproval(run)}
}
}
m.appendToolResult(run, toolCall, m.executeToolCall(ctx, run.Registry, toolCall))
run.NextToolCall++
}
run.ToolCalls = nil
return nil
}
func (m *Manager) appendToolResult(run *pendingAgentRun, toolCall models.ToolCall, result string) {
run.Messages = append(run.Messages, models.ChatMessage{
Role: models.RoleTool,
ToolCallID: toolCall.ID,
Name: toolCall.Function.Name,
Content: result,
})
}
func (m *Manager) storeAgentRun(run *pendingAgentRun) {
m.pendingRunMu.Lock()
defer m.pendingRunMu.Unlock()
m.deleteExpiredAgentRunsLocked(time.Now())
m.pendingRuns[run.ID] = run
}
func (m *Manager) claimAgentRun(runID string, agentID int) (*pendingAgentRun, error) {
m.pendingRunMu.Lock()
defer m.pendingRunMu.Unlock()
now := time.Now()
m.deleteExpiredAgentRunsLocked(now)
run, ok := m.pendingRuns[runID]
if !ok {
return nil, envelope.NewError(envelope.NotFoundError, m.i18n.T("ai.toolApprovalExpired"), nil)
}
if run.Scope.AgentID != agentID {
return nil, envelope.NewError(envelope.PermissionError, m.i18n.T("ai.toolApprovalUnavailable"), nil)
}
delete(m.pendingRuns, runID)
return run, nil
}
func (m *Manager) validateApprovedTool(run *pendingAgentRun) error {
tool, err := m.GetTool(run.ToolID)
if err != nil || !tool.Enabled || tool.Name != run.ToolCalls[run.NextToolCall].Function.Name || !tool.UpdatedAt.Equal(run.ToolUpdatedAt) {
return envelope.NewError(envelope.ConflictError, m.i18n.T("ai.toolChangedBeforeApproval"), nil)
}
if run.Scope.Surface == models.ToolInvocationCopilot && !tool.CopilotEnabled {
return envelope.NewError(envelope.ConflictError, m.i18n.T("ai.toolChangedBeforeApproval"), nil)
}
if run.Scope.Surface == models.ToolInvocationReply && !tool.GenerateReplyEnabled {
return envelope.NewError(envelope.ConflictError, m.i18n.T("ai.toolChangedBeforeApproval"), nil)
}
return nil
}
func (m *Manager) deleteExpiredAgentRunsLocked(now time.Time) {
for id, run := range m.pendingRuns {
if !run.ExpiresAt.After(now) {
delete(m.pendingRuns, id)
}
}
}
func completedAgentRun(content string) models.AgentRunResult {
return models.AgentRunResult{Status: models.AgentRunCompleted, Content: stripCodeFence(content)}
}
func agentRunApproval(run *pendingAgentRun) *models.ToolApproval {
toolCall := run.ToolCalls[run.NextToolCall]
return &models.ToolApproval{RunID: run.ID, ToolName: toolCall.Function.Name, Arguments: toolCall.Function.Arguments}
}
func sameAgentRunScope(a, b AgentRunScope) bool {
return a.AgentID == b.AgentID && a.ConversationID == b.ConversationID && a.ConversationUUID == b.ConversationUUID && a.Surface == b.Surface
}
+131
View File
@@ -0,0 +1,131 @@
package ai
import (
"context"
"errors"
"testing"
"time"
"github.com/abhinavxd/libredesk/internal/ai/models"
"github.com/jmoiron/sqlx/types"
)
type approvalTestProvider struct {
responses []models.ChatCompletionResult
calls int
failAt int
}
type approvalTestTool struct {
executions int
}
func (p *approvalTestProvider) SendPrompt(context.Context, models.PromptPayload) (string, error) {
return "", nil
}
func (p *approvalTestProvider) SendChatCompletion(context.Context, models.ChatCompletionPayload) (models.ChatCompletionResult, error) {
p.calls++
if p.calls == p.failAt {
return models.ChatCompletionResult{}, errors.New("provider unavailable")
}
response := p.responses[0]
p.responses = p.responses[1:]
return response, nil
}
func (p *approvalTestProvider) GetEmbeddings(context.Context, string) ([]float32, error) {
return nil, nil
}
func (p *approvalTestProvider) GetEmbeddingsBatch(context.Context, []string) ([][]float32, error) {
return nil, nil
}
func (t *approvalTestTool) Name() string { return "update_balance" }
func (t *approvalTestTool) Description() string { return "Updates a balance." }
func (t *approvalTestTool) Parameters() types.JSONText { return types.JSONText(`{"type":"object"}`) }
func (t *approvalTestTool) Execute(context.Context, string) (string, error) {
t.executions++
return "updated", nil
}
func TestAgentToolDeclineResumesWithoutExecution(t *testing.T) {
m := newTestManager(t)
tool := &approvalTestTool{}
provider := &approvalTestProvider{responses: []models.ChatCompletionResult{
{ToolCalls: []models.ToolCall{{ID: "call-1", Type: "function", Function: models.ToolCallFunction{Name: tool.Name(), Arguments: `{"amount":100}`}}}},
{Content: "No changes were made."},
}}
run := &pendingAgentRun{
Scope: AgentRunScope{AgentID: 7, ConversationID: 9, ConversationUUID: "conversation-1", Surface: models.ToolInvocationCopilot},
Client: provider,
Registry: map[string]Tool{tool.Name(): tool},
MaxSteps: 5,
Approvals: true,
}
result, err := m.continueAgentRun(t.Context(), run)
if err != nil {
t.Fatalf("continue run: %v", err)
}
if result.Status != models.AgentRunApprovalRequired || result.Approval == nil {
t.Fatalf("expected approval request, got %#v", result)
}
if tool.executions != 0 {
t.Fatalf("tool executed before approval: %d", tool.executions)
}
result, _, err = m.ResumeAgentRun(t.Context(), result.Approval.RunID, 7, false)
if err != nil {
t.Fatalf("decline run: %v", err)
}
if result.Status != models.AgentRunCompleted || result.Content != "No changes were made." {
t.Fatalf("unexpected completed result: %#v", result)
}
if tool.executions != 0 {
t.Fatalf("declined tool executed: %d", tool.executions)
}
if _, _, err := m.ResumeAgentRun(t.Context(), run.ID, 7, false); err == nil {
t.Fatal("expected a second decision to fail")
}
}
func TestAgentToolDecisionIsFinalWhenFollowUpFails(t *testing.T) {
m := newTestManager(t)
tool := &approvalTestTool{}
provider := &approvalTestProvider{
responses: []models.ChatCompletionResult{
{ToolCalls: []models.ToolCall{{ID: "call-1", Type: "function", Function: models.ToolCallFunction{Name: tool.Name(), Arguments: `{}`}}}},
},
failAt: 2,
}
run := &pendingAgentRun{
Scope: AgentRunScope{AgentID: 7, ConversationID: 9, ConversationUUID: "conversation-1", Surface: models.ToolInvocationCopilot},
Client: provider,
Registry: map[string]Tool{tool.Name(): tool},
MaxSteps: 5,
Approvals: true,
}
result, err := m.continueAgentRun(t.Context(), run)
if err != nil {
t.Fatalf("continue run: %v", err)
}
result, _, err = m.ResumeAgentRun(t.Context(), result.Approval.RunID, 7, false)
if err != nil {
t.Fatalf("decline run: %v", err)
}
if result.Status != models.AgentRunCompleted {
t.Fatalf("expected a final result, got %#v", result)
}
}
func (t *approvalTestTool) approvalRequired() bool { return true }
func (t *approvalTestTool) toolID() int { return 1 }
func (t *approvalTestTool) toolUpdatedAt() time.Time { return time.Unix(1, 0) }
+10 -5
View File
@@ -60,11 +60,13 @@ type Manager struct {
chunkCfg stringutil.ChunkConfig
index *embeddingIndex
// indexReady is closed once the boot-time index load finishes; Search blocks on it.
indexReady chan struct{}
reindexMu sync.Mutex
reconcileMu sync.Mutex
genMu sync.Mutex
gen map[genKey]uint64
indexReady chan struct{}
reindexMu sync.Mutex
reconcileMu sync.Mutex
genMu sync.Mutex
gen map[genKey]uint64
pendingRunMu sync.Mutex
pendingRuns map[string]*pendingAgentRun
// tagGen is bumped on every tag vector purge; an in-flight tag reindex only commits if its gen is still current.
tagGen atomic.Uint64
// embedSem caps concurrent background embed jobs.
@@ -123,6 +125,8 @@ type queries struct {
GetTools *sqlx.Stmt `query:"get-tools"`
GetTool *sqlx.Stmt `query:"get-tool"`
GetEnabledToolsByIDs *sqlx.Stmt `query:"get-enabled-tools-by-ids"`
GetEnabledCopilotToolIDs *sqlx.Stmt `query:"get-enabled-copilot-tool-ids"`
GetEnabledGenerateToolIDs *sqlx.Stmt `query:"get-enabled-generate-reply-tool-ids"`
GetToolAuth *sqlx.Stmt `query:"get-tool-auth"`
InsertTool *sqlx.Stmt `query:"insert-tool"`
UpdateTool *sqlx.Stmt `query:"update-tool"`
@@ -150,6 +154,7 @@ func New(opts Opts) (*Manager, error) {
index: newEmbeddingIndex(),
indexReady: make(chan struct{}),
gen: make(map[genKey]uint64),
pendingRuns: make(map[string]*pendingAgentRun),
embedSem: make(chan struct{}, maxConcurrentEmbeds),
httpClient: &http.Client{
Timeout: 20 * time.Second,
+4 -4
View File
@@ -41,7 +41,7 @@ To answer questions about the customer's history or other tickets, use the tools
)
// GenerateReply drafts a reply to a conversation using the agentic loop (tools included).
func (m *Manager) GenerateReply(ctx context.Context, transcript, instruction string, tctx ToolContext, extra []Tool) (string, error) {
func (m *Manager) GenerateReply(ctx context.Context, transcript, instruction string, tctx ToolContext, extra []Tool, allowedToolIDs []int, scope AgentRunScope) (models.AgentRunResult, error) {
var user strings.Builder
if strings.TrimSpace(transcript) != "" {
fmt.Fprintf(&user, "Conversation so far:\n%s\n\n", transcript)
@@ -56,11 +56,11 @@ func (m *Manager) GenerateReply(ctx context.Context, transcript, instruction str
if len(extra) > 0 {
systemPrompt += "\n\n" + replyDraftHistoryToolsPrompt
}
return m.RunAgentWithTools(ctx, systemPrompt, history, defaultMaxSteps, tctx, nil, true, true, extra)
return m.RunAgentWithApprovals(ctx, systemPrompt, history, defaultMaxSteps, tctx, allowedToolIDs, extra, scope)
}
// Copilot answers an agent's chat message; persona borrows an assistant's voice without changing the tool set.
func (m *Manager) Copilot(ctx context.Context, conversationContext string, history []models.ChatMessage, tctx ToolContext, extra []Tool, persona string) (string, error) {
func (m *Manager) Copilot(ctx context.Context, conversationContext string, history []models.ChatMessage, tctx ToolContext, extra []Tool, persona string, allowedToolIDs []int, scope AgentRunScope) (models.AgentRunResult, error) {
msgs := make([]models.ChatMessage, 0, len(history)+1)
if strings.TrimSpace(conversationContext) != "" {
msgs = append(msgs, models.ChatMessage{
@@ -78,7 +78,7 @@ func (m *Manager) Copilot(ctx context.Context, conversationContext string, histo
if strings.TrimSpace(persona) != "" {
systemPrompt += "\n\nPersona from the selected assistant - apply it to how you write, but keep the tools and rules above unchanged:\n" + persona
}
return m.RunAgentWithTools(ctx, systemPrompt, msgs, defaultMaxSteps, tctx, nil, true, true, extra)
return m.RunAgentWithApprovals(ctx, systemPrompt, msgs, defaultMaxSteps, tctx, allowedToolIDs, extra, scope)
}
// Summarize produces a short handover summary of a conversation transcript.
+36 -12
View File
@@ -15,6 +15,8 @@ const (
RoleUser = "user"
RoleAssistant = "assistant"
RoleTool = "tool"
// RoleApproval is a synthetic copilot turn carrying a pending tool approval; it is never persisted.
RoleApproval = "approval"
ProviderTypeCompletion = "completion"
ProviderTypeEmbedding = "embedding"
@@ -30,6 +32,12 @@ const (
KnowledgeSourceManual = "manual"
KnowledgeSourceConversation = "conversation"
KnowledgeSourceURL = "url"
ToolInvocationCopilot = "copilot"
ToolInvocationReply = "generate_reply"
AgentRunCompleted = "completed"
AgentRunApprovalRequired = "approval_required"
)
// Provider is a row in ai_providers (one per type: completion / embedding).
@@ -102,20 +110,35 @@ type HelpArticleItem struct {
// Tool is a custom, admin-defined HTTP tool the assistant can call.
type Tool struct {
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Name string `db:"name" json:"name"`
Description string `db:"description" json:"description"`
URL string `db:"url" json:"url"`
Method string `db:"method" json:"method"`
Auth types.JSONText `db:"auth" json:"auth"`
Parameters types.JSONText `db:"parameters" json:"parameters"`
Enabled bool `db:"enabled" json:"enabled"`
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Name string `db:"name" json:"name"`
Description string `db:"description" json:"description"`
URL string `db:"url" json:"url"`
Method string `db:"method" json:"method"`
Auth types.JSONText `db:"auth" json:"auth"`
Parameters types.JSONText `db:"parameters" json:"parameters"`
Enabled bool `db:"enabled" json:"enabled"`
CopilotEnabled bool `db:"copilot_enabled" json:"copilot_enabled"`
GenerateReplyEnabled bool `db:"generate_reply_enabled" json:"generate_reply_enabled"`
RequiresAgentApproval bool `db:"requires_agent_approval" json:"requires_agent_approval"`
// RequiresVerification fails closed: a flagged tool is blocked until the contact is verified for the conversation.
RequiresVerification bool `db:"requires_verification" json:"requires_verification"`
}
type ToolApproval struct {
RunID string `json:"run_id"`
ToolName string `json:"tool_name"`
Arguments string `json:"arguments"`
}
type AgentRunResult struct {
Status string `json:"status"`
Content string `json:"content,omitempty"`
Approval *ToolApproval `json:"approval,omitempty"`
}
// ToolAuth is the decoded ai_tools.auth JSONB: headers injected on every request.
type ToolAuth struct {
Headers []ToolAuthHeader `json:"headers"`
@@ -171,8 +194,9 @@ type ChatImage struct {
// CopilotMessage is one persisted turn of an agent's copilot chat on a conversation.
type CopilotMessage struct {
Role string `db:"role" json:"role"`
Content string `db:"content" json:"content"`
Role string `db:"role" json:"role"`
Content string `db:"content" json:"content"`
Approval *ToolApproval `db:"-" json:"approval,omitempty"`
}
// MarshalJSON emits plain string content when there are no images, or the OpenAI multimodal
+13 -7
View File
@@ -57,7 +57,7 @@ WITH RECURSIVE published_collections AS (
SELECT a.id, a.title, a.content, a.status, a.ai_enabled, a.embedded_fingerprint,
a.collection_id IN (SELECT id FROM published_collections) AS is_reachable
FROM help_articles a
WHERE (a.status = 'published' AND a.ai_enabled) OR a.embedded_fingerprint <> '';
WHERE (a.status = 'published' AND a.ai_enabled) OR a.embedded_fingerprint != '';
-- name: get-embeddable-help-article
WITH RECURSIVE published_collections AS (
@@ -103,23 +103,29 @@ SELECT id, name FROM tags ORDER BY id;
SELECT id, source_type, source_id, chunk_text, embedding, dimensions FROM embeddings;
-- name: get-tools
SELECT id, created_at, updated_at, name, description, url, method, auth, parameters, enabled, requires_verification FROM ai_tools ORDER BY updated_at DESC;
SELECT id, created_at, updated_at, name, description, url, method, auth, parameters, enabled, requires_verification, copilot_enabled, generate_reply_enabled, requires_agent_approval FROM ai_tools ORDER BY updated_at DESC;
-- name: get-enabled-tools-by-ids
SELECT id, created_at, updated_at, name, description, url, method, auth, parameters, enabled, requires_verification FROM ai_tools WHERE enabled = true AND id = ANY($1);
SELECT id, created_at, updated_at, name, description, url, method, auth, parameters, enabled, requires_verification, copilot_enabled, generate_reply_enabled, requires_agent_approval FROM ai_tools WHERE enabled = true AND id = ANY($1);
-- name: get-enabled-copilot-tool-ids
SELECT id FROM ai_tools WHERE enabled = true AND copilot_enabled = true ORDER BY id;
-- name: get-enabled-generate-reply-tool-ids
SELECT id FROM ai_tools WHERE enabled = true AND generate_reply_enabled = true ORDER BY id;
-- name: get-tool
SELECT id, created_at, updated_at, name, description, url, method, auth, parameters, enabled, requires_verification FROM ai_tools WHERE id = $1;
SELECT id, created_at, updated_at, name, description, url, method, auth, parameters, enabled, requires_verification, copilot_enabled, generate_reply_enabled, requires_agent_approval FROM ai_tools WHERE id = $1;
-- name: get-tool-auth
SELECT auth FROM ai_tools WHERE id = $1;
-- name: insert-tool
INSERT INTO ai_tools (name, description, url, method, auth, parameters, enabled, requires_verification)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *;
INSERT INTO ai_tools (name, description, url, method, auth, parameters, enabled, requires_verification, copilot_enabled, generate_reply_enabled, requires_agent_approval)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *;
-- name: update-tool
UPDATE ai_tools SET name = $2, description = $3, url = $4, method = $5, auth = $6, parameters = $7, enabled = $8, requires_verification = $9, updated_at = now()
UPDATE ai_tools SET name = $2, description = $3, url = $4, method = $5, auth = $6, parameters = $7, enabled = $8, requires_verification = $9, copilot_enabled = $10, generate_reply_enabled = $11, requires_agent_approval = $12, updated_at = now()
WHERE id = $1 RETURNING *;
-- name: delete-tool
+7 -1
View File
@@ -11,6 +11,7 @@ import (
"regexp"
"strconv"
"strings"
"time"
"github.com/abhinavxd/libredesk/internal/ai/models"
"github.com/abhinavxd/libredesk/internal/crypto"
@@ -175,6 +176,12 @@ func (t *httpTool) Parameters() types.JSONText {
return t.tool.Parameters
}
func (t *httpTool) approvalRequired() bool { return t.tool.RequiresAgentApproval }
func (t *httpTool) toolID() int { return t.tool.ID }
func (t *httpTool) toolUpdatedAt() time.Time { return t.tool.UpdatedAt }
func (t *httpTool) Execute(ctx context.Context, args string) (string, error) {
if t.tool.RequiresVerification && !t.tctx.verified() {
t.lo.Debug("custom tool blocked, contact not verified", "tool", t.tool.Name)
@@ -260,7 +267,6 @@ func (t *httpTool) Execute(ctx context.Context, args string) (string, error) {
if t.tctx.InboxID != 0 {
req.Header.Set("X-Libredesk-Inbox-Id", strconv.Itoa(t.tctx.InboxID))
}
resp, err := t.client.Do(req)
if err != nil {
t.lo.Error("error calling custom tool", "tool", t.tool.Name, "error", err)
+20 -2
View File
@@ -53,6 +53,24 @@ func (m *Manager) GetEnabledToolsByIDs(ids []int) ([]models.Tool, error) {
return tools, nil
}
func (m *Manager) GetEnabledCopilotToolIDs() ([]int, error) {
ids := make([]int, 0)
if err := m.q.GetEnabledCopilotToolIDs.Select(&ids); err != nil {
m.lo.Error("error fetching Copilot tool ids", "error", err)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
return ids, nil
}
func (m *Manager) GetEnabledGenerateReplyToolIDs() ([]int, error) {
ids := make([]int, 0)
if err := m.q.GetEnabledGenerateToolIDs.Select(&ids); err != nil {
m.lo.Error("error fetching Generate Reply tool ids", "error", err)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
return ids, nil
}
func (m *Manager) CreateTool(t models.Tool) (models.Tool, error) {
if err := m.validateToolInput(&t); err != nil {
return t, err
@@ -63,7 +81,7 @@ func (m *Manager) CreateTool(t models.Tool) (models.Tool, error) {
}
params := toolParametersOrEmpty(t)
var created models.Tool
if err := m.q.InsertTool.Get(&created, t.Name, t.Description, t.URL, t.Method, auth, params, t.Enabled, t.RequiresVerification); err != nil {
if err := m.q.InsertTool.Get(&created, t.Name, t.Description, t.URL, t.Method, auth, params, t.Enabled, t.RequiresVerification, t.CopilotEnabled, t.GenerateReplyEnabled, t.RequiresAgentApproval); err != nil {
m.lo.Error("error creating tool", "error", err)
return created, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
@@ -89,7 +107,7 @@ func (m *Manager) UpdateTool(id int, t models.Tool) (models.Tool, error) {
}
params := toolParametersOrEmpty(t)
var updated models.Tool
if err := m.q.UpdateTool.Get(&updated, id, t.Name, t.Description, t.URL, t.Method, auth, params, t.Enabled, t.RequiresVerification); err != nil {
if err := m.q.UpdateTool.Get(&updated, id, t.Name, t.Description, t.URL, t.Method, auth, params, t.Enabled, t.RequiresVerification, t.CopilotEnabled, t.GenerateReplyEnabled, t.RequiresAgentApproval); err != nil {
if err == sql.ErrNoRows {
return updated, envelope.NewError(envelope.NotFoundError, m.i18n.T("globals.messages.notFound"), nil)
}
+1 -1
View File
@@ -23,7 +23,7 @@ func newTestManager(t *testing.T) *Manager {
t.Fatalf("i18n init: %v", err)
}
lo := logf.New(logf.Opts{})
return &Manager{lo: &lo, i18n: tr, encryptionKey: testEncryptionKey}
return &Manager{lo: &lo, i18n: tr, encryptionKey: testEncryptionKey, pendingRuns: make(map[string]*pendingAgentRun)}
}
func decodeToolAuth(t *testing.T, raw types.JSONText) models.ToolAuth {
+9
View File
@@ -20,6 +20,15 @@ var notificationTypesV2_9_0 = []string{
}
func V2_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
if _, err := db.Exec(`ALTER TABLE ai_tools ADD COLUMN IF NOT EXISTS copilot_enabled BOOLEAN NOT NULL DEFAULT false;`); err != nil {
return err
}
if _, err := db.Exec(`ALTER TABLE ai_tools ADD COLUMN IF NOT EXISTS generate_reply_enabled BOOLEAN NOT NULL DEFAULT false;`); err != nil {
return err
}
if _, err := db.Exec(`ALTER TABLE ai_tools ADD COLUMN IF NOT EXISTS requires_agent_approval BOOLEAN NOT NULL DEFAULT true;`); err != nil {
return err
}
if _, err := db.Exec(`
UPDATE roles
SET permissions = array_append(permissions, 'messages:write_private')
+3
View File
@@ -775,6 +775,9 @@ CREATE TABLE ai_tools (
parameters JSONB NOT NULL DEFAULT '{}',
enabled BOOLEAN NOT NULL DEFAULT true,
requires_verification BOOLEAN NOT NULL DEFAULT true,
copilot_enabled BOOLEAN NOT NULL DEFAULT false,
generate_reply_enabled BOOLEAN NOT NULL DEFAULT false,
requires_agent_approval BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT constraint_ai_tools_on_name CHECK (name ~ '^[a-zA-Z0-9_-]+$' AND length(name) <= 64)
);