diff --git a/.env.example b/.env.example index 6de6d60..133a7df 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ # OpenAI OPENAI_API_KEY=sk-pon-tu-api-key -# Opcional. Si lo dejas vacío, el SDK usará su modelo por defecto. +# Opcional. Si lo dejas vacío se usa gpt-5.4-mini (valor por defecto explícito, no el del SDK). OPENAI_MODEL= # ResponseGrid API (compartida por todas las cuentas; las credenciales por cuenta viven en accounts.json) diff --git a/README.es.md b/README.es.md index 27aa134..adcfa08 100644 --- a/README.es.md +++ b/README.es.md @@ -94,7 +94,7 @@ Edita `.env`: ```env OPENAI_API_KEY=sk-... -# Opcional. Si lo dejas vacío, el SDK usará su modelo por defecto. +# Opcional. Si lo dejas vacío se usa gpt-5.4-mini (valor por defecto explícito, no el del SDK). OPENAI_MODEL= API_BASE_URL=https://api.responsegrid.app diff --git a/README.md b/README.md index 686d3ec..5c974ae 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ Two files, both git-ignored (only their `*.example.*` counterparts are tracked): ```env OPENAI_API_KEY=sk-... -OPENAI_MODEL= # optional; empty = SDK default +OPENAI_MODEL= # optional; empty = gpt-5.4-mini (explicit default, not the SDK's) API_BASE_URL=https://api.responsegrid.app # Only if you have any channel="whatsapp" account: diff --git a/package-lock.json b/package-lock.json index b547305..1e39d63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,27 +1,28 @@ { - "name": "telegram-openai-agent-ts", + "name": "responsegrid-chatbot", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "telegram-openai-agent-ts", + "name": "responsegrid-chatbot", "version": "0.1.0", + "license": "AGPL-3.0-only", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", - "@openai/agents": "latest", - "dotenv": "latest", - "openai": "latest", - "telegraf": "latest", + "@openai/agents": "^0.12.0", + "dotenv": "^17.4.2", + "openai": "^6.45.0", + "telegraf": "^4.16.3", "zod": "^4.0.0" }, "devDependencies": { - "@types/node": "latest", - "tsx": "latest", - "typescript": "latest" + "@types/node": "^26.1.0", + "tsx": "^4.22.5", + "typescript": "^6.0.3" }, "engines": { - "node": ">=22" + "node": ">=24" } }, "node_modules/@esbuild/aix-ppc64": { diff --git a/package.json b/package.json index 7c8bfdd..a070e29 100644 --- a/package.json +++ b/package.json @@ -16,16 +16,16 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", - "@openai/agents": "latest", - "dotenv": "latest", - "openai": "latest", - "telegraf": "latest", + "@openai/agents": "^0.12.0", + "dotenv": "^17.4.2", + "openai": "^6.45.0", + "telegraf": "^4.16.3", "zod": "^4.0.0" }, "devDependencies": { - "@types/node": "latest", - "tsx": "latest", - "typescript": "latest" + "@types/node": "^26.1.0", + "tsx": "^4.22.5", + "typescript": "^6.0.3" }, "engines": { "node": ">=24" diff --git a/src/agent/agent.ts b/src/agent/agent.ts index db4cd91..577b398 100644 --- a/src/agent/agent.ts +++ b/src/agent/agent.ts @@ -54,7 +54,8 @@ Estilo: export const apiAgent = new Agent({ name: "ResponseGrid Agent", - ...(env.openaiModel ? { model: env.openaiModel } : {}), + // Siempre explícito (string): el SDK aplica así los modelSettings por defecto del modelo. + model: env.openaiModel, instructions: async (runContext) => buildInstructions(runContext.context.account), tools: agentTools, }); diff --git a/src/application/conversation-service.test.ts b/src/application/conversation-service.test.ts index 364b91c..61964fc 100644 --- a/src/application/conversation-service.test.ts +++ b/src/application/conversation-service.test.ts @@ -1,7 +1,9 @@ import test from "node:test"; import assert from "node:assert"; import { ConversationService, MAX_TEXT_LENGTH, isCorruptedHistoryError, isTransientError } from "./conversation-service.js"; +import { ModelIncompleteResponseError } from "./model-incomplete-response-error.js"; import { RateLimiter } from "./rate-limiter.js"; +import type { ConversationLogFields } from "../infrastructure/observability/conversation-logger.js"; import type { Account } from "../domain/account.js"; import type { MessagingChannel, SelectionOption } from "../domain/ports/messaging-channel.port.js"; import type { AuthStore } from "../domain/ports/auth-store.port.js"; @@ -273,6 +275,33 @@ test("ConversationService · si el reintento también falla, avisa sin lanzar", assert.match(sent[0].text, /problema técnico temporal/); }); +test("isTransientError no trata una respuesta incompleta del modelo como transitoria", () => { + assert.ok(!isTransientError(new ModelIncompleteResponseError("max_output_tokens").message)); +}); + +test("ConversationService · respuesta incompleta del modelo: registra la causa, no reintenta y avisa", async () => { + const authStore = makeFakeAuthStore(); + const logs: ConversationLogFields[] = []; + let calls = 0; + const service = new ConversationService( + { getSession: () => makeFakeSession(), authStore, log: (fields) => void logs.push(fields) }, + async () => { + calls += 1; + throw new ModelIncompleteResponseError("max_output_tokens"); + }, + ); + const { channel, sent } = makeFakeChannel(); + + await service.handle({ account, chatId: "904", text: "consulta" }, channel); // no debe lanzar + + assert.strictEqual(calls, 1, "no reintenta"); + const errors = logs.filter((l) => l.kind === "error"); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].error, "model-incomplete (max_output_tokens)"); + assert.strictEqual(sent.length, 1); + assert.match(sent[0].text, /problema procesando/); +}); + test("ConversationService · error no transitorio: avisa sin reintentar ni lanzar", async () => { const authStore = makeFakeAuthStore(); let calls = 0; diff --git a/src/application/conversation-service.ts b/src/application/conversation-service.ts index 4c6746c..62c2f13 100644 --- a/src/application/conversation-service.ts +++ b/src/application/conversation-service.ts @@ -9,12 +9,16 @@ import type { ConversationStore } from "../domain/ports/conversation-store.port. import type { AuthStore } from "../domain/ports/auth-store.port.js"; import { ApiClient } from "../infrastructure/responsegrid/api-client.js"; import { logConversation, type ConversationLogFields } from "../infrastructure/observability/conversation-logger.js"; +import { ModelIncompleteResponseError } from "./model-incomplete-response-error.js"; import type { RateLimiter } from "./rate-limiter.js"; import { detectGreeting, WELCOME } from "./welcome.js"; /** Longitud máxima de un mensaje de texto que se procesa (protege coste/abuso). */ export const MAX_TEXT_LENGTH = 8000; +/** Aviso genérico al usuario ante un error no recuperable del run. */ +const GENERIC_ERROR_REPLY = "Perdona, ha habido un problema procesando tu mensaje. Inténtalo de nuevo, por favor."; + /** * Detecta el error de OpenAI por historial con un par de tool incompleto * ("No tool call found for function call output ..."), que bloquea cada turno @@ -145,6 +149,13 @@ export class ConversationService { const message = error instanceof Error ? error.message : String(error); log({ kind: "error", ...logBase, ms: Date.now() - startedAt, error: message }); + // Respuesta incompleta y vacía del modelo: no es transitoria (reintentar repite el + // fallo). Ya queda registrada arriba como "model-incomplete ()". + if (error instanceof ModelIncompleteResponseError) { + await channel.sendText(chatId, GENERIC_ERROR_REPLY); + return; + } + // Historial con un par de tool incompleto: OpenAI rechaza cada turno y el // usuario queda bloqueado. Reiniciamos la sesión y pedimos repetir. if (isCorruptedHistoryError(message)) { @@ -174,10 +185,7 @@ export class ConversationService { } } else { // Cualquier otro error: nunca dejar al usuario en silencio. - await channel.sendText( - chatId, - "Perdona, ha habido un problema procesando tu mensaje. Inténtalo de nuevo, por favor.", - ); + await channel.sendText(chatId, GENERIC_ERROR_REPLY); return; } } diff --git a/src/application/model-incomplete-response-error.ts b/src/application/model-incomplete-response-error.ts new file mode 100644 index 0000000..e0e3327 --- /dev/null +++ b/src/application/model-incomplete-response-error.ts @@ -0,0 +1,18 @@ +/** + * El modelo devolvió una respuesta incompleta SIN ninguna salida (status "incomplete" y + * output vacío). No es transitorio: repetir la misma petición vuelve a fallar igual + * (p. ej. un schema de tool rechazado agota max_output_tokens sin generar nada), así que + * no se reintenta. Antes el SDK lo tomaba como "sin salida final", repetía turnos y + * acababa en un MaxTurnsExceededError que ocultaba la causa (incidente 2026-09-11). + */ +export class ModelIncompleteResponseError extends Error { + override readonly name = "ModelIncompleteResponseError"; + + constructor( + /** Motivo que da la API (`incomplete_details.reason`), p. ej. "max_output_tokens". */ + readonly reason: string, + readonly responseId?: string, + ) { + super(`model-incomplete (${reason})`); + } +} diff --git a/src/config/env.test.ts b/src/config/env.test.ts new file mode 100644 index 0000000..edaa09c --- /dev/null +++ b/src/config/env.test.ts @@ -0,0 +1,17 @@ +import test from "node:test"; +import assert from "node:assert"; +import { DEFAULT_OPENAI_MODEL, resolveOpenAIModel } from "./env.js"; + +test("resolveOpenAIModel", async (t) => { + await t.test("sin OPENAI_MODEL usa el modelo por defecto explícito (no el del SDK)", () => { + assert.strictEqual(DEFAULT_OPENAI_MODEL, "gpt-5.4-mini"); + assert.strictEqual(resolveOpenAIModel(undefined), "gpt-5.4-mini"); + assert.strictEqual(resolveOpenAIModel(""), "gpt-5.4-mini"); + assert.strictEqual(resolveOpenAIModel(" "), "gpt-5.4-mini"); + }); + + await t.test("respeta OPENAI_MODEL si está definido", () => { + assert.strictEqual(resolveOpenAIModel("gpt-5.5"), "gpt-5.5"); + assert.strictEqual(resolveOpenAIModel(" gpt-5.5 "), "gpt-5.5"); + }); +}); diff --git a/src/config/env.ts b/src/config/env.ts index 366f593..b52950c 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -20,9 +20,20 @@ function optionalInt(name: string): number | undefined { return value ? Number.parseInt(value, 10) : undefined; } +/** + * Modelo por defecto explícito (el verificado en producción). No delegamos en el + * default del SDK: ya ha cambiado entre versiones de @openai/agents. + */ +export const DEFAULT_OPENAI_MODEL = "gpt-5.4-mini"; + +/** OPENAI_MODEL si está definido y no vacío; si no, DEFAULT_OPENAI_MODEL. */ +export function resolveOpenAIModel(value: string | undefined): string { + return value && value.trim() !== "" ? value.trim() : DEFAULT_OPENAI_MODEL; +} + export const env = { openaiApiKey: required("OPENAI_API_KEY"), - openaiModel: optional("OPENAI_MODEL"), + openaiModel: resolveOpenAIModel(process.env.OPENAI_MODEL), // ResponseGrid / API externa — una sola instancia compartida por todas las cuentas. apiBaseUrl: optional("API_BASE_URL"), diff --git a/src/index.ts b/src/index.ts index 9432dab..da025fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { loadAccountsFromFile } from "./config/accounts.js"; import { AccountRegistry } from "./application/account-registry.js"; import { ConversationService } from "./application/conversation-service.js"; import { RateLimiter } from "./application/rate-limiter.js"; +import { createOpenAIRunner } from "./infrastructure/openai/incomplete-response-guard.js"; import { FileSessionRepository } from "./infrastructure/persistence/file-session-store.js"; import { JsonTokenStore } from "./infrastructure/persistence/json-token-store.js"; import { startTelegramBots } from "./infrastructure/telegram/telegram-bootstrap.js"; @@ -16,11 +17,17 @@ const registry = new AccountRegistry(loadAccountsFromFile(accountsFile)); const sessions = new FileSessionRepository(join(process.cwd(), ".sessions")); const authStore = new JsonTokenStore(join(process.cwd(), ".sessions", "tokens.json")); -const conversationService = new ConversationService({ - getSession: (account, chatId) => sessions.getOrCreate(account, chatId), - authStore, - rateLimiter: new RateLimiter(), -}); +// Runner con guard de respuestas incompletas del modelo (falla rápido en vez de agotar turnos). +const runner = createOpenAIRunner(); + +const conversationService = new ConversationService( + { + getSession: (account, chatId) => sessions.getOrCreate(account, chatId), + authStore, + rateLimiter: new RateLimiter(), + }, + (agent, input, options) => runner.run(agent, input, options), +); const telegramBots = startTelegramBots(registry, conversationService, sessions, authStore); // Telegraf v4: bot.launch() no resuelve hasta que el bot se DETIENE, así que NO se puede diff --git a/src/infrastructure/openai/incomplete-response-guard.test.ts b/src/infrastructure/openai/incomplete-response-guard.test.ts new file mode 100644 index 0000000..319f314 --- /dev/null +++ b/src/infrastructure/openai/incomplete-response-guard.test.ts @@ -0,0 +1,150 @@ +import test from "node:test"; +import assert from "node:assert"; +import { + Agent, + MaxTurnsExceededError, + Runner, + Usage, + setTracingDisabled, + type Model, + type ModelProvider, + type ModelResponse, +} from "@openai/agents"; +import { ModelIncompleteResponseError } from "../../application/model-incomplete-response-error.js"; +import { + IncompleteResponseGuardModel, + IncompleteResponseGuardModelProvider, + type IncompletePartialResponse, +} from "./incomplete-response-guard.js"; + +// Ningún test llama a OpenAI: el modelo es un doble que devuelve respuestas fijas. +// `tracingDisabled` del Runner no basta: el run abre igualmente una traza global que el +// exportador por defecto enviaría a OpenAI. Se desactiva el tracing para este proceso. +setTracingDisabled(true); + +/** Respuesta tal y como la vio producción el 2026-09-11: incompleta, sin output y 0 tokens. */ +const emptyIncomplete: ModelResponse = { + usage: new Usage(), + output: [], + responseId: "resp_vacia", + providerData: { id: "resp_vacia", status: "incomplete", incomplete_details: { reason: "max_output_tokens" }, output: [] }, +}; + +const partialIncomplete: ModelResponse = { + usage: new Usage(), + output: [ + { + type: "message", + role: "assistant", + status: "incomplete", + content: [{ type: "output_text", text: "Respuesta truncada…" }], + }, + ], + responseId: "resp_truncada", + providerData: { id: "resp_truncada", status: "incomplete", incomplete_details: { reason: "max_output_tokens" } }, +}; + +const completed: ModelResponse = { + usage: new Usage(), + output: [ + { + type: "message", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "Hola" }], + }, + ], + responseId: "resp_ok", + providerData: { id: "resp_ok", status: "completed" }, +}; + +function fakeModel(response: ModelResponse) { + let calls = 0; + const model: Model = { + getResponse: async () => { + calls += 1; + return response; + }, + getStreamedResponse: () => { + throw new Error("no se usa: el bot llama con stream: false"); + }, + }; + return { model, calls: () => calls }; +} + +function providerOf(model: Model): ModelProvider { + return { getModel: () => model }; +} + +const request = {} as Parameters[0]; + +test("IncompleteResponseGuardModel", async (t) => { + await t.test("lanza ModelIncompleteResponseError con el motivo si la respuesta es incompleta y vacía", async () => { + const guard = new IncompleteResponseGuardModel(fakeModel(emptyIncomplete).model, () => {}); + + await assert.rejects(guard.getResponse(request), (error: unknown) => { + assert.ok(error instanceof ModelIncompleteResponseError); + assert.strictEqual(error.reason, "max_output_tokens"); + assert.strictEqual(error.responseId, "resp_vacia"); + assert.strictEqual(error.message, "model-incomplete (max_output_tokens)"); + return true; + }); + }); + + await t.test("usa 'unknown' si la API no informa del motivo", async () => { + const guard = new IncompleteResponseGuardModel( + fakeModel({ ...emptyIncomplete, providerData: { status: "incomplete" } }).model, + () => {}, + ); + + await assert.rejects(guard.getResponse(request), /model-incomplete \(unknown\)/); + }); + + await t.test("incompleta CON output: no corta el flujo, solo lo registra", async () => { + const warnings: IncompletePartialResponse[] = []; + const guard = new IncompleteResponseGuardModel(fakeModel(partialIncomplete).model, (w) => void warnings.push(w)); + + const response = await guard.getResponse(request); + + assert.strictEqual(response, partialIncomplete); + assert.deepStrictEqual(warnings, [{ reason: "max_output_tokens", responseId: "resp_truncada", outputItems: 1 }]); + }); + + await t.test("respuesta completa: la devuelve tal cual sin registrar nada", async () => { + const warnings: IncompletePartialResponse[] = []; + const guard = new IncompleteResponseGuardModel(fakeModel(completed).model, (w) => void warnings.push(w)); + + assert.strictEqual(await guard.getResponse(request), completed); + assert.strictEqual(warnings.length, 0); + }); +}); + +test("IncompleteResponseGuardModelProvider · envuelve los modelos del proveedor", async () => { + const provider = new IncompleteResponseGuardModelProvider(providerOf(fakeModel(emptyIncomplete).model), () => {}); + + const model = await provider.getModel("gpt-5.4-mini"); + + assert.ok(model instanceof IncompleteResponseGuardModel); +}); + +test("Runner del SDK · respuesta incompleta y vacía", async (t) => { + const agent = new Agent({ name: "test", instructions: "test" }); + + await t.test("sin guard: el SDK repite turnos hasta MaxTurnsExceededError (el síntoma del incidente)", async () => { + const fake = fakeModel(emptyIncomplete); + const runner = new Runner({ modelProvider: providerOf(fake.model) }); + + await assert.rejects(runner.run(agent, "hola", { maxTurns: 3 }), MaxTurnsExceededError); + assert.strictEqual(fake.calls(), 3); + }); + + await t.test("con guard: falla en el primer turno con ModelIncompleteResponseError", async () => { + const fake = fakeModel(emptyIncomplete); + const runner = new Runner({ + modelProvider: new IncompleteResponseGuardModelProvider(providerOf(fake.model), () => {}), + }); + + await assert.rejects(runner.run(agent, "hola", { maxTurns: 3 }), ModelIncompleteResponseError); + assert.strictEqual(fake.calls(), 1, "no repite turnos"); + }); +}); diff --git a/src/infrastructure/openai/incomplete-response-guard.ts b/src/infrastructure/openai/incomplete-response-guard.ts new file mode 100644 index 0000000..1da7f2f --- /dev/null +++ b/src/infrastructure/openai/incomplete-response-guard.ts @@ -0,0 +1,90 @@ +import { + OpenAIProvider, + Runner, + type Model, + type ModelProvider, + type ModelRequest, + type ModelResponse, + type ModelRetryAdviceRequest, +} from "@openai/agents"; +import { ModelIncompleteResponseError } from "../../application/model-incomplete-response-error.js"; + +/** + * Guard de respuestas incompletas de la Responses API (incidente 2026-09-11). + * + * Si la API devuelve `status: "incomplete"` con `output` vacío, `@openai/agents` lo toma + * como "sin salida final", repite el turno y acaba en `MaxTurnsExceededError` sin pista de + * la causa. Este decorador de `Model` lo corta en el primer turno con un error con nombre. + * + * Se engancha como `ModelProvider` (y no con un `fetch` propio del cliente OpenAI): un + * error lanzado dentro de `fetch` lo convierte el cliente en `APIConnectionError` y lo + * reintenta, perdiendo el tipo. Tampoco se pasa un `Model` a `Agent.model`: el SDK solo + * aplica los modelSettings por defecto del modelo (reasoning/verbosity) si es un string. + */ + +/** Respuesta incompleta que SÍ trae salida (p. ej. truncada por longitud): solo se registra. */ +export interface IncompletePartialResponse { + reason: string; + responseId?: string; + outputItems: number; +} + +export type IncompletePartialLogger = (info: IncompletePartialResponse) => void; + +const logIncompletePartial: IncompletePartialLogger = (info) => + console.warn(JSON.stringify({ t: "model", kind: "incomplete-partial", ...info })); + +export class IncompleteResponseGuardModel implements Model { + constructor( + private readonly inner: Model, + private readonly logPartial: IncompletePartialLogger = logIncompletePartial, + ) {} + + async getResponse(request: ModelRequest): Promise { + const response = await this.inner.getResponse(request); + // `providerData` es la respuesta cruda de /v1/responses (status, incomplete_details…). + if (response.providerData?.status !== "incomplete") { + return response; + } + + const reason: string = response.providerData.incomplete_details?.reason ?? "unknown"; + if (response.output.length === 0) { + throw new ModelIncompleteResponseError(reason, response.responseId); + } + + this.logPartial({ reason, responseId: response.responseId, outputItems: response.output.length }); + return response; + } + + // Sin guard: el bot llama siempre con stream: false. + getStreamedResponse(request: ModelRequest) { + return this.inner.getStreamedResponse(request); + } + + getRetryAdvice(args: ModelRetryAdviceRequest) { + return this.inner.getRetryAdvice?.(args); + } +} + +export class IncompleteResponseGuardModelProvider implements ModelProvider { + constructor( + private readonly inner: ModelProvider, + private readonly logPartial?: IncompletePartialLogger, + ) {} + + async getModel(modelName?: string): Promise { + return new IncompleteResponseGuardModel(await this.inner.getModel(modelName), this.logPartial); + } +} + +/** + * Runner de producción: mismo proveedor que registra `@openai/agents` por defecto, con el + * guard de respuestas incompletas. El resto de la configuración es la del runner por defecto. + */ +export function createOpenAIRunner(): Runner { + return new Runner({ + modelProvider: new IncompleteResponseGuardModelProvider( + new OpenAIProvider({ cacheResponsesWebSocketModels: false }), + ), + }); +}