diff --git a/extensions/telegram-manager/src/agents/BaseAgent.ts b/extensions/telegram-manager/src/agents/BaseAgent.ts index 26f8d9086d309..7d8c8bfd99485 100644 --- a/extensions/telegram-manager/src/agents/BaseAgent.ts +++ b/extensions/telegram-manager/src/agents/BaseAgent.ts @@ -1,7 +1,7 @@ +import EventEmitter from "events"; // plugins/telegram/src/agents/BaseAgent.ts import fs from "fs"; import path from "path"; -import EventEmitter from "events"; import { TelegramStorage } from "../storage/TelegramStorage"; import { AgentRecord, diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index 40f1d49e75b85..3c34a61885246 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -9,6 +9,7 @@ import { handleApproveCommand } from "./commands-approve.js"; import { handleBashCommand } from "./commands-bash.js"; import { handleCompactCommand } from "./commands-compact.js"; import { handleConfigCommand, handleDebugCommand } from "./commands-config.js"; +import { handleDialogueCommand } from "./commands-dialogue.js"; import { handleCommandsListCommand, handleContextCommand, @@ -56,6 +57,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise { + if (!allowTextCommands) { + return null; + } + const body = params.command.commandBodyNormalized.trim().toLowerCase(); + const firstWord = body.split(/\s+/)[0]; + + // ── /confirm | /agree | /yes ────────────────────────────────────────────── + if (CONFIRM_COMMANDS.has(firstWord)) { + if (!params.command.isAuthorizedSender) { + logVerbose(`Ignoring ${firstWord} from unauthorized sender`); + return { shouldContinue: false }; + } + const entry = params.sessionEntry; + const pending = entry?.pendingConfirmation; + if (!pending) { + return { + shouldContinue: false, + reply: { text: "ℹ️ There is nothing awaiting confirmation right now." }, + }; + } + if (params.storePath && params.sessionKey) { + await updateSessionStoreEntry({ + storePath: params.storePath, + sessionKey: params.sessionKey, + update: async () => ({ pendingConfirmation: undefined }), + }); + } + return { + shouldContinue: false, + reply: { + text: + `✅ Confirmed: "${pending.task}"\n\n` + + `Send your next message and the agent will proceed with the confirmed action.`, + }, + }; + } + + // ── /reject | /cancel | /no ─────────────────────────────────────────────── + if (REJECT_COMMANDS.has(firstWord)) { + if (!params.command.isAuthorizedSender) { + logVerbose(`Ignoring ${firstWord} from unauthorized sender`); + return { shouldContinue: false }; + } + const entry = params.sessionEntry; + const pending = entry?.pendingConfirmation; + if (!pending) { + return { + shouldContinue: false, + reply: { text: "ℹ️ There is nothing awaiting confirmation right now." }, + }; + } + if (params.storePath && params.sessionKey) { + await updateSessionStoreEntry({ + storePath: params.storePath, + sessionKey: params.sessionKey, + update: async () => ({ pendingConfirmation: undefined }), + }); + } + return { + shouldContinue: false, + reply: { + text: + `❌ Cancelled: "${pending.task}"\n\n` + + `Send your next message to tell the agent what you'd like to do instead.`, + }, + }; + } + + // ── /dialogue ───────────────────────────────────────────────────────────── + if (!body.startsWith(DIALOGUE_COMMAND_PREFIX)) { + return null; + } + if (!params.command.isAuthorizedSender) { + logVerbose("Ignoring /dialogue from unauthorized sender"); + return { shouldContinue: false }; + } + + const rest = body.slice(DIALOGUE_COMMAND_PREFIX.length).trim(); + + // /dialogue status + if (!rest || rest === "status") { + const entry = params.sessionEntry; + const pending = entry?.pendingConfirmation; + const confirmRequired = entry?.confirmationRequired ?? false; + const lines: string[] = ["📋 **Dialogue state**"]; + lines.push(confirmRequired ? "• Mode: confirmation-required ✅" : "• Mode: normal"); + if (pending) { + const age = Math.round((Date.now() - pending.sentAt) / 1000); + lines.push(`• Pending confirmation (${age}s ago): "${pending.task}"`); + lines.push('• Use /confirm or /reject to respond, or just type "yes"/"no".'); + } else { + lines.push("• No pending confirmation"); + } + return { shouldContinue: false, reply: { text: lines.join("\n") } }; + } + + // /dialogue confirm-required on|off + const crMatch = rest.match(/^confirm-required\s+(on|off|true|false|enable|disable)$/i); + if (crMatch) { + const value = /^(on|true|enable)$/i.test(crMatch[1]); + if (params.storePath && params.sessionKey) { + await updateSessionStoreEntry({ + storePath: params.storePath, + sessionKey: params.sessionKey, + update: async () => ({ confirmationRequired: value }), + }); + } + const label = value ? "enabled ✅" : "disabled"; + return { + shouldContinue: false, + reply: { + text: + `✅ Confirmation-required mode ${label}. The agent will now ` + + (value + ? "always ask for your agreement before taking significant actions." + : "proceed without requesting explicit confirmation."), + }, + }; + } + + return { + shouldContinue: false, + reply: { + text: + "Usage:\n" + + "• `/dialogue status` – show current dialogue state\n" + + "• `/dialogue confirm-required on|off` – toggle confirmation-required mode\n" + + "• `/confirm` (or /agree, /yes) – confirm a pending action\n" + + "• `/reject` (or /cancel, /no) – reject/cancel a pending action", + }, + }; +}; diff --git a/src/auto-reply/reply/dialogue-confirmation.test.ts b/src/auto-reply/reply/dialogue-confirmation.test.ts new file mode 100644 index 0000000000000..8c26c7305902d --- /dev/null +++ b/src/auto-reply/reply/dialogue-confirmation.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { + agentResponseRequestsConfirmation, + buildConfirmationRequiredSystemNote, + buildPendingConfirmationNote, + isConfirmationResponse, + isRejectionResponse, +} from "./dialogue-confirmation.js"; + +describe("isConfirmationResponse", () => { + it("returns true for 'yes'", () => { + expect(isConfirmationResponse("yes")).toBe(true); + }); + + it("returns true for 'ok'", () => { + expect(isConfirmationResponse("ok")).toBe(true); + }); + + it("returns true for Russian 'да'", () => { + expect(isConfirmationResponse("да")).toBe(true); + }); + + it("returns true for Russian 'ок'", () => { + expect(isConfirmationResponse("ок")).toBe(true); + }); + + it("returns true for 'confirm'", () => { + expect(isConfirmationResponse("confirm")).toBe(true); + }); + + it("returns true for 'yes!'", () => { + expect(isConfirmationResponse("yes!")).toBe(true); + }); + + it("returns true for 'согласен'", () => { + expect(isConfirmationResponse("согласен")).toBe(true); + }); + + it("returns false for empty string", () => { + expect(isConfirmationResponse("")).toBe(false); + }); + + it("returns false for unrelated text", () => { + expect(isConfirmationResponse("What time is it?")).toBe(false); + }); +}); + +describe("isRejectionResponse", () => { + it("returns true for 'no'", () => { + expect(isRejectionResponse("no")).toBe(true); + }); + + it("returns true for 'cancel'", () => { + expect(isRejectionResponse("cancel")).toBe(true); + }); + + it("returns true for Russian 'нет'", () => { + expect(isRejectionResponse("нет")).toBe(true); + }); + + it("returns true for 'stop'", () => { + expect(isRejectionResponse("stop")).toBe(true); + }); + + it("returns true for 'отмена'", () => { + expect(isRejectionResponse("отмена")).toBe(true); + }); + + it("returns false for empty string", () => { + expect(isRejectionResponse("")).toBe(false); + }); + + it("returns false for unrelated text", () => { + expect(isRejectionResponse("What time is it?")).toBe(false); + }); +}); + +describe("agentResponseRequestsConfirmation", () => { + it("returns true for 'Please confirm this action'", () => { + expect(agentResponseRequestsConfirmation("Please confirm this action.")).toBe(true); + }); + + it("returns true for 'Do you confirm?'", () => { + expect(agentResponseRequestsConfirmation("Do you confirm this?")).toBe(true); + }); + + it("returns true for 'Would you like me to proceed?'", () => { + expect(agentResponseRequestsConfirmation("Would you like me to proceed?")).toBe(true); + }); + + it("returns true for 'Shall I proceed?'", () => { + expect(agentResponseRequestsConfirmation("Shall I proceed?")).toBe(true); + }); + + it("returns true for Russian 'Подтвердите'", () => { + expect(agentResponseRequestsConfirmation("Подтвердите ваше решение.")).toBe(true); + }); + + it("returns false for regular text", () => { + expect(agentResponseRequestsConfirmation("I have completed the task.")).toBe(false); + }); + + it("returns false for empty string", () => { + expect(agentResponseRequestsConfirmation("")).toBe(false); + }); +}); + +describe("buildPendingConfirmationNote", () => { + it("includes the task in the note", () => { + const note = buildPendingConfirmationNote("delete all files"); + expect(note).toContain("delete all files"); + expect(note).toContain("awaiting user confirmation"); + }); +}); + +describe("buildConfirmationRequiredSystemNote", () => { + it("contains the confirmation-required instructions", () => { + const note = buildConfirmationRequiredSystemNote(); + expect(note).toContain("confirmation required"); + expect(note.toLowerCase()).toContain("explicitly ask the user to confirm"); + }); +}); diff --git a/src/auto-reply/reply/dialogue-confirmation.ts b/src/auto-reply/reply/dialogue-confirmation.ts new file mode 100644 index 0000000000000..7d424856cf668 --- /dev/null +++ b/src/auto-reply/reply/dialogue-confirmation.ts @@ -0,0 +1,227 @@ +/** + * Dialogue confirmation loop support. + * + * Provides helpers to: + * - Detect whether a user message is confirming or rejecting a pending action. + * - Detect whether an agent response is requesting confirmation from the user. + * - Build prompt notes that tell the agent about a pending confirmation state. + */ + +import type { SessionEntry } from "../../config/sessions.js"; +import { updateSessionStore } from "../../config/sessions.js"; + +/** Maximum character length for a task description in a pending confirmation entry. */ +const MAX_TASK_DESCRIPTION_LENGTH = 120; + +/** Words/phrases a user might send to confirm an action (English + Russian). */ +const CONFIRMATION_PATTERNS: RegExp[] = [ + /^(yes|yeah|yep|yup|sure|ok|okay|k|agree|confirmed|confirm|proceed|go\s+ahead|do\s+it|sounds\s+good|absolutely|definitely|affirmative|correct|right|exactly|perfect|great|fine)[\s!.]*$/i, + /^(да|ок|окей|согласен|согласна|подтверждаю|подтвердить|продолжай|продолжать|давай|верно|точно|отлично|хорошо)[\s!.]*$/i, + /\b(yes|confirm|proceed|agree)\b/i, + /\b(да|подтверждаю|согласен|согласна)\b/i, +]; + +/** Words/phrases a user might send to reject/cancel a pending action (English + Russian). */ +const REJECTION_PATTERNS: RegExp[] = [ + /^(no|nope|nah|cancel|reject|stop|abort|don'?t|never|negative|disagree|skip)[\s!.]*$/i, + /^(нет|не\s+надо|отмена|отменить|стоп|отказ|отказаться|не\s+соглашусь|не\s+буду)[\s!.]*$/i, + /\b(cancel|reject|abort|stop)\b/i, + /\b(нет|отмена|отказ)\b/i, +]; + +/** + * Patterns indicating an agent response is requesting explicit user confirmation. + */ +const AGENT_CONFIRMATION_REQUEST_PATTERNS: RegExp[] = [ + /\bplease\s+confirm\b/i, + /\bdo\s+you\s+(?:confirm|agree|approve)\b/i, + /\bwould\s+you\s+like\s+(?:me\s+to\s+)?proceed\b/i, + /\bshall\s+i\s+proceed\b/i, + /\bcan\s+i\s+proceed\b/i, + /\bneed\s+your\s+(?:confirmation|approval|agreement)\b/i, + /\bwaiting\s+for\s+(?:your\s+)?(?:confirmation|approval|agreement)\b/i, + /\bconfirm\s+(?:this|that|the|your)\b/i, + // Russian patterns (use (?:^|\s) instead of \b — \b doesn't work with Cyrillic in JS) + /(?:^|\s)подтвердите(?:\s|[,.:!?]|$)/i, + /(?:^|\s)вы\s+подтверждаете(?:\s|[,.:!?]|$)/i, + /(?:^|\s)ваше\s+подтверждение(?:\s|[,.:!?]|$)/i, + /(?:^|\s)нужно\s+ваше\s+(?:согласие|подтверждение)(?:\s|[,.:!?]|$)/i, +]; + +/** + * Returns true when the user message looks like an explicit confirmation. + */ +export function isConfirmationResponse(text: string): boolean { + const trimmed = text.trim(); + if (!trimmed) { + return false; + } + return CONFIRMATION_PATTERNS.some((re) => re.test(trimmed)); +} + +/** + * Returns true when the user message looks like an explicit rejection/cancellation. + */ +export function isRejectionResponse(text: string): boolean { + const trimmed = text.trim(); + if (!trimmed) { + return false; + } + return REJECTION_PATTERNS.some((re) => re.test(trimmed)); +} + +/** + * Returns true when the agent's response text contains a request for user confirmation. + * This is used to automatically enter "awaiting confirmation" state. + */ +export function agentResponseRequestsConfirmation(text: string): boolean { + if (!text.trim()) { + return false; + } + return AGENT_CONFIRMATION_REQUEST_PATTERNS.some((re) => re.test(text)); +} + +/** + * Builds a short note to prepend to the agent's prompt context when the session + * is currently awaiting user confirmation for a specific task. + */ +export function buildPendingConfirmationNote(task: string): string { + return ( + `[Dialogue state: awaiting user confirmation]\n` + + `You previously proposed the following action and asked the user to confirm:\n` + + `"${task}"\n` + + `The user has not yet confirmed or rejected this action.\n` + + `In your response, remind the user what you are waiting for and ask them to confirm ` + + `(e.g. reply "yes", "ok", "да") or cancel (e.g. reply "no", "cancel", "нет").\n` + + `Do NOT proceed with the action until the user explicitly confirms.` + ); +} + +/** + * Builds a short confirmation prompt that, when injected into the system prompt, + * instructs the agent to seek user agreement before completing significant actions. + */ +export function buildConfirmationRequiredSystemNote(): string { + return ( + `[Dialogue mode: confirmation required]\n` + + `Before completing any significant action, you must:\n` + + `1. Describe what you plan to do.\n` + + `2. Explicitly ask the user to confirm (e.g. "Please confirm by replying yes/да") before proceeding.\n` + + `3. Do NOT proceed until the user sends an explicit confirmation.\n` + + `4. If the user says "no", "cancel", or "нет", stop and ask what they would like to do instead.\n` + + `This ensures every important action has the user's agreement.` + ); +} + +/** + * When `pendingConfirmation` is set on the session and the raw user message is a + * confirmation or rejection, prepend the appropriate context to the body and clear + * the pending flag so the agent knows whether to proceed. + * + * Returns the (possibly modified) body string. + */ +export async function applyConfirmationContext(params: { + body: string; + rawUserMessage: string; + sessionEntry?: SessionEntry; + sessionStore?: Record; + sessionKey?: string; + storePath?: string; +}): Promise { + const { body, rawUserMessage, sessionEntry, sessionStore, sessionKey, storePath } = params; + const pending = sessionEntry?.pendingConfirmation; + if (!pending) { + return body; + } + + const confirmed = isConfirmationResponse(rawUserMessage); + const rejected = isRejectionResponse(rawUserMessage); + + if (!confirmed && !rejected) { + // Neither — leave body as-is; the system prompt note handles the reminder. + return body; + } + + // Clear pendingConfirmation from session + if (sessionEntry) { + sessionEntry.pendingConfirmation = undefined; + sessionEntry.updatedAt = Date.now(); + if (sessionStore && sessionKey) { + sessionStore[sessionKey] = sessionEntry; + } + if (storePath && sessionKey) { + await updateSessionStore(storePath, (store) => { + const existing = store[sessionKey]; + if (existing) { + store[sessionKey] = { + ...existing, + pendingConfirmation: undefined, + updatedAt: Date.now(), + }; + } + }); + } + } + + if (confirmed) { + const prefix = + `[User confirmed the pending action]\n` + + `Pending action: "${pending.task}"\n` + + `The user has explicitly confirmed. Please proceed with the action.\n\n`; + return `${prefix}${body}`; + } + + // rejected + const prefix = + `[User rejected/cancelled the pending action]\n` + + `Pending action: "${pending.task}"\n` + + `The user has explicitly rejected/cancelled. Do NOT proceed with this action.\n` + + `Ask the user what they would like to do instead.\n\n`; + return `${prefix}${body}`; +} + +/** + * After the agent returns a response, inspect the response text and set + * `pendingConfirmation` on the session if the agent is requesting confirmation. + * + * The `task` is derived from the command body that triggered the run. + */ +export async function setConfirmationPendingIfRequested(params: { + responseText: string; + commandBody: string; + sessionEntry?: SessionEntry; + sessionStore?: Record; + sessionKey?: string; + storePath?: string; +}): Promise { + const { responseText, commandBody, sessionEntry, sessionStore, sessionKey, storePath } = params; + + if (!agentResponseRequestsConfirmation(responseText)) { + return; + } + // Build a short task description from the command body (first MAX_TASK_DESCRIPTION_LENGTH chars) + const rawTask = commandBody.trim().slice(0, MAX_TASK_DESCRIPTION_LENGTH); + const task = rawTask || "pending action"; + + const pendingConfirmation = { task, sentAt: Date.now() }; + + if (sessionEntry) { + sessionEntry.pendingConfirmation = pendingConfirmation; + sessionEntry.updatedAt = Date.now(); + if (sessionStore && sessionKey) { + sessionStore[sessionKey] = sessionEntry; + } + } + if (storePath && sessionKey) { + await updateSessionStore(storePath, (store) => { + const existing = store[sessionKey]; + if (existing) { + store[sessionKey] = { + ...existing, + pendingConfirmation, + updatedAt: Date.now(), + }; + } + }); + } +} diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index e12342efcdc79..9fece2cc5a3a1 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -37,6 +37,12 @@ import type { GetReplyOptions, ReplyPayload } from "../types.js"; import { runReplyAgent } from "./agent-runner.js"; import { applySessionHints } from "./body.js"; import type { buildCommandContext } from "./commands.js"; +import { + applyConfirmationContext, + buildConfirmationRequiredSystemNote, + buildPendingConfirmationNote, + setConfirmationPendingIfRequested, +} from "./dialogue-confirmation.js"; import type { InlineDirectives } from "./directive-handling.js"; import { buildGroupChatContext, buildGroupIntro } from "./groups.js"; import { buildInboundMetaSystemPrompt, buildInboundUserContextPrefix } from "./inbound-meta.js"; @@ -188,7 +194,21 @@ export async function runPreparedReply( const inboundMetaPrompt = buildInboundMetaSystemPrompt( isNewSession ? sessionCtx : { ...sessionCtx, ThreadStarterBody: undefined }, ); - const extraSystemPrompt = [inboundMetaPrompt, groupChatContext, groupIntro, groupSystemPrompt] + // Inject dialogue confirmation notes into the system prompt when applicable. + const pendingConfirmation = sessionEntry?.pendingConfirmation; + const confirmationRequired = sessionEntry?.confirmationRequired; + const dialogueSystemNote = pendingConfirmation + ? buildPendingConfirmationNote(pendingConfirmation.task) + : confirmationRequired + ? buildConfirmationRequiredSystemNote() + : ""; + const extraSystemPrompt = [ + inboundMetaPrompt, + groupChatContext, + groupIntro, + groupSystemPrompt, + dialogueSystemNote, + ] .filter(Boolean) .join("\n\n"); const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""; @@ -248,6 +268,16 @@ export async function runPreparedReply( storePath, abortKey: command.abortKey, }); + // When awaiting confirmation, detect natural-language yes/no in the user message and + // prepend the appropriate context so the agent knows whether to proceed. + prefixedBodyBase = await applyConfirmationContext({ + body: prefixedBodyBase, + rawUserMessage: rawBodyTrimmed, + sessionEntry, + sessionStore, + sessionKey, + storePath, + }); const isGroupSession = sessionEntry?.chatType === "group" || sessionEntry?.chatType === "channel"; const isMainSession = !isGroupSession && sessionKey === normalizeMainKey(sessionCfg?.mainKey); prefixedBodyBase = await prependSystemEvents({ @@ -443,7 +473,7 @@ export async function runPreparedReply( }, }; - return runReplyAgent({ + const result = await runReplyAgent({ commandBody: prefixedCommandBody, followupRun, queueKey, @@ -469,4 +499,22 @@ export async function runPreparedReply( shouldInjectGroupIntro, typingMode, }); + + // After the agent responds, detect if its reply contains a confirmation request. + // If so, persist a pendingConfirmation flag so the next user message is handled + // as a confirmation/rejection response rather than a fresh request. + if (result) { + const payloads = Array.isArray(result) ? result : [result]; + const combinedText = payloads.map((p) => (typeof p.text === "string" ? p.text : "")).join("\n"); + await setConfirmationPendingIfRequested({ + responseText: combinedText, + commandBody: prefixedCommandBody, + sessionEntry, + sessionStore, + sessionKey, + storePath, + }); + } + + return result; } diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 25091cd065ea7..58dfeeb753690 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -112,6 +112,21 @@ export type SessionEntry = { lastThreadId?: string | number; skillsSnapshot?: SessionSkillSnapshot; systemPromptReport?: SessionSystemPromptReport; + /** + * Set when the agent is awaiting explicit user confirmation before proceeding with an action. + * Cleared once the user confirms or rejects. + */ + pendingConfirmation?: { + /** Short description of the action awaiting confirmation. */ + task: string; + /** Unix timestamp (ms) when the confirmation request was sent. */ + sentAt: number; + }; + /** + * When true the agent is instructed to always seek user agreement before + * completing significant actions (dialogue confirmation-required mode). + */ + confirmationRequired?: boolean; }; export function mergeSessionEntry(