mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-10-26 06:19:36 +00:00
merge main
This commit is contained in:
@@ -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,30 @@ 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{}
|
||||
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
|
||||
app.ai.ClearPendingAgentRuns(scope)
|
||||
}
|
||||
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 +499,46 @@ 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}
|
||||
app.ai.ClearPendingAgentRuns(scope)
|
||||
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 +563,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 +588,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 +613,54 @@ 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 },
|
||||
// The agent picked this conversation and reviews the call before it runs, so the customer's
|
||||
// own OTP state does not gate an agent-initiated lookup.
|
||||
Verified: func() bool { return true },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,6 +287,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))
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
@@ -821,6 +825,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('globals.terms.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,30 @@
|
||||
<template>
|
||||
<AlertDialog
|
||||
:open="!!pendingToolApproval && pendingToolConversationUUID === currentConversationUUID"
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{{ $t('ai.toolApprovalTitle') }}</AlertDialogTitle>
|
||||
<AlertDialogDescription as="div">
|
||||
<ToolApprovalDetails v-if="pendingToolApproval" :approval="pendingToolApproval" />
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="isGenerating"
|
||||
@click="resolveGenerateToolApproval(false)"
|
||||
>
|
||||
{{ $t('globals.messages.decline') }}
|
||||
</Button>
|
||||
<Button type="button" :disabled="isGenerating" @click="resolveGenerateToolApproval(true)">
|
||||
{{ $t('globals.messages.approveAndRun') }}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog :open="showContactEmailWarning" @update:open="showContactEmailWarning = $event">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
@@ -95,7 +121,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"
|
||||
@@ -169,6 +199,7 @@ import {
|
||||
} from '@shared-ui/components/ui/alert-dialog'
|
||||
import { Dialog, DialogContent } from '@shared-ui/components/ui/dialog'
|
||||
import { Button } from '@shared-ui/components/ui/button'
|
||||
import ToolApprovalDetails from '@/features/conversation/ToolApprovalDetails.vue'
|
||||
import { Pencil, Paperclip } from 'lucide-vue-next'
|
||||
import { useVisualViewportHeight } from '@main/composables/useVisualViewportHeight'
|
||||
import { useIsComposerCramped } from '@main/composables/useIsComposerCramped'
|
||||
@@ -257,18 +288,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 +330,39 @@ 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 ([403, 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +409,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 +425,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 +600,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 }
|
||||
)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<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"
|
||||
>{{ approval.tool_name }}</code
|
||||
></template
|
||||
>
|
||||
</i18n-t>
|
||||
</p>
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-medium text-foreground">{{ $t('globals.terms.arguments') }}</p>
|
||||
<pre
|
||||
class="max-h-60 overflow-auto whitespace-pre-wrap rounded-md bg-muted p-3 text-xs text-foreground [overflow-wrap:anywhere]"
|
||||
>{{ formattedArguments }}</pre
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
approval: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const formattedArguments = computed(() => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(props.approval.arguments), null, 2)
|
||||
} catch {
|
||||
return props.approval.arguments || ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -31,8 +33,12 @@
|
||||
</Button>
|
||||
</div>
|
||||
<div ref="scrollRef" class="flex-1 overflow-y-auto p-4 space-y-4 min-h-0">
|
||||
<div v-if="isHydrating" class="h-full flex items-center justify-center text-muted-foreground">
|
||||
<DotLoader />
|
||||
<span class="sr-only">{{ $t('globals.terms.loading') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="messages.length === 0"
|
||||
v-else-if="messages.length === 0"
|
||||
class="h-full flex flex-col items-center justify-center gap-4 text-center px-4"
|
||||
>
|
||||
<div class="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
@@ -75,6 +81,36 @@
|
||||
: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')"
|
||||
>
|
||||
<p class="text-sm font-medium">{{ $t('ai.toolApprovalTitle') }}</p>
|
||||
<ToolApprovalDetails :approval="msg.approval" />
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="isThinking"
|
||||
@click="resolveToolApproval(msg.approval, false)"
|
||||
>
|
||||
{{ $t('globals.messages.decline') }}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
:disabled="isThinking"
|
||||
@click="resolveToolApproval(msg.approval, true)"
|
||||
>
|
||||
{{ $t('globals.messages.approveAndRun') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg px-3 py-2 text-sm [overflow-wrap:anywhere]"
|
||||
:class="
|
||||
msg.isUser
|
||||
@@ -85,7 +121,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
|
||||
@@ -163,6 +199,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, onMounted, watch } from 'vue'
|
||||
import { Button } from '@shared-ui/components/ui/button'
|
||||
import ToolApprovalDetails from '@/features/conversation/ToolApprovalDetails.vue'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -198,10 +235,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.
|
||||
@@ -214,6 +248,7 @@ const input = ref('')
|
||||
// Thinking state is per conversation so an in-flight send for one conversation does not show or
|
||||
// block the panel after the agent switches to another.
|
||||
const thinkingByUUID = ref({})
|
||||
const isHydrating = ref(true)
|
||||
const isThinking = computed(() => !!thinkingByUUID.value[conversationStore.current?.uuid || ''])
|
||||
const scrollRef = ref(null)
|
||||
|
||||
@@ -264,18 +299,33 @@ const clearChat = async () => {
|
||||
// Load the persisted chat from the server when a conversation opens, so a refresh
|
||||
// does not lose it. Skip if the store already has messages for it (a live session).
|
||||
const hydrate = async (uuid) => {
|
||||
if (!uuid || copilotStore.getMessages(uuid).length > 0) return
|
||||
if (!uuid) return
|
||||
if (copilotStore.getMessages(uuid).length > 0) {
|
||||
isHydrating.value = false
|
||||
return
|
||||
}
|
||||
const rev = revision(uuid)
|
||||
let staleHydrate = false
|
||||
isHydrating.value = true
|
||||
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 }))
|
||||
if (rev !== revision(uuid) || copilotStore.getMessages(uuid).length > 0) {
|
||||
staleHydrate = true
|
||||
return
|
||||
}
|
||||
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()
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: the panel still works without history.
|
||||
} finally {
|
||||
if (!staleHydrate) isHydrating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,7 +350,10 @@ const send = async (preset) => {
|
||||
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).filter((message) => !message.approval),
|
||||
{ role: 'user', content: text }
|
||||
])
|
||||
input.value = ''
|
||||
thinkingByUUID.value[uuid] = true
|
||||
await scrollToBottom()
|
||||
@@ -310,10 +363,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 +385,46 @@ 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 ([403, 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 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
-1
@@ -182,12 +182,16 @@
|
||||
"admin.ai.tool.deleteConfirmation": "This will permanently delete the tool.",
|
||||
"admin.ai.tool.reservedName": "This name is reserved for a built-in tool. Choose a different name.",
|
||||
"admin.ai.tool.requireVerification": "Require verified contact",
|
||||
"admin.ai.tool.requireVerificationHint": "The assistant verifies the customer's identity by emailing them a one-time code before this tool can run.",
|
||||
"admin.ai.tool.requireVerificationHint": "The assistant emails the customer a one-time code and runs this tool only after they enter it. Copilot and Generate Reply run it without the code, since a human agent asks for it there.",
|
||||
"admin.ai.tool.turnOffVerificationTitle": "Turn off customer authentication?",
|
||||
"admin.ai.tool.turnOffVerificationBody": "With verification on, Libredesk emails the customer a one-time code and runs this tool only after they enter it. Turning it off lets anyone in the chat trigger the tool.",
|
||||
"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.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. Keep this on for tools that change data. Read-only lookups can run without it.",
|
||||
"admin.ai.snippet.new": "New snippet",
|
||||
"admin.ai.snippet.edit": "Edit snippet",
|
||||
"admin.ai.snippet.deleteConfirmation": "This will permanently delete the snippet.",
|
||||
@@ -616,6 +620,14 @@
|
||||
"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. The AI cannot run again until it is reviewed. Approve or decline the pending request first.",
|
||||
"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.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.",
|
||||
"auth.backToLogin": "Back to login",
|
||||
"auth.checkEmailForReset": "Check your email for the password reset link.",
|
||||
"auth.confirmPassword": "Confirm password",
|
||||
@@ -841,6 +853,7 @@
|
||||
"globals.messages.additionalFeedback": "Additional feedback (optional)",
|
||||
"globals.messages.all": "All",
|
||||
"globals.messages.apply": "Apply",
|
||||
"globals.messages.approveAndRun": "Approve and run",
|
||||
"globals.messages.areYouAbsolutelySure": "Are you absolutely sure?",
|
||||
"globals.messages.attachFile": "Attach file",
|
||||
"globals.messages.back": "Back",
|
||||
@@ -873,6 +886,7 @@
|
||||
"globals.messages.delete": "Delete",
|
||||
"globals.messages.deleteAll": "Delete all",
|
||||
"globals.messages.deletedSuccessfully": "Deleted successfully",
|
||||
"globals.messages.decline": "Decline",
|
||||
"globals.messages.disable": "Disable",
|
||||
"globals.messages.edit": "Edit",
|
||||
"globals.messages.empty": "{name} empty",
|
||||
@@ -975,10 +989,12 @@
|
||||
"globals.terms.actor": "Actor | Actors",
|
||||
"globals.terms.admin": "Admin | Admins",
|
||||
"globals.terms.agent": "Agent | Agents",
|
||||
"globals.terms.agentAccess": "Agent access",
|
||||
"globals.terms.alert": "Alert | Alerts",
|
||||
"globals.terms.announcement": "Announcement | Announcements",
|
||||
"globals.terms.apiKey": "API key | API keys",
|
||||
"globals.terms.appliesTo": "Applies to",
|
||||
"globals.terms.arguments": "Arguments",
|
||||
"globals.terms.article": "Article | Articles",
|
||||
"globals.terms.ascending": "Ascending",
|
||||
"globals.terms.assignedTeam": "Assigned team",
|
||||
|
||||
+33
-45
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
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) {
|
||||
result, err := m.processAgentToolCalls(ctx, run)
|
||||
if err != nil {
|
||||
return models.AgentRunResult{}, err
|
||||
}
|
||||
if 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++
|
||||
result, err := m.processAgentToolCalls(ctx, run)
|
||||
if err != nil {
|
||||
return models.AgentRunResult{}, err
|
||||
}
|
||||
if 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, error) {
|
||||
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)
|
||||
if err := m.storeAgentRun(run); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &models.AgentRunResult{Status: models.AgentRunApprovalRequired, Approval: agentRunApproval(run)}, nil
|
||||
}
|
||||
}
|
||||
m.appendToolResult(run, toolCall, m.executeToolCall(ctx, run.Registry, toolCall))
|
||||
run.NextToolCall++
|
||||
}
|
||||
run.ToolCalls = nil
|
||||
return nil, 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,
|
||||
})
|
||||
}
|
||||
|
||||
// storeAgentRun admits one pending run per scope, so two requests racing on the same
|
||||
// conversation and surface cannot both park a tool call the agent could approve twice.
|
||||
func (m *Manager) storeAgentRun(run *pendingAgentRun) error {
|
||||
m.pendingRunMu.Lock()
|
||||
defer m.pendingRunMu.Unlock()
|
||||
m.deleteExpiredAgentRunsLocked(time.Now())
|
||||
for _, pending := range m.pendingRuns {
|
||||
if sameAgentRunScope(pending.Scope, run.Scope) {
|
||||
return envelope.NewError(envelope.ConflictError, m.i18n.T("ai.toolApprovalPending"), nil)
|
||||
}
|
||||
}
|
||||
m.pendingRuns[run.ID] = run
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
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 TestSecondRunInSameScopeIsRejectedWhileOneAwaitsApproval(t *testing.T) {
|
||||
m := newTestManager(t)
|
||||
tool := &approvalTestTool{}
|
||||
scope := AgentRunScope{AgentID: 7, ConversationID: 9, ConversationUUID: "conversation-1", Surface: models.ToolInvocationCopilot}
|
||||
newRun := func() *pendingAgentRun {
|
||||
return &pendingAgentRun{
|
||||
Scope: scope,
|
||||
Client: &approvalTestProvider{responses: []models.ChatCompletionResult{
|
||||
{ToolCalls: []models.ToolCall{{ID: "call-1", Type: "function", Function: models.ToolCallFunction{Name: tool.Name(), Arguments: `{}`}}}},
|
||||
}},
|
||||
Registry: map[string]Tool{tool.Name(): tool},
|
||||
MaxSteps: 5,
|
||||
Approvals: true,
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := m.continueAgentRun(t.Context(), newRun()); err != nil {
|
||||
t.Fatalf("first run: %v", err)
|
||||
}
|
||||
if _, err := m.continueAgentRun(t.Context(), newRun()); err == nil {
|
||||
t.Fatal("expected the second run in the same scope to be rejected")
|
||||
}
|
||||
if len(m.pendingRuns) != 1 {
|
||||
t.Fatalf("expected one pending run, got %d", len(m.pendingRuns))
|
||||
}
|
||||
}
|
||||
|
||||
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
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
|
||||
"github.com/abhinavxd/libredesk/internal/stringutil"
|
||||
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
@@ -87,6 +89,15 @@ func otpSendsKey(convUUID, email string) string {
|
||||
|
||||
func otpConvSendsKey(convUUID string) string { return otpSendsKeyPrefix + convUUID }
|
||||
|
||||
// isContactVerified reports whether the customer on this conversation has proven their identity: a
|
||||
// JWT livechat contact is trusted by login, everyone else only within an OTP verification window.
|
||||
func (m *Manager) isContactVerified(conv cmodels.Conversation) bool {
|
||||
if conv.InboxChannel != channelEmail && conv.Contact.Type == umodels.UserTypeContact {
|
||||
return true
|
||||
}
|
||||
return m.isConversationVerified(conv.UUID, conv.Contact.Email.String)
|
||||
}
|
||||
|
||||
// isConversationVerified holds the invariant "verified == the contact's current email is the one
|
||||
// proven by OTP": the verified value stores the attested address, and a contact email rebound
|
||||
// after verification (from this or any sibling conversation) no longer matches.
|
||||
|
||||
@@ -40,9 +40,9 @@ const handoffToolLine = "- hand_off_to_human: transfer the conversation to a hum
|
||||
|
||||
const defaultLanguageLine = "Reply in the same language as the customer's last message."
|
||||
|
||||
const cannotAnswerWithHandoff = `If you cannot answer from the knowledge base: tell the customer you could not help with that and offer to connect them with a human. Only call hand_off_to_human once the customer asks for a human or accepts your offer, or if they are clearly stuck or frustrated. Do not tell the customer they have been transferred unless you have used the tool.`
|
||||
const cannotAnswerWithHandoff = `If you cannot answer from the knowledge base: tell the customer you could not help with that and offer to connect them with a human. A tool that fails or returns an error is handled the same way: tell the customer you could not complete the request and offer a human. Only call hand_off_to_human once the customer asks for a human or accepts your offer, or if they are clearly stuck or frustrated. Do not tell the customer they have been transferred unless you have used the tool.`
|
||||
|
||||
const cannotAnswerNoHandoff = `If you cannot answer from the knowledge base: tell the customer you could not help with that. There is no human agent to transfer to - never offer to connect them with a human, transfer the conversation, or promise that someone will follow up.`
|
||||
const cannotAnswerNoHandoff = `If you cannot answer from the knowledge base, or a tool fails or returns an error: tell the customer you could not help with that. There is no human agent to transfer to - never offer to connect them with a human, transfer the conversation, or promise that someone will follow up.`
|
||||
|
||||
// noContactIdentityNote is a trusted, data-free system-prompt line for when nothing identifies the contact.
|
||||
const noContactIdentityNote = "You do not have any identifying details for this customer yet. If you need to identify them for a request, ask."
|
||||
|
||||
@@ -64,8 +64,9 @@ var (
|
||||
|
||||
// runOutcome records which terminal tool action the assistant took during one response run.
|
||||
type runOutcome struct {
|
||||
handedOff bool
|
||||
resolved bool
|
||||
handedOff bool
|
||||
handoffReason string
|
||||
resolved bool
|
||||
}
|
||||
|
||||
type searchKnowledgeTool struct {
|
||||
@@ -143,12 +144,8 @@ func (t *handoffTool) Execute(ctx context.Context, args string) (string, error)
|
||||
}
|
||||
_ = json.Unmarshal([]byte(args), &in)
|
||||
t.m.lo.Debug("ai agent handoff tool called", "conversation_uuid", t.conv.UUID, "reason", in.Reason)
|
||||
if t.m.requestHandoffForm(t.conv, t.assistant) {
|
||||
t.outcome.handedOff = true
|
||||
return "The customer has been asked for details before the human handoff. Do not take further action.", nil
|
||||
}
|
||||
t.m.handoff(t.conv, t.assistant, in.Reason)
|
||||
t.outcome.handedOff = true
|
||||
t.outcome.handoffReason = in.Reason
|
||||
return "The conversation has been handed off to a human. Do not take further action.", nil
|
||||
}
|
||||
|
||||
|
||||
+14
-15
@@ -277,14 +277,8 @@ func (m *Manager) handle(ctx context.Context, convID int) {
|
||||
}
|
||||
m.lo.Debug("ai agent running", "conversation_uuid", conv.UUID, "history_messages", len(history), "turns", turns)
|
||||
|
||||
// A JWT livechat contact is trusted by login; everyone else (email channel, anonymous visitor)
|
||||
// is trusted only within an OTP verification window. Read live so mid-turn verification counts.
|
||||
verified := func() bool {
|
||||
if conv.InboxChannel != channelEmail && conv.Contact.Type == umodels.UserTypeContact {
|
||||
return true
|
||||
}
|
||||
return m.isConversationVerified(conv.UUID, conv.Contact.Email.String)
|
||||
}
|
||||
// Read live so mid-turn verification counts.
|
||||
verified := func() bool { return m.isContactVerified(conv) }
|
||||
// Snapshot for the run-start registration decisions (one Redis read); tctx still gets the live
|
||||
// closure so mid-turn verification is picked up per tool call.
|
||||
runVerified := verified()
|
||||
@@ -349,14 +343,11 @@ func (m *Manager) handle(ctx context.Context, convID int) {
|
||||
}
|
||||
if err != nil {
|
||||
m.lo.Error("error running ai agent", "conversation_uuid", conv.UUID, "error", err)
|
||||
if !outcome.handedOff {
|
||||
m.handoff(conv, assistant, m.i18n.T("ai.agent.handoffError"))
|
||||
reason := m.i18n.T("ai.agent.handoffError")
|
||||
if outcome.handedOff {
|
||||
reason = outcome.handoffReason
|
||||
}
|
||||
return
|
||||
}
|
||||
// The assistant escalated; the handoff tool already reassigned and noted it.
|
||||
if outcome.handedOff {
|
||||
m.lo.Debug("ai agent handed off", "conversation_uuid", conv.UUID)
|
||||
m.handoff(conv, assistant, reason)
|
||||
return
|
||||
}
|
||||
// The model's text answer is the reply to the customer. Handoff and resolve are separate tool actions.
|
||||
@@ -386,6 +377,14 @@ func (m *Manager) handle(ctx context.Context, convID int) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if outcome.handedOff {
|
||||
m.lo.Debug("ai agent handed off", "conversation_uuid", conv.UUID)
|
||||
if m.requestHandoffForm(conv, assistant) {
|
||||
return
|
||||
}
|
||||
m.handoff(conv, assistant, outcome.handoffReason)
|
||||
return
|
||||
}
|
||||
if outcome.resolved && (answer != "" || turns > 0) {
|
||||
m.resolve(conv, assistant)
|
||||
return
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -777,6 +777,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)
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user