-
Notifications
You must be signed in to change notification settings - Fork 51
fix(agent): el bot ya no deja al cliente en seco cuando la IA falla #75
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -17,6 +17,7 @@ import { CustomerFactsRepo } from "./db/facts"; | |||||||
| import { createModel } from "./llm/provider"; | ||||||||
| import { formatLlmError } from "./llm/errorDetail"; | ||||||||
| import { runLlmTurn } from "./llm/runTurn"; | ||||||||
| import { llmFailureReply } from "./failureReply"; | ||||||||
| import { costOfUsage } from "./pricing"; | ||||||||
| import type { ChannelId } from "./channels/shared"; | ||||||||
| import { maskTelegramToken, unmaskTelegramToken } from "./telegramFiles"; | ||||||||
|
|
@@ -413,8 +414,53 @@ export class SupportAgent extends Agent<Env, SupportAgentState> { | |||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| // Turno con FOTO: si ningún modelo pudo con el mensaje multimodal (la | ||||||||
| // imagen no se pudo descargar, el modelo no tiene visión, el proveedor la | ||||||||
| // rechazó), el turno sigue con PURO TEXTO. El cliente casi siempre | ||||||||
| // escribió algo junto a la foto ("quiero info de esta propiedad"): con | ||||||||
| // eso el bot puede conversar y preguntar qué muestra la foto, en vez de | ||||||||
| // tumbar el turno completo por la imagen. | ||||||||
| if (!ok) { | ||||||||
| assistantText = "Algo falló de mi lado, intenta de nuevo en un momento."; | ||||||||
| const last: any = aiMessages[aiMessages.length - 1]; | ||||||||
| if (Array.isArray(last?.content)) { | ||||||||
| const textPart = last.content.find((p: any) => p?.type === "text"); | ||||||||
| aiMessages[aiMessages.length - 1] = { | ||||||||
| role: "user", | ||||||||
| content: | ||||||||
| `${textPart?.text ?? ""}\n[El cliente mandó una FOTO que no pudiste abrir. Sigue la conversación con naturalidad: ` + | ||||||||
| `pídele que te cuente qué se ve en ella y ofrécele ayuda concreta. No menciones fallas técnicas.]`.trim(), | ||||||||
| }; | ||||||||
| try { | ||||||||
| await attempt(model); | ||||||||
| ok = true; | ||||||||
| console.warn("[SupportAgent] turno con foto rescatado en modo solo-texto"); | ||||||||
| } catch (eTxt: any) { | ||||||||
| console.error("[SupportAgent] reintento solo-texto falló:", formatLlmError(eTxt)); | ||||||||
| } | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| if (!ok) { | ||||||||
| assistantText = llmFailureReply(this.env.BOT_LANGUAGE); | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: rg -n 'takeover|paused|human|handoff|botPaused|status' src/agent.ts src/tools/handoffHuman.ts src/db/conversations.ts | head -80Repository: santmun/forja Length of output: 2311 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- src/agent.ts: relevant structure and ranges ---'
ast-grep outline src/agent.ts
printf '%s\n' '--- src/agent.ts: lines 60-190 ---'
sed -n '60,190p' src/agent.ts
printf '%s\n' '--- src/agent.ts: lines 400-470 ---'
sed -n '400,470p' src/agent.ts
printf '%s\n' '--- src/tools/handoffHuman.ts: lines 1-190 ---'
sed -n '1,190p' src/tools/handoffHuman.ts
printf '%s\n' '--- src/db/conversations.ts: lines 1-100 ---'
sed -n '1,100p' src/db/conversations.ts
printf '%s\n' '--- references to ingest/processBuffer/pause/handoff ---'
rg -n -C 4 'ingest|processBuffer|setPausedUntil|botPaused|handoffHumanTool|notifyOwner' srcRepository: santmun/forja Length of output: 42912 🏁 Script executed: set -e
printf '%s\n' '--- agent flow ---'
sed -n '60,190p' src/agent.ts
sed -n '400,470p' src/agent.ts
printf '%s\n' '--- handoff tool ---'
sed -n '1,190p' src/tools/handoffHuman.ts
printf '%s\n' '--- conversation persistence ---'
sed -n '1,100p' src/db/conversations.ts
printf '%s\n' '--- bound callers and state checks ---'
rg -n -C 4 'ingest|processBuffer|setPausedUntil|botPaused|handoffHumanTool|notifyOwner' srcRepository: santmun/forja Length of output: 42297 Pause the conversation before promising human handoff. The final-failure branch calls Set Suggested fix if (!ok) {
assistantText = llmFailureReply(this.env.BOT_LANGUAGE);
+ await convs.setPausedUntil(convId, Date.now() + 60 * 60 * 1000);
// Aviso INMEDIATO al dueño: un cliente real se quedó sin respuesta.📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||
| // Aviso INMEDIATO al dueño: un cliente real se quedó sin respuesta. El | ||||||||
| // watchdog solo alerta con 3+ fallos en 30 min, así que con 1 o 2 | ||||||||
| // nadie se enteraba y el lead se enfriaba. | ||||||||
| try { | ||||||||
| const { notifyOwner } = await import("./tools/handoffHuman"); | ||||||||
| const lastUser = (history[history.length - 1]?.content ?? "") | ||||||||
| .replace(/\[IMAGE_URL: [^\]]+\]/g, "[foto]") | ||||||||
| .replace(/\s+/g, " ") | ||||||||
| .trim(); | ||||||||
| await notifyOwner(this.env, { | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift Do not make the customer reply wait on owner notification.
🤖 Prompt for AI Agents |
||||||||
| reason: "el bot no pudo responder", | ||||||||
| summary: | ||||||||
| `Cliente ${this.state.channelUserId} (${this.state.channel}) se quedó esperando. ` + | ||||||||
| `Escribió: "${lastUser.slice(0, 200)}". Contéstale tú desde el panel.`, | ||||||||
| ticketId: `fallo-${convId}`, | ||||||||
| }); | ||||||||
| } catch (eN) { | ||||||||
| console.error("[SupportAgent] aviso de fallo al dueño falló:", eN); | ||||||||
| } | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
|
|
||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| /** | ||
| * Respuesta cuando el LLM falló del todo (primario + retries + fallback). | ||
| * | ||
| * Antes era "Algo falló de mi lado, intenta de nuevo en un momento.": delataba | ||
| * al bot y dejaba al cliente en seco, pidiéndole que él reintentara. Ahora | ||
| * suena a persona y promete lo que sí pasa: el dueño recibe aviso en ese mismo | ||
| * momento (agent.ts → notifyOwner) y contesta él. | ||
| * | ||
| * El watchdog cuenta los fallos por estos textos (y por el viejo, para que el | ||
| * historial previo al cambio siga contando). | ||
| */ | ||
| export const LLM_FAILURE_REPLIES = { | ||
| es: "Gracias por escribirnos. Te paso con una persona del equipo para ayudarte; te responde en breve por aquí.", | ||
| en: "Thanks for reaching out. I'm passing you to someone on our team; they'll reply here shortly.", | ||
|
Comment on lines
+13
to
+14
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '90,200p' src/tools/handoffHuman.tsRepository: santmun/forja Length of output: 4068 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- symbol references ---'
rg -n -C 5 'LLM_FAILURE_REPLIES|llmFailureReply|notifyOwner|handoffHuman|HandoffNotice|admin/tickets|ticket.*created|create.*ticket|tickets' src
printf '%s\n' '--- handoffHuman outline ---'
ast-grep outline src/tools/handoffHuman.ts --view expanded
printf '%s\n' '--- failureReply ---'
cat -n src/failureReply.tsRepository: santmun/forja Length of output: 41974 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- agent failure and persistence flow ---'
sed -n '380,510p' src/agent.ts
printf '%s\n' '--- agent message persistence references ---'
rg -n -C 6 'assistantText|addMessage|messages\.create|MessagesRepo|conversation.*message|save.*message' src/agent.ts
printf '%s\n' '--- admin conversation list and detail ---'
sed -n '110,210p' src/admin/views/conversations.ts
sed -n '240,330p' src/admin/views/conversations.ts
printf '%s\n' '--- admin route bindings ---'
rg -n -C 4 'conversations|renderConversations|/admin/conversations' src/admin/routes.ts src/admin/views/conversations.tsRepository: santmun/forja Length of output: 39785 Do not promise a prompt human reply without owner notification. When no notification channel is configured, Select this wording only when owner notification succeeds. Otherwise, use wording that does not promise a prompt human reply. 🤖 Prompt for AI Agents |
||
| } as const; | ||
|
|
||
| export const LLM_FAILURE_LEGACY_PREFIX = "Algo falló"; | ||
|
|
||
| export function llmFailureReply(lang: string | undefined): string { | ||
| return (lang ?? "").toLowerCase().startsWith("en") ? LLM_FAILURE_REPLIES.en : LLM_FAILURE_REPLIES.es; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
|
|
||
| // Cuando la IA falla, el bot no deja al cliente en seco: | ||
| // 1. Turno con FOTO que ningún modelo pudo abrir → reintento con puro texto. | ||
| // 2. Falla total → respuesta con tono humano (no "Algo falló de mi lado") y | ||
| // aviso INMEDIATO al dueño con quién se quedó esperando. | ||
| // Mismo harness que test/agent.media.test.ts (agents/ai mockeados, sin red). | ||
|
|
||
| vi.mock("agents", () => ({ | ||
| Agent: class { | ||
| ctx: any; | ||
| env: any; | ||
| state: any; | ||
| constructor(ctx: any, env: any) { | ||
| this.ctx = ctx; | ||
| this.env = env; | ||
| } | ||
| setState(s: any) { | ||
| this.state = s; | ||
| } | ||
| sql(..._args: any[]) { | ||
| return undefined; | ||
| } | ||
| }, | ||
| })); | ||
|
|
||
| const streamTextMock = vi.fn(); | ||
| const generateTextMock = vi.fn(); | ||
| vi.mock("ai", () => ({ | ||
| streamText: (...args: any[]) => streamTextMock(...args), | ||
| generateText: (...args: any[]) => generateTextMock(...args), | ||
| tool: (def: any) => def, | ||
| })); | ||
| vi.mock("@ai-sdk/anthropic", () => ({ | ||
| createAnthropic: () => (modelId: string) => ({ modelId }), | ||
| })); | ||
|
|
||
| const notifyOwnerMock = vi.fn(async () => {}); | ||
| vi.mock("../src/tools/handoffHuman", async (importOriginal) => ({ | ||
| ...(await importOriginal<typeof import("../src/tools/handoffHuman")>()), | ||
| notifyOwner: (...args: any[]) => (notifyOwnerMock as any)(...args), | ||
| })); | ||
|
|
||
| import { SupportAgent } from "../src/agent"; | ||
| import { ConversationsRepo } from "../src/db/conversations"; | ||
| import { MessagesRepo } from "../src/db/messages"; | ||
| import { SettingsRepo } from "../src/db/settings"; | ||
| import * as senderMod from "../src/replies/sender"; | ||
| import { LLM_FAILURE_REPLIES } from "../src/failureReply"; | ||
|
|
||
| function okResult(text: string) { | ||
| async function* gen() { | ||
| yield text; | ||
| } | ||
| return { | ||
| textStream: gen(), | ||
| usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, cachedInputTokens: 0 }), | ||
| steps: Promise.resolve([{ toolCalls: [] }]), | ||
| }; | ||
| } | ||
|
|
||
| const lastIsImage = (args: any) => Array.isArray(args?.messages?.at(-1)?.content); | ||
|
|
||
| function makeAgent() { | ||
| const env: any = { | ||
| DB: {}, | ||
| AI: { run: vi.fn(async () => ({ text: "" })) }, | ||
| ANTHROPIC_API_KEY: "sk-test", | ||
| BOT_TIER: "pro", | ||
| BOT_LANGUAGE: "es", | ||
| BUFFER_SECONDS: "8", | ||
| BOT_NAME: "TestBot", | ||
| BUSINESS_NAME: "TestCo", | ||
| }; | ||
| const agent: any = new (SupportAgent as any)({ storage: { setAlarm: vi.fn(), getAlarm: vi.fn() } }, env); | ||
| agent.setState({ | ||
| conversationId: "conv-1", | ||
| channel: "telegram", | ||
| channelUserId: "5215512345678", | ||
| pendingMessages: [], | ||
| lastAlarmAt: 0, | ||
| lastUserLang: "es", | ||
| toolCallsInLast2Turns: 0, | ||
| lastSearchKbScore: 1, | ||
| imageRetryCount: 0, | ||
| }); | ||
| return agent; | ||
| } | ||
|
|
||
| function stubTurn(lastUserContent: string) { | ||
| const sendReply = vi.fn(async () => {}); | ||
| vi.spyOn(SettingsRepo.prototype, "all").mockResolvedValue({}); | ||
| vi.spyOn(MessagesRepo.prototype, "append").mockResolvedValue(undefined as any); | ||
| vi.spyOn(MessagesRepo.prototype, "lastN").mockResolvedValue([ | ||
| { role: "user", content: lastUserContent }, | ||
| ] as any); | ||
| vi.spyOn(ConversationsRepo.prototype, "touchLastMessage").mockResolvedValue(undefined as any); | ||
| vi.spyOn(senderMod, "pickAdapter").mockReturnValue({ sendReply } as any); | ||
| return sendReply; | ||
| } | ||
|
|
||
| const sentText = (sendReply: any) => | ||
| ((sendReply.mock.calls.at(-1)?.at(0) as { chunks: string[] } | undefined)?.chunks ?? []).join(" ").replace(/\s+/g, " "); | ||
|
|
||
| describe("el bot no deja al cliente en seco cuando la IA falla", () => { | ||
| beforeEach(() => { | ||
| vi.restoreAllMocks(); | ||
| streamTextMock.mockReset(); | ||
| generateTextMock.mockReset(); | ||
| notifyOwnerMock.mockClear(); | ||
| }); | ||
|
|
||
| it("foto que ningún modelo pudo abrir → sigue la conversación con puro texto", async () => { | ||
| const agent = makeAgent(); | ||
| const sendReply = stubTurn( | ||
| "Me gustaría información de esta propiedad\n[IMAGE_URL: https://example.com/lona.jpg]", | ||
| ); | ||
| // Con la foto adjunta todo revienta (p. ej. la imagen no se pudo descargar); | ||
| // con puro texto el modelo contesta normal. | ||
| const fail = () => { | ||
| throw Object.assign(new Error("Error while downloading https://example.com/lona.jpg"), { | ||
| name: "AI_APICallError", | ||
| statusCode: 400, | ||
| }); | ||
| }; | ||
| streamTextMock.mockImplementation((args: any) => | ||
| lastIsImage(args) ? fail() : okResult("¡Claro! ¿Me dices dónde viste el anuncio?"), | ||
| ); | ||
| generateTextMock.mockImplementation(async (args: any) => | ||
| lastIsImage(args) ? fail() : { text: "¡Claro! ¿Me dices dónde viste el anuncio?", usage: {}, steps: [] }, | ||
| ); | ||
|
|
||
| agent.state.pendingMessages = [{ text: "Me gustaría información", receivedAt: Date.now() }]; | ||
| await agent.processBuffer(); | ||
|
|
||
| const reintento = [...streamTextMock.mock.calls, ...generateTextMock.mock.calls] | ||
| .map((c) => c[0]) | ||
| .find((a) => !lastIsImage(a)); | ||
| expect(reintento.messages.at(-1).content).toContain("Me gustaría información de esta propiedad"); | ||
| expect(reintento.messages.at(-1).content).toContain("FOTO que no pudiste abrir"); | ||
| expect(sentText(sendReply)).toContain("¿Me dices dónde viste el anuncio?"); | ||
| expect(notifyOwnerMock).not.toHaveBeenCalled(); | ||
| }, 20_000); | ||
|
|
||
| it("falla total → respuesta humana + aviso inmediato al dueño", async () => { | ||
| const agent = makeAgent(); | ||
| const sendReply = stubTurn("Hola, ¿cuánto cuesta?"); | ||
| const boom = () => { | ||
| throw Object.assign(new Error("rate limit"), { name: "AI_APICallError", statusCode: 429 }); | ||
| }; | ||
| streamTextMock.mockImplementation(boom); | ||
| generateTextMock.mockImplementation(async () => boom()); | ||
|
|
||
| agent.state.pendingMessages = [{ text: "Hola, ¿cuánto cuesta?", receivedAt: Date.now() }]; | ||
| await agent.processBuffer(); | ||
|
|
||
| expect(sentText(sendReply)).toBe(LLM_FAILURE_REPLIES.es); | ||
| expect(sentText(sendReply)).not.toMatch(/Algo falló/); | ||
| expect(notifyOwnerMock).toHaveBeenCalledTimes(1); | ||
| const [, notice] = notifyOwnerMock.mock.calls[0] as unknown as [unknown, { reason: string; summary: string }]; | ||
| expect(notice.reason).toBe("el bot no pudo responder"); | ||
| expect(notice.summary).toContain("5215512345678"); | ||
| expect(notice.summary).toContain("Hola, ¿cuánto cuesta?"); | ||
| }, 20_000); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
sed -n '360,470p' src/agent.tsRepository: santmun/forja
Length of output: 5027
🏁 Script executed:
Repository: santmun/forja
Length of output: 25551
Retry the text-only message with the fallback model.
modelis the primary model. The text-only rescue callsattempt(model)only. If the fallback rejected the image but can process text, this path misses a successful response.Suggested fix
} catch (eTxt: any) { console.error("[SupportAgent] reintento solo-texto falló:", formatLlmError(eTxt)); } + if (!ok && fb) { + try { + await attempt(fb.model); + usedModelId = fb.modelId; + ok = true; + } catch (eTxtFb: any) { + console.error("[SupportAgent] fallback solo-texto falló:", formatLlmError(eTxtFb)); + } + } }🤖 Prompt for AI Agents