add ask to run prompt for tool calls (demo) (#5261)

* add ask to run prompt for tools

* border-none on buttons

* translations

* linting

* i18n (#5263)

* extend approve/deny requests to telegram

* break up handler
This commit is contained in:
Timothy Carambat
2026-03-24 15:18:17 -07:00
committed by GitHub
parent 8937b8a98b
commit 7e9737dd86
44 changed files with 1154 additions and 8 deletions

View File

@@ -0,0 +1,243 @@
import React, { useState, useEffect, useRef } from "react";
import { CaretDown, Check, X, Hammer } from "@phosphor-icons/react";
import AgentSkillWhitelist from "@/models/agentSkillWhitelist";
import { useTranslation } from "react-i18next";
export default function ToolApprovalRequest({
requestId,
skillName,
payload = {},
description = null,
timeoutMs = null,
websocket,
onResponse,
}) {
const [isExpanded, setIsExpanded] = useState(false);
const [responded, setResponded] = useState(false);
const [approved, setApproved] = useState(null);
const [alwaysAllow, setAlwaysAllow] = useState(false);
const [timeRemaining, setTimeRemaining] = useState(timeoutMs);
const startTimeRef = useRef(null);
const hasPayload = payload && Object.keys(payload).length > 0;
useEffect(() => {
if (!timeoutMs || responded) return;
if (startTimeRef.current === null) {
startTimeRef.current = Date.now();
}
const intervalId = setInterval(() => {
const elapsed = Date.now() - startTimeRef.current;
const remaining = Math.max(0, timeoutMs - elapsed);
setTimeRemaining(remaining);
if (remaining <= 0) {
clearInterval(intervalId);
handleTimeout();
}
}, 50);
return () => clearInterval(intervalId);
}, [timeoutMs, responded]);
function handleTimeout() {
if (responded) return;
setResponded(true);
setApproved(false);
onResponse?.(false);
}
async function handleResponse(isApproved) {
if (responded) return;
setResponded(true);
setApproved(isApproved);
// If user approved and checked "Always allow", add to whitelist
if (isApproved && alwaysAllow) {
await AgentSkillWhitelist.addToWhitelist(skillName);
}
if (websocket && websocket.readyState === WebSocket.OPEN) {
websocket.send(
JSON.stringify({
type: "toolApprovalResponse",
requestId,
approved: isApproved,
})
);
}
onResponse?.(isApproved);
}
const progressPercent = timeoutMs ? (timeRemaining / timeoutMs) * 100 : 0;
return (
<div className="flex justify-center w-full my-1 pr-4">
<div className="w-full flex flex-col">
<div className="w-full">
<div
style={{
transition: "all 0.1s ease-in-out",
borderRadius: "16px",
}}
className="relative bg-zinc-800 light:bg-slate-100 p-4 pb-2 flex flex-col gap-y-1 overflow-hidden"
>
<ToolApprovalHeader
skillName={skillName}
hasPayload={hasPayload}
isExpanded={isExpanded}
setIsExpanded={setIsExpanded}
/>
<div className="flex flex-col gap-y-1">
{description && (
<span className="text-white/60 light:text-slate-700 font-medium font-mono text-xs">
{description}
</span>
)}
<ToolApprovalPayload payload={payload} isExpanded={isExpanded} />
<ToolApprovalResponseOption
approved={approved}
skillName={skillName}
alwaysAllow={alwaysAllow}
setAlwaysAllow={setAlwaysAllow}
onApprove={() => handleResponse(true)}
onReject={() => handleResponse(false)}
/>
<ToolApprovalResponseMessage approved={approved} />
</div>
{timeoutMs && !responded && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-zinc-700 light:bg-slate-300">
<div
className="h-full bg-sky-500 light:bg-sky-600 transition-none"
style={{ width: `${progressPercent}%` }}
/>
</div>
)}
</div>
</div>
</div>
</div>
);
}
function ToolApprovalHeader({
skillName,
hasPayload,
isExpanded,
setIsExpanded,
}) {
const { t } = useTranslation();
return (
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2">
<Hammer size={16} />
<div className="text-white/80 light:text-slate-900 font-medium text-sm flex gap-x-1">
{t("chat_window.agent_invocation.model_wants_to_call")}
<span className="font-semibold text-sky-400 light:text-sky-600">
{skillName}
</span>
</div>
</div>
{hasPayload && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="absolute top-4 right-4 border-none"
aria-label={isExpanded ? "Hide details" : "Show details"}
>
<CaretDown
className={`w-4 h-4 transform transition-transform duration-200 ${isExpanded ? "rotate-180" : ""}`}
/>
</button>
)}
</div>
);
}
function ToolApprovalPayload({ payload, isExpanded }) {
const hasPayload = payload && Object.keys(payload).length > 0;
if (!hasPayload || !isExpanded) return null;
function formatPayload(data) {
if (typeof data === "string") return data;
try {
return JSON.stringify(data, null, 2);
} catch {
return String(data);
}
}
return (
<div className="p-3 bg-zinc-900/50 light:bg-slate-200/50 rounded-lg overflow-x-auto">
<pre className="text-xs text-zinc-300 light:text-slate-700 font-mono whitespace-pre-wrap break-words">
{formatPayload(payload)}
</pre>
</div>
);
}
function ToolApprovalResponseOption({
approved,
skillName,
alwaysAllow,
setAlwaysAllow,
onApprove,
onReject,
}) {
const { t } = useTranslation();
if (approved !== null) return null;
return (
<div className="flex flex-col gap-2 mt-1 pb-2">
<div className="flex gap-2">
<button
type="button"
onClick={onApprove}
className="border-none transition-all duration-300 bg-white text-black hover:opacity-60 px-4 py-2 rounded-lg text-sm"
>
{t("chat_window.agent_invocation.approve")}
</button>
<button
type="button"
onClick={onReject}
className="border-none text-white light:text-slate-900 text-sm font-medium w-[70px] h-9 rounded-lg hover:bg-white/5 light:hover:bg-slate-300"
>
{t("chat_window.agent_invocation.reject")}
</button>
</div>
<label className="flex items-center gap-2 cursor-pointer text-white/60 light:text-slate-600 text-xs hover:text-white/80 light:hover:text-slate-800 transition-colors">
<input
type="checkbox"
checked={alwaysAllow}
onChange={(e) => setAlwaysAllow(e.target.checked)}
className="w-3.5 h-3.5 rounded border-white/20 bg-transparent cursor-pointer"
/>
<span>
{t("chat_window.agent_invocation.always_allow", { skillName })}
</span>
</label>
</div>
);
}
function ToolApprovalResponseMessage({ approved }) {
const { t } = useTranslation();
if (approved === null) return null;
if (approved === false) {
return (
<div className="flex items-center gap-1 text-sm font-medium text-red-400 light:text-red-500">
<X size={16} weight="bold" />
<span>{t("chat_window.agent_invocation.tool_call_was_rejected")}</span>
</div>
);
}
return (
<div className="flex items-center gap-1 text-sm font-medium text-green-400 light:text-green-500">
<Check size={16} weight="bold" />
<span>{t("chat_window.agent_invocation.tool_call_was_approved")}</span>
</div>
);
}

View File

@@ -9,6 +9,7 @@ import {
import HistoricalMessage from "./HistoricalMessage";
import PromptReply from "./PromptReply";
import StatusResponse from "./StatusResponse";
import ToolApprovalRequest from "./ToolApprovalRequest";
import { useManageWorkspaceModal } from "../../../Modals/ManageWorkspace";
import ManageWorkspace from "../../../Modals/ManageWorkspace";
import { ArrowDown } from "@phosphor-icons/react";
@@ -29,6 +30,7 @@ export default forwardRef(function (
sendCommand,
updateHistory,
regenerateAssistantMessage,
websocket = null,
},
ref
) {
@@ -179,6 +181,7 @@ export default forwardRef(function (
regenerateAssistantMessage,
saveEditedMessage,
forkThread,
websocket,
}),
[
workspace,
@@ -186,6 +189,7 @@ export default forwardRef(function (
regenerateAssistantMessage,
saveEditedMessage,
forkThread,
websocket,
]
);
const lastMessageInfo = useMemo(() => getLastMessageInfo(history), [history]);
@@ -262,6 +266,7 @@ const getLastMessageInfo = (history) => {
* @param {Function} param0.regenerateAssistantMessage - The function to regenerate the assistant message.
* @param {Function} param0.saveEditedMessage - The function to save the edited message.
* @param {Function} param0.forkThread - The function to fork the thread.
* @param {WebSocket} param0.websocket - The active websocket connection for agent communication.
* @returns {Array} The compiled history of messages.
*/
function buildMessages({
@@ -270,6 +275,7 @@ function buildMessages({
regenerateAssistantMessage,
saveEditedMessage,
forkThread,
websocket,
}) {
return history.reduce((acc, props, index) => {
const isLastBotReply =
@@ -284,6 +290,21 @@ function buildMessages({
return acc;
}
if (props.type === "toolApprovalRequest") {
acc.push(
<ToolApprovalRequest
key={`tool-approval-${props.requestId}`}
requestId={props.requestId}
skillName={props.skillName}
payload={props.payload}
description={props.description}
timeoutMs={props.timeoutMs}
websocket={websocket}
/>
);
return acc;
}
if (props.type === "rechartVisualize" && !!props.content) {
acc.push(<Chartable key={props.uuid} props={props} />);
} else if (isLastBotReply && props.animate) {

View File

@@ -428,6 +428,7 @@ export default function ChatContainer({ workspace, knownHistory = [] }) {
sendCommand={sendCommand}
updateHistory={setChatHistory}
regenerateAssistantMessage={regenerateAssistantMessage}
websocket={websocket}
/>
</MetricsProvider>
<PromptInput

View File

@@ -757,6 +757,14 @@ const TRANSLATIONS = {
start_agent_session: "ابدأ جلسة الممثل",
use_agent_session_to_use_tools:
"يمكنك استخدام الأدوات المتاحة في الدردشة عن طريق بدء جلسة مع ممثل خدمة العملاء باستخدام الرمز '@agent' في بداية رسالتك.",
agent_invocation: {
model_wants_to_call: "النموذج يرغب في الاتصال.",
approve: "الموافقة",
reject: "رفض",
always_allow: "تأكد دائمًا من {{skillName}}",
tool_call_was_approved: "تمت الموافقة على طلب الحصول على الأدوات.",
tool_call_was_rejected: "تم رفض طلب الاتصال بالأداة.",
},
},
profile_settings: {
edit_account: "تحرير الحساب",

View File

@@ -886,6 +886,14 @@ const TRANSLATIONS = {
start_agent_session: "Spustit relaci s agentem",
use_agent_session_to_use_tools:
"Můžete využít nástroje v chatu spuštěním sezení s agentem pomocí příkazu '@agent' na začátku vašeho vstupu.",
agent_invocation: {
model_wants_to_call: "Model chce zavolat",
approve: "Schválit",
reject: "Zamítnout",
always_allow: "Vždy dbejte na to, aby {{skillName}}",
tool_call_was_approved: "Žádost o použití nástroje byla schválena.",
tool_call_was_rejected: "Žádost o použití nástroje byla zamítnuta.",
},
},
profile_settings: {
edit_account: "Upravit účet",

View File

@@ -771,6 +771,15 @@ const TRANSLATIONS = {
start_agent_session: "Start Agent-session",
use_agent_session_to_use_tools:
"Du kan bruge værktøjer i chat ved at starte en agent-session med '@agent' i starten af din forespørgsel.",
agent_invocation: {
model_wants_to_call: "Modellen ønsker at ringe",
approve: "Godkend",
reject: "Afvise",
always_allow: "Sørg altid for, at {{skillName}} er tilgængeligt.",
tool_call_was_approved:
"Anmodningen om at bruge værktøjet blev godkendt.",
tool_call_was_rejected: "Anmodningen om at bruge værktøjet blev afvist.",
},
},
profile_settings: {
edit_account: "Rediger konto",

View File

@@ -865,6 +865,15 @@ const TRANSLATIONS = {
start_agent_session: "Starte eine Agent-Sitzung",
use_agent_session_to_use_tools:
'Sie können Tools im Chat nutzen, indem Sie eine Agentensitzung mit "@agent" am Anfang Ihrer Anfrage starten.',
agent_invocation: {
model_wants_to_call: "Das Modell möchte anrufen.",
approve: "Genehmigen",
reject: "Ablehnen",
always_allow: "Bitte stellen Sie immer {{skillName}} sicher.",
tool_call_was_approved:
"Die Genehmigung für die Bestellung der Werkzeuge wurde erteilt.",
tool_call_was_rejected: "Die Anfrage nach dem Werkzeug wurde abgelehnt.",
},
},
profile_settings: {
edit_account: "Account bearbeiten",

View File

@@ -962,6 +962,14 @@ const TRANSLATIONS = {
missing_credentials: "This provider is missing credentials!",
missing_credentials_description: "Set up now",
},
agent_invocation: {
model_wants_to_call: "Model wants to call",
approve: "Approve",
reject: "Reject",
always_allow: "Always allow {{skillName}}",
tool_call_was_approved: "Tool call was approved",
tool_call_was_rejected: "Tool call was rejected",
},
},
profile_settings: {
edit_account: "Edit Account",

View File

@@ -879,6 +879,14 @@ const TRANSLATIONS = {
start_agent_session: "Iniciar sesión como agente",
use_agent_session_to_use_tools:
"Puede utilizar las herramientas disponibles en el chat iniciando una sesión con un agente utilizando el prefijo '@agent' al principio de su mensaje.",
agent_invocation: {
model_wants_to_call: "El modelo quiere llamar",
approve: "Aprobar",
reject: "Rechazar",
always_allow: "Siempre asegúrese de que haya {{skillName}}",
tool_call_was_approved: "La solicitud de herramientas ha sido aprobada.",
tool_call_was_rejected: "La solicitud de herramienta fue rechazada.",
},
},
profile_settings: {
edit_account: "Editar cuenta",

View File

@@ -830,6 +830,14 @@ const TRANSLATIONS = {
start_agent_session: "Alusta agenti sessiooni",
use_agent_session_to_use_tools:
"Saate kasutada vahendeid vestluses, alustades agenti sessiooni, lisades käskile '@agent' sõna.",
agent_invocation: {
model_wants_to_call: "Mudel soovib helistada",
approve: "Heakskiid",
reject: "Hüvasti, keelan",
always_allow: "Aeg-ajalt lubage {{skillName}}",
tool_call_was_approved: "Vahendite tellimuse kinnitati.",
tool_call_was_rejected: "Vahendite taotlus jäeti rahuldamata.",
},
},
profile_settings: {
edit_account: "Muuda kontot",

View File

@@ -766,6 +766,14 @@ const TRANSLATIONS = {
start_agent_session: "شروع جلسه با نماینده",
use_agent_session_to_use_tools:
"شما می‌توانید از ابزارهای موجود در چت با شروع یک جلسه با یک عامل از طریق استفاده از '@agent' در ابتدای پیام خود استفاده کنید.",
agent_invocation: {
model_wants_to_call: "مدل می‌خواهد تماس بگیرد",
approve: "تایید",
reject: "رد",
always_allow: "همیشه، {{skillName}} را در نظر بگیرید.",
tool_call_was_approved: "درخواست برای تهیه ابزار تأیید شد.",
tool_call_was_rejected: "درخواست استفاده از ابزار رد شد.",
},
},
profile_settings: {
edit_account: "ویرایش حساب",

View File

@@ -770,6 +770,15 @@ const TRANSLATIONS = {
start_agent_session: "Démarrer la session de l'agent",
use_agent_session_to_use_tools:
'Vous pouvez utiliser des outils via le chat en lançant une session avec un agent en utilisant le préfixe "@agent" au début de votre requête.',
agent_invocation: {
model_wants_to_call: "Le modèle souhaite passer un appel.",
approve: "Approuver",
reject: "Refuser",
always_allow: "Il est toujours important de {{skillName}}",
tool_call_was_approved: "La demande d'outils a été approuvée.",
tool_call_was_rejected:
"La demande d'utilisation de l'outil a été rejetée.",
},
},
profile_settings: {
edit_account: "Modifier le compte",

View File

@@ -834,6 +834,14 @@ const TRANSLATIONS = {
start_agent_session: "התחלת סשן עם סוכן",
use_agent_session_to_use_tools:
"ניתן להשתמש בכלי הדיון באמצעות פתיחת סשן עם נציג על ידי שימוש בסימן '@agent' בתחילת ההודעה.",
agent_invocation: {
model_wants_to_call: "המודל רוצה להתקשר",
approve: "אישור",
reject: "דחייה",
always_allow: "יש תמיד להקצות {{skillName}}",
tool_call_was_approved: "הבקשה לקבלת הכלי אושרה.",
tool_call_was_rejected: "בקשת השימוש בכלי נדחתה.",
},
},
profile_settings: {
edit_account: "ערוך חשבון",

View File

@@ -779,6 +779,16 @@ const TRANSLATIONS = {
start_agent_session: "Avvia sessione agente",
use_agent_session_to_use_tools:
'È possibile utilizzare gli strumenti disponibili tramite chat avviando una sessione con un agente utilizzando il prefisso "@agent" all\'inizio del messaggio.',
agent_invocation: {
model_wants_to_call: "Il modello desidera effettuare una chiamata.",
approve: "Approvato",
reject: "Rifiutare",
always_allow: "Assicurarsi sempre di avere {{skillName}}",
tool_call_was_approved:
"La richiesta di fornitura di strumenti è stata approvata.",
tool_call_was_rejected:
"La richiesta di accesso all'attrezzatura è stata rifiutata.",
},
},
profile_settings: {
edit_account: "Modifica account",

View File

@@ -759,6 +759,14 @@ const TRANSLATIONS = {
start_agent_session: "エージェントセッションを開始",
use_agent_session_to_use_tools:
"チャットでツールを使用するには、プロンプトの冒頭に'@agent'を使用してエージェントセッションを開始してください。",
agent_invocation: {
model_wants_to_call: "モデルは電話をかけたい。",
approve: "承認",
reject: "拒否",
always_allow: "常に、{{skillName}}を確保してください。",
tool_call_was_approved: "ツールの使用許可が承認されました",
tool_call_was_rejected: "ツール呼び出しは拒否されました",
},
},
profile_settings: {
edit_account: "アカウントを編集",

View File

@@ -844,6 +844,14 @@ const TRANSLATIONS = {
start_agent_session: "에이전트 세션 시작",
use_agent_session_to_use_tools:
"채팅에서 도구를 사용하려면, 프롬프트의 시작 부분에 '@agent'을 사용하여 에이전트 세션을 시작할 수 있습니다.",
agent_invocation: {
model_wants_to_call: "모델이 통화하고 싶어",
approve: "승인",
reject: "거부",
always_allow: "항상 {{skillName}}을 허용",
tool_call_was_approved: "도구 사용 승인",
tool_call_was_rejected: "도구 호출이 거부되었습니다.",
},
},
profile_settings: {
edit_account: "계정 정보 수정",

View File

@@ -889,6 +889,14 @@ const TRANSLATIONS = {
missing_credentials: "Šiam tiekėjui trūksta duomenų!",
missing_credentials_description: "Nustatyti dabar",
},
agent_invocation: {
model_wants_to_call: "Modelis nori paskambinti",
approve: "Patvirtinti",
reject: "Atmetti",
always_allow: "Visada būkite pasiruošę {{skillName}}",
tool_call_was_approved: "Įrankių užsakymas buvo patvirtintas.",
tool_call_was_rejected: "Klausimas dėl įrankio buvo atmetamas.",
},
},
profile_settings: {
edit_account: "Redaguoti paskyrą",

View File

@@ -863,6 +863,15 @@ const TRANSLATIONS = {
start_agent_session: "Sākt aģenta sesiju",
use_agent_session_to_use_tools:
'Jūs varat izmantot rīkus čatā, sākot aģenta sesiju, ievietojot "@agent" jūsu iniciālajā tekstā.',
agent_invocation: {
model_wants_to_call: "Modeļa vēlējas izrunāt",
approve: "Aizmaksā, apstiprināts",
reject: "Atgrūst",
always_allow: "Vienmēr nodrošiniet {{skillName}}",
tool_call_was_approved: "Instrumentu pieprasījums tika apstiprināts.",
tool_call_was_rejected:
"Pieprasījums par instrumenta izmantošanu tika atgrūstīts.",
},
},
profile_settings: {
edit_account: "Rediģēt kontu",

View File

@@ -767,6 +767,16 @@ const TRANSLATIONS = {
start_agent_session: "Start Agent Sessie",
use_agent_session_to_use_tools:
'U kunt tools in de chat gebruiken door een sessie met een agent te starten, beginnend met "@agent" aan het begin van uw bericht.',
agent_invocation: {
model_wants_to_call: "De klant wil een gesprek plannen.",
approve: "Goedkeuren",
reject: "Afgewijzen",
always_allow: "Zorg er altijd voor dat {{skillName}} aanwezig is.",
tool_call_was_approved:
"De aanvraag voor het gereedschap is goedgekeurd.",
tool_call_was_rejected:
"De aanvraag om het gereedschap te gebruiken is afgewezen.",
},
},
profile_settings: {
edit_account: "Account bewerken",

View File

@@ -860,6 +860,15 @@ const TRANSLATIONS = {
start_agent_session: "Rozpocznij sesję dla agenta",
use_agent_session_to_use_tools:
"Możesz korzystać z narzędzi w czacie, inicjując sesję z agentem, wpisując '@agent' na początku swojego zapytania.",
agent_invocation: {
model_wants_to_call: "Model chce zadzwonić",
approve: "Zaakceptować",
reject: "Odrzucić",
always_allow: "Zawsze należy uwzględnić {{skillName}}",
tool_call_was_approved:
"Zgłoszenie dotyczące narzędzia zostało zatwierdzone.",
tool_call_was_rejected: "Żądanie użycia narzędzia zostało odrzucone.",
},
},
profile_settings: {
edit_account: "Edytuj konto",

View File

@@ -844,6 +844,16 @@ const TRANSLATIONS = {
start_agent_session: "Iniciar Sessão de Agente",
use_agent_session_to_use_tools:
'Você pode utilizar as ferramentas disponíveis no chat iniciando uma sessão com um agente, adicionando "@agent" no início da sua mensagem.',
agent_invocation: {
model_wants_to_call: "O modelo deseja fazer uma ligação.",
approve: "Aprovar",
reject: "Rejeitar",
always_allow:
"Certifique-se sempre de que {{skillName}} esteja disponível.",
tool_call_was_approved: "A solicitação de ferramentas foi aprovada.",
tool_call_was_rejected:
"A solicitação de acesso à ferramenta foi rejeitada.",
},
},
profile_settings: {
edit_account: "Editar conta",

View File

@@ -569,6 +569,15 @@ const TRANSLATIONS = {
start_agent_session: "Începe sesiunea de agent",
use_agent_session_to_use_tools:
'Puteți utiliza instrumentele disponibile în chat, inițiind o sesiune cu un agent, începând mesajul cu "@agent".',
agent_invocation: {
model_wants_to_call: "Persoana respectivă dorește să facă o telefonare.",
approve: "Aprobă",
reject: "Refuz",
always_allow: "Asigurați-vă întotdeauna că {{skillName}}",
tool_call_was_approved: "Cererea de achiziție a fost aprobată.",
tool_call_was_rejected:
"Cererea de utilizare a instrumentului a fost respinsă.",
},
},
profile_settings: {
edit_account: "Editează contul",

View File

@@ -770,6 +770,16 @@ const TRANSLATIONS = {
start_agent_session: "Начать сеанс для агента",
use_agent_session_to_use_tools:
"Вы можете использовать инструменты в чате, начав сеанс с агентом, добавив '@agent' в начало вашего сообщения.",
agent_invocation: {
model_wants_to_call: "Модель хочет позвонить",
approve: "Одобрить",
reject: "Отказ",
always_allow: "Всегда оставляйте {{skillName}}",
tool_call_was_approved:
"Запрос на предоставление инструмента был одобрен.",
tool_call_was_rejected:
"Запрос на предоставление инструмента был отклонен.",
},
},
profile_settings: {
edit_account: "Редактировать учётную запись",

View File

@@ -765,6 +765,14 @@ const TRANSLATIONS = {
start_agent_session: "Temsilci Oturumu Başlat",
use_agent_session_to_use_tools:
'Çatınızdaki araçları kullanmak için, isteminizin başında "@agent" ile bir ajan oturumu başlatabilirsiniz.',
agent_invocation: {
model_wants_to_call: "Model, arama yapmak istiyor",
approve: "Onayla",
reject: "Reddet",
always_allow: "Her zaman {{skillName}}'ı sağlayın.",
tool_call_was_approved: "Araç talebi onaylandı.",
tool_call_was_rejected: "Ara çağrısı reddedildi.",
},
},
profile_settings: {
edit_account: "Hesabı Düzenle",

View File

@@ -762,6 +762,14 @@ const TRANSLATIONS = {
start_agent_session: "Bắt đầu phiên làm việc với đại lý",
use_agent_session_to_use_tools:
"Bạn có thể sử dụng các công cụ trong cuộc trò chuyện bằng cách bắt đầu một phiên với trợ lý bằng cách sử dụng '@agent' ở đầu yêu cầu của bạn.",
agent_invocation: {
model_wants_to_call: "Người mẫu muốn gọi",
approve: "Chấp thuận",
reject: "Từ chối",
always_allow: "Luôn luôn đảm bảo {{skillName}}",
tool_call_was_approved: "Đã được phê duyệt yêu cầu dụng cụ.",
tool_call_was_rejected: "Yêu cầu gọi công cụ đã bị từ chối.",
},
},
profile_settings: {
edit_account: "Chỉnh sửa Tài khoản",

View File

@@ -805,6 +805,14 @@ const TRANSLATIONS = {
start_agent_session: "开始代理会",
use_agent_session_to_use_tools:
"您可以通过在提示词的开头使用'@agent'来启动与代理的聊天,从而使用聊天工具。",
agent_invocation: {
model_wants_to_call: "该型号希望进行通话。",
approve: "批准",
reject: "拒绝",
always_allow: "请务必留出 {{skillName}}",
tool_call_was_approved: "工具使用申请已获得批准。",
tool_call_was_rejected: "请求获取工具已被拒绝。",
},
},
profile_settings: {
edit_account: "编辑帐户",

View File

@@ -715,6 +715,14 @@ const TRANSLATIONS = {
start_agent_session: "開始智慧代理人工作階段",
use_agent_session_to_use_tools:
"若要在對話中使用工具,請在提示詞開頭加上 '@agent',即可開始智慧代理人工作階段。",
agent_invocation: {
model_wants_to_call: "模型想要撥打電話",
approve: "批准",
reject: "拒絕",
always_allow: "請務必確保 {{skillName}}",
tool_call_was_approved: "工具請求已獲得批准。",
tool_call_was_rejected: "請求已遭拒絕",
},
},
profile_settings: {
edit_account: "編輯帳戶",

View File

@@ -0,0 +1,21 @@
import { API_BASE } from "@/utils/constants";
import { baseHeaders } from "@/utils/request";
const AgentSkillWhitelist = {
/**
* Add a skill to the whitelist
* @param {string} skillName - The skill name to whitelist
* @returns {Promise<{success: boolean, error?: string}>}
*/
addToWhitelist: async function (skillName) {
return fetch(`${API_BASE}/agent-skills/whitelist/add`, {
method: "POST",
headers: baseHeaders(),
body: JSON.stringify({ skillName }),
})
.then((res) => res.json())
.catch((e) => ({ success: false, error: e.message }));
},
};
export default AgentSkillWhitelist;

View File

@@ -12,6 +12,7 @@ const handledEvents = [
"awaitingFeedback",
"wssFailure",
"rechartVisualize",
"toolApprovalRequest",
// Streaming events
"reportStreamEvent",
];
@@ -47,7 +48,12 @@ export default function handleSocketResponse(socket, event, setChatHistory) {
});
}
if (!handledEvents.includes(data.type) || !data.content) return;
// toolApprovalRequest doesn't have content field, so check separately
if (data.type === "toolApprovalRequest") {
if (!data.requestId || !data.skillName) return;
} else if (!handledEvents.includes(data.type) || !data.content) {
return;
}
if (data.type === "reportStreamEvent") {
// Enable agent streaming for the next message so we can handle streaming or non-streaming responses
@@ -220,6 +226,31 @@ export default function handleSocketResponse(socket, event, setChatHistory) {
});
}
if (data.type === "toolApprovalRequest") {
return setChatHistory((prev) => {
return [
...prev.filter((msg) => !!msg.content),
{
uuid: v4(),
type: "toolApprovalRequest",
requestId: data.requestId,
skillName: data.skillName,
payload: data.payload,
description: data.description,
timeoutMs: data.timeoutMs,
content: `Approval requested for ${data.skillName}`,
role: "assistant",
sources: [],
closed: false,
error: null,
animate: false,
pending: true,
metrics: {},
},
];
});
}
return setChatHistory((prev) => {
return [
...prev.filter((msg) => !!msg.content),

View File

@@ -0,0 +1,46 @@
const { AgentSkillWhitelist } = require("../models/agentSkillWhitelist");
const { reqBody, userFromSession } = require("../utils/http");
const { validatedRequest } = require("../utils/middleware/validatedRequest");
const {
flexUserRoleValid,
ROLES,
} = require("../utils/middleware/multiUserProtected");
function agentSkillWhitelistEndpoints(app) {
if (!app) return;
app.post(
"/agent-skills/whitelist/add",
[validatedRequest, flexUserRoleValid(ROLES.all)],
async (request, response) => {
try {
const { skillName } = reqBody(request);
if (!skillName) {
response
.status(400)
.json({ success: false, error: "Missing skillName" });
return;
}
const user = await userFromSession(request, response);
if (!user && response.locals?.multiUserMode) {
return response
.status(401)
.json({ success: false, error: "Unauthorized" });
}
const userId = user?.id || null;
const { success, error } = await AgentSkillWhitelist.add(
skillName,
userId
);
return response.status(success ? 200 : 400).json({ success, error });
} catch (e) {
console.error(e);
return response.status(500).json({ success: false, error: e.message });
}
}
);
}
module.exports = { agentSkillWhitelistEndpoints };

View File

@@ -11,6 +11,7 @@ const { safeJsonParse } = require("../utils/http");
// Setup listener for incoming messages to relay to socket so it can be handled by agent plugin.
function relayToSocket(message) {
if (this.handleFeedback) return this?.handleFeedback?.(message);
if (this.handleToolApproval) return this?.handleToolApproval?.(message);
this.checkBailCommand(message);
}

View File

@@ -60,6 +60,7 @@ const {
const { TemporaryAuthToken } = require("../models/temporaryAuthToken");
const { SystemPromptVariables } = require("../models/systemPromptVariables");
const { VALID_COMMANDS } = require("../utils/chats");
const { AgentSkillWhitelist } = require("../models/agentSkillWhitelist");
function systemEndpoints(app) {
if (!app) return;
@@ -619,7 +620,7 @@ function systemEndpoints(app) {
multi_user_mode: true,
});
await BrowserExtensionApiKey.migrateApiKeysToMultiUser(user.id);
await AgentSkillWhitelist.clearSingleUserWhitelist();
await updateENV(
{
JWTSecret: process.env.JWT_SECRET || v4(),

View File

@@ -23,6 +23,9 @@ const { bootHTTP, bootSSL } = require("./utils/boot");
const { workspaceThreadEndpoints } = require("./endpoints/workspaceThreads");
const { documentEndpoints } = require("./endpoints/document");
const { agentWebsocket } = require("./endpoints/agentWebsocket");
const {
agentSkillWhitelistEndpoints,
} = require("./endpoints/agentSkillWhitelist");
const { experimentalEndpoints } = require("./endpoints/experimental");
const { browserExtensionEndpoints } = require("./endpoints/browserExtension");
const { communityHubEndpoints } = require("./endpoints/communityHub");
@@ -75,6 +78,7 @@ embedManagementEndpoints(apiRouter);
utilEndpoints(apiRouter);
documentEndpoints(apiRouter);
agentWebsocket(apiRouter);
agentSkillWhitelistEndpoints(apiRouter);
experimentalEndpoints(apiRouter);
developerEndpoints(app, apiRouter);
communityHubEndpoints(apiRouter);

View File

@@ -8,6 +8,9 @@ const { WorkspaceThread } = require("../models/workspaceThread");
const { streamResponse } = require("../utils/telegramBot/chat/stream");
process.on("message", async (payload) => {
// Ignore tool approval responses - these are handled by http-socket plugin
if (payload?.type === "toolApprovalResponse") return;
const {
botToken,
chatId,

View File

@@ -0,0 +1,100 @@
const prisma = require("../utils/prisma");
const { safeJsonParse } = require("../utils/http");
const AgentSkillWhitelist = {
SINGLE_USER_LABEL: "whitelisted_agent_skills",
/**
* Get the label for storing whitelist in system_settings
* @param {number|null} userId - User ID in multi-user mode, null for single-user
* @returns {string}
*/
_getLabel: function (userId = null) {
if (userId) return `user_${userId}_whitelisted_agent_skills`;
return this.SINGLE_USER_LABEL;
},
/**
* Get the whitelisted skills for a user or the system
* @param {number|null} userId - User ID in multi-user mode, null for single-user
* @returns {Promise<string[]>} Array of whitelisted skill names
*/
get: async function (userId = null) {
try {
const label = this._getLabel(userId);
const setting = await prisma.system_settings.findFirst({
where: { label },
});
return safeJsonParse(setting?.value, []);
} catch (error) {
console.error("AgentSkillWhitelist.get error:", error.message);
return [];
}
},
/**
* Add a skill to the whitelist
* @param {string} skillName - The skill name to whitelist
* @param {number|null} userId - User ID in multi-user mode, null for single-user
* @returns {Promise<{success: boolean, error: string|null}>}
*/
add: async function (skillName, userId = null) {
try {
if (!skillName || typeof skillName !== "string") {
return { success: false, error: "Invalid skill name" };
}
const label = this._getLabel(userId);
const currentList = await this.get(userId);
if (currentList.includes(skillName)) {
return { success: true, error: null };
}
const newList = [...currentList, skillName];
await prisma.system_settings.upsert({
where: { label },
update: { value: JSON.stringify(newList) },
create: { label, value: JSON.stringify(newList) },
});
return { success: true, error: null };
} catch (error) {
console.error("AgentSkillWhitelist.add error:", error.message);
return { success: false, error: error.message };
}
},
/**
* Check if a skill is whitelisted
* @param {string} skillName - The skill name to check
* @param {number|null} userId - User ID in multi-user mode, null for single-user
* @returns {Promise<boolean>}
*/
isWhitelisted: async function (skillName, userId = null) {
const whitelist = await this.get(userId);
return whitelist.includes(skillName);
},
/**
* Clear the single-user whitelist (used when switching to multi-user mode)
* @returns {Promise<{success: boolean, error: string|null}>}
*/
clearSingleUserWhitelist: async function () {
try {
await prisma.system_settings.deleteMany({
where: { label: this.SINGLE_USER_LABEL },
});
return { success: true, error: null };
} catch (error) {
console.error(
"AgentSkillWhitelist.clearSingleUserWhitelist error:",
error.message
);
return { success: false, error: error.message };
}
},
};
module.exports = { AgentSkillWhitelist };

View File

@@ -1,10 +1,48 @@
const chalk = require("chalk");
const { Telemetry } = require("../../../../models/telemetry");
const { v4: uuidv4 } = require("uuid");
const TOOL_APPROVAL_TIMEOUT_MS = 120 * 1_000; // 2 mins for tool approval
/**
* Get the IPC channel for worker communication.
* Bree workers use worker_threads internally but polyfill process.on("message") for receiving.
* Workers send via parentPort.postMessage(), receive via process.on("message").
* @returns {{ send: Function, on: Function, removeListener: Function } | null}
*/
function getWorkerIPC() {
try {
const { parentPort } = require("node:worker_threads");
if (parentPort) {
// Bree worker context: send via parentPort, receive via process (Bree polyfill)
return {
send: (msg) => parentPort.postMessage(msg),
on: (event, handler) => process.on(event, handler),
removeListener: (event, handler) =>
process.removeListener(event, handler),
};
}
} catch {}
// Fallback for child_process workers
if (typeof process.send === "function") {
return {
send: (msg) => process.send(msg),
on: (event, handler) => process.on(event, handler),
removeListener: (event, handler) =>
process.removeListener(event, handler),
};
}
return null;
}
/**
* HTTP Interface plugin for Aibitat to emulate a websocket interface in the agent
* framework so we dont have to modify the interface for passing messages and responses
* in REST or WSS.
*
* When telegramChatId is provided, enables tool approval via Telegram inline keyboards
* using IPC messages to communicate with the parent TelegramBotService process.
*/
const httpSocket = {
name: "httpSocket",
@@ -21,12 +59,17 @@ const httpSocket = {
required: false,
default: true,
},
telegramChatId: {
required: false,
default: null,
},
},
},
plugin: function ({
handler,
muteUserReply = true, // Do not post messages to "USER" back to frontend.
introspection = false, // when enabled will attach socket to Aibitat object with .introspect method which reports status updates to frontend.
telegramChatId = null, // When set, enables tool approval via Telegram IPC
}) {
return {
name: this.name,
@@ -59,6 +102,125 @@ const httpSocket = {
},
};
/**
* Request user approval before executing a tool/skill.
* Only available when running in Telegram context (telegramChatId is set).
* Sends IPC message to parent process which shows Telegram inline keyboard.
*
* @param {Object} options - The approval request options
* @param {string} options.skillName - The name of the skill/tool requesting approval
* @param {Object} [options.payload={}] - Optional payload data to display to the user
* @param {string} [options.description] - Optional description of what the skill will do
* @returns {Promise<{approved: boolean, message: string}>} - The approval result
*/
aibitat.requestToolApproval = async function ({
skillName,
payload = {},
description = null,
}) {
// Check whitelist first
const {
AgentSkillWhitelist,
} = require("../../../../models/agentSkillWhitelist");
const isWhitelisted = await AgentSkillWhitelist.isWhitelisted(
skillName,
null
);
if (isWhitelisted) {
console.log(
chalk.green(`Skill ${skillName} is whitelisted - auto-approved.`)
);
return {
approved: true,
message: "Skill is whitelisted - auto-approved.",
};
}
// Tool approval only available in Telegram worker context
const ipc = getWorkerIPC();
if (!telegramChatId || !ipc) {
console.log(
chalk.yellow(
`Tool approval requested for ${skillName} but no Telegram context available. Auto-denying for safety.`
)
);
return {
approved: false,
message:
"Tool approval is not available in this context. Operation denied.",
};
}
const requestId = uuidv4();
console.log(
chalk.blue(
`Requesting tool approval for ${skillName} (${requestId})`
)
);
// Send introspection message before the approval UI appears
aibitat.introspect(
`Requesting approval to execute: ${skillName}${description ? ` - ${description}` : ""}`
);
return new Promise((resolve) => {
let timeoutId = null;
const messageHandler = (msg) => {
if (msg?.type !== "toolApprovalResponse") return;
if (msg?.requestId !== requestId) return;
ipc.removeListener("message", messageHandler);
clearTimeout(timeoutId);
if (msg.approved) {
console.log(
chalk.green(`Tool ${skillName} approved by user via Telegram`)
);
return resolve({
approved: true,
message: "User approved the tool execution.",
});
}
console.log(
chalk.yellow(`Tool ${skillName} denied by user via Telegram`)
);
return resolve({
approved: false,
message: "Tool call was rejected by the user.",
});
};
ipc.on("message", messageHandler);
// Send approval request to parent TelegramBotService process
ipc.send({
type: "toolApprovalRequest",
requestId,
chatId: telegramChatId,
skillName,
payload,
description,
timeoutMs: TOOL_APPROVAL_TIMEOUT_MS,
});
timeoutId = setTimeout(() => {
ipc.removeListener("message", messageHandler);
console.log(
chalk.yellow(
`Tool approval request timed out after ${TOOL_APPROVAL_TIMEOUT_MS}ms`
)
);
resolve({
approved: false,
message:
"Tool approval request timed out. User did not respond in time.",
});
}, TOOL_APPROVAL_TIMEOUT_MS);
});
};
// We can only receive one message response with HTTP
// so we end on first response.
aibitat.onMessage((message) => {

View File

@@ -1,6 +1,9 @@
const chalk = require("chalk");
const { Telemetry } = require("../../../../models/telemetry");
const { v4: uuidv4 } = require("uuid");
const { safeJsonParse } = require("../../../http");
const SOCKET_TIMEOUT_MS = 300 * 1_000; // 5 mins
const TOOL_APPROVAL_TIMEOUT_MS = 120 * 1_000; // 2 mins for tool approval
/**
* Websocket Interface plugin. It prints the messages on the console and asks for feedback
@@ -11,6 +14,7 @@ const SOCKET_TIMEOUT_MS = 300 * 1_000; // 5 mins
// askForFeedback?: any
// awaitResponse?: any
// handleFeedback?: (message: string) => void;
// handleToolApproval?: (message: string) => void;
// }
const WEBSOCKET_BAIL_COMMANDS = [
@@ -43,6 +47,7 @@ const websocket = {
socket, // @type AIbitatWebSocket
muteUserReply = true, // Do not post messages to "USER" back to frontend.
introspection = false, // when enabled will attach socket to Aibitat object with .introspect method which reports status updates to frontend.
userId = null, // User ID for multi-user mode whitelist lookups
}) {
return {
name: this.name,
@@ -79,6 +84,102 @@ const websocket = {
},
};
/**
* Request user approval before executing a tool/skill.
* This sends a request to the frontend and blocks until the user responds.
* If the skill is whitelisted, approval is granted automatically.
*
* @param {Object} options - The approval request options
* @param {string} options.skillName - The name of the skill/tool requesting approval
* @param {Object} [options.payload={}] - Optional payload data to display to the user
* @param {string} [options.description] - Optional description of what the skill will do
* @returns {Promise<{approved: boolean, message: string}>} - The approval result
*/
aibitat.requestToolApproval = async function ({
skillName,
payload = {},
description = null,
}) {
const {
AgentSkillWhitelist,
} = require("../../../../models/agentSkillWhitelist");
const isWhitelisted = await AgentSkillWhitelist.isWhitelisted(
skillName,
userId
);
if (isWhitelisted) {
console.log(
chalk.green(
userId
? `User ${userId} - `
: "" + `Skill ${skillName} is whitelisted - auto-approved.`
)
);
return {
approved: true,
message: "Skill is whitelisted - auto-approved.",
};
}
const requestId = uuidv4();
return new Promise((resolve) => {
let timeoutId = null;
socket.handleToolApproval = (message) => {
try {
const data = safeJsonParse(message, {});
if (
data?.type !== "toolApprovalResponse" ||
data?.requestId !== requestId
)
return;
delete socket.handleToolApproval;
clearTimeout(timeoutId);
if (data.approved) {
return resolve({
approved: true,
message: "User approved the tool execution.",
});
}
return resolve({
approved: false,
message: "Tool call was rejected by the user.",
});
} catch (e) {
console.error("Error handling tool approval response:", e);
}
};
socket.send(
JSON.stringify({
type: "toolApprovalRequest",
requestId,
skillName,
payload,
description,
timeoutMs: TOOL_APPROVAL_TIMEOUT_MS,
})
);
timeoutId = setTimeout(() => {
delete socket.handleToolApproval;
console.log(
chalk.yellow(
`Tool approval request timed out after ${TOOL_APPROVAL_TIMEOUT_MS}ms`
)
);
resolve({
approved: false,
message:
"Tool approval request timed out. User did not respond in time.",
});
}, TOOL_APPROVAL_TIMEOUT_MS);
});
};
// aibitat.onStart(() => {
// console.log("🚀 starting chat ...");
// });

View File

@@ -436,6 +436,7 @@ class EphemeralAgentHandler extends AgentHandler {
async createAIbitat(
args = {
handler: null,
telegramChatId: null,
}
) {
this.aibitat = new AIbitat({
@@ -456,12 +457,14 @@ class EphemeralAgentHandler extends AgentHandler {
this.aibitat.fetchParsedFileContext = () => this.#fetchParsedFileContext();
// Attach HTTP response object if defined for chunk streaming.
// When telegramChatId is provided, tool approval via Telegram is enabled.
this.log(`Attached ${httpSocket.name} plugin to Agent cluster`);
this.aibitat.use(
httpSocket.plugin({
handler: args.handler,
muteUserReply: true,
introspection: true,
telegramChatId: args.telegramChatId,
})
);

View File

@@ -726,6 +726,7 @@ class AgentHandler {
socket: args.socket,
muteUserReply: true,
introspection: true,
userId: this.invocation.user_id || null,
})
);

View File

@@ -232,7 +232,7 @@ async function handleAgentResponse(
threadId: thread?.id || null,
attachments,
}).init();
await agentHandler.createAIbitat({ handler });
await agentHandler.createAIbitat({ handler, telegramChatId: chatId });
// httpSocket terminates after the first agent message, but cap rounds
// as a safety net so the agent can't loop indefinitely.

View File

@@ -38,6 +38,8 @@ class TelegramBotService {
#pendingPairings = new Map();
// Active workers per chat: chatId -> { worker, jobId }
#activeWorkers = new Map();
// Pending tool approval requests: requestId -> { worker, chatId, messageId }
#pendingToolApprovals = new Map();
constructor() {
if (TelegramBotService._instance) return TelegramBotService._instance;
@@ -153,6 +155,7 @@ class TelegramBotService {
this.#chatState.clear();
this.#pendingPairings.clear();
this.#activeWorkers.clear();
this.#pendingToolApprovals.clear();
this.#log("Stopped");
}
@@ -349,9 +352,12 @@ class TelegramBotService {
guard(msg, () => handler(ctx, msg.chat.id, msg.text));
});
// Register callback queries, used for workspace/thread selection interactive menus
// Register callback queries, used for workspace/thread selection, tool approval, etc.
this.#bot.on("callback_query", (query) =>
handleKeyboardQueryCallback(ctx, query)
handleKeyboardQueryCallback(ctx, query, {
pendingToolApprovals: this.#pendingToolApprovals,
log: this.#log.bind(this),
})
);
this.#bot.on("message", (msg) => {
@@ -396,6 +402,9 @@ class TelegramBotService {
if (worker) {
worker.on("message", (msg) => {
if (msg?.type === "closeInvocation") invocationUuid = msg.uuid;
if (msg?.type === "toolApprovalRequest") {
this.#handleToolApprovalRequest(worker, msg);
}
});
this.#activeWorkers.set(chatId, { worker, jobId, bgService });
}
@@ -467,6 +476,91 @@ class TelegramBotService {
return true;
}
/**
* Handle a tool approval request from a worker process.
* Sends a Telegram message with Approve/Deny inline keyboard buttons.
* @param {Worker} worker - The worker process requesting approval
* @param {Object} msg - The tool approval request message
*/
async #handleToolApprovalRequest(worker, msg) {
const { requestId, chatId, skillName, payload, description, timeoutMs } =
msg;
this.#log(
`Tool approval request received: ${skillName} (requestId: ${requestId})`
);
try {
const payloadText =
payload && Object.keys(payload).length > 0
? `\n\n<b>Parameters:</b>\n<code>${JSON.stringify(payload, null, 2)}</code>`
: "";
const descText = description ? `\n${description}` : "";
const messageText =
`🔧 <b>Tool Approval Required</b>\n\n` +
`The agent wants to execute: <b>${skillName}</b>${descText}${payloadText}\n\n` +
`Do you want to allow this action?`;
const keyboard = {
inline_keyboard: [
[
{
text: "✅ Approve",
callback_data: `tool:approve:${requestId}`,
},
{ text: "❌ Deny", callback_data: `tool:deny:${requestId}` },
],
],
};
const sent = await this.#bot.sendMessage(chatId, messageText, {
parse_mode: "HTML",
reply_markup: keyboard,
});
this.#pendingToolApprovals.set(requestId, {
worker,
chatId,
messageId: sent.message_id,
skillName,
});
// Auto-cleanup if timeout expires (worker will also timeout)
setTimeout(() => {
if (this.#pendingToolApprovals.has(requestId)) {
this.#pendingToolApprovals.delete(requestId);
this.#bot
.editMessageText(
`⏱️ Tool approval for <b>${skillName}</b> timed out.`,
{
chat_id: chatId,
message_id: sent.message_id,
parse_mode: "HTML",
}
)
.catch(() => {});
}
}, timeoutMs + 1000);
} catch (error) {
this.#log("Failed to send tool approval request:", error.message);
// Send denial back to worker if we can't show the UI
try {
const response = {
type: "toolApprovalResponse",
requestId,
approved: false,
};
if (worker && typeof worker.send === "function") {
worker.send(response);
} else if (worker && typeof worker.postMessage === "function") {
worker.postMessage(response);
}
} catch {}
}
}
#shouldVoiceRespond(isVoiceMessage) {
if (!this.#config) return false;
const mode = this.#config.voice_response_mode || "text_only";

View File

@@ -0,0 +1,104 @@
/**
* Handle tool approval callback from an inline keyboard button.
* This handler requires access to the pending tool approvals map from TelegramBotService.
*
* @param {object} params
* @param {import("../../commands/index").BotContext} params.ctx
* @param {number} params.chatId
* @param {{id: string}} params.query
* @param {string} params.data
* @param {Map} params.pendingToolApprovals - Map of pending tool approval requests
* @param {Function} [params.log] - Logging function (falls back to ctx.log)
*/
async function handleToolApproval({
ctx,
chatId,
query,
data,
pendingToolApprovals,
log,
} = {}) {
const _log = log || ctx.log;
_log(`Tool approval callback received: ${data}`);
try {
const parts = data.split(":");
if (parts.length !== 3) {
_log(`Invalid callback data format: ${data}`);
await ctx.bot.answerCallbackQuery(query.id, {
text: "Invalid request format.",
});
return;
}
const action = parts[1]; // "approve" or "deny"
const requestId = parts[2];
const pending = pendingToolApprovals.get(requestId);
if (!pending) {
_log(`No pending approval found for requestId: ${requestId}`);
await ctx.bot.answerCallbackQuery(query.id, {
text: "This approval request has expired.",
});
return;
}
const { worker, messageId, skillName } = pending;
const approved = action === "approve";
_log(`Processing ${approved ? "approval" : "denial"} for ${skillName}`);
// Send response back to worker (Bree workers use send(), raw worker_threads use postMessage())
try {
const response = {
type: "toolApprovalResponse",
requestId,
approved,
};
if (worker && typeof worker.send === "function") {
worker.send(response);
_log(
`Sent tool approval response to worker via send(): ${approved ? "approved" : "denied"}`
);
} else if (worker && typeof worker.postMessage === "function") {
worker.postMessage(response);
_log(
`Sent tool approval response to worker via postMessage(): ${approved ? "approved" : "denied"}`
);
} else {
_log(
`Worker not available to send approval response (send: ${typeof worker?.send}, postMessage: ${typeof worker?.postMessage})`
);
}
} catch (err) {
_log(`Failed to send approval response: ${err.message}`);
}
pendingToolApprovals.delete(requestId);
// Update the message to show the result
const resultText = approved
? `✅ <b>${skillName}</b> was approved.`
: `❌ <b>${skillName}</b> was denied.`;
await ctx.bot
.editMessageText(resultText, {
chat_id: chatId,
message_id: messageId,
parse_mode: "HTML",
})
.catch((err) => _log(`Failed to edit message: ${err.message}`));
await ctx.bot.answerCallbackQuery(query.id, {
text: approved ? "Approved!" : "Denied.",
});
} catch (error) {
_log(`Error handling tool approval callback: ${error.message}`);
await ctx.bot.answerCallbackQuery(query.id, {
text: "Something went wrong.",
});
}
}
module.exports = { handleToolApproval };

View File

@@ -10,6 +10,7 @@ const { handleModelSelect } = require("./handleModelSelect");
const { handleSourceSelect } = require("./handleSourceSelect");
const { handleSourcePagination } = require("./handleSourcePagination");
const { handleBackSources } = require("./handleBackSources");
const { handleToolApproval } = require("./handleToolApproval");
const ExactCallbackHandlers = {
"ws-create": handleWorkspaceCreate,
@@ -20,6 +21,7 @@ const ExactCallbackHandlers = {
};
const PrefixCallbackHandlers = [
{ prefix: "tool:", handler: handleToolApproval },
{ prefix: "wspg:", handler: handleWorkspacePagination },
{ prefix: "ws:", handler: handleWorkspaceSelect },
{ prefix: "thpg:", handler: handleThreadPagination },

View File

@@ -2,11 +2,12 @@ const { isVerified } = require("../verification");
const { resolveCallbackHandler } = require("./callbacks");
/**
* Handle inline keyboard callback queries (workspace/thread selection).
* Handle inline keyboard callback queries (workspace/thread selection, tool approval, etc).
* @param {BotContext} ctx
* @param {object} query - Telegram callback query object
* @param {object} [options={}] - Optional dependencies that specific handlers may need
*/
async function handleKeyboardQueryCallback(ctx, query) {
async function handleKeyboardQueryCallback(ctx, query, options = {}) {
const chatId = query.message.chat.id;
const messageId = query.message.message_id;
const data = query.data;
@@ -21,7 +22,7 @@ async function handleKeyboardQueryCallback(ctx, query) {
try {
const handler = resolveCallbackHandler(data);
if (!handler) throw new Error(`Callback handler not found: ${data}`);
await handler({ ctx, chatId, query, messageId, data });
await handler({ ctx, chatId, query, messageId, data, ...options });
} catch (error) {
ctx.log("Callback error:", error.message);
await ctx.bot.answerCallbackQuery(query.id, {