Skip to content

chore: detectar respuestas incompletas del modelo, fijar dependencias y modelo explícito - #25

Merged
vgpastor merged 3 commits into
mainfrom
chore/robustez-modelo-y-dependencias
Sep 11, 2026
Merged

vgpastor merged 3 commits into
mainfrom
chore/robustez-modelo-y-dependencias

Conversation

@vgpastor

Copy link
Copy Markdown
Contributor

Contexto: incidente 2026-09-11

El bot dejó de contestar a todos (Telegram y WhatsApp). Un schema de tool en modo strict hacía que la Responses API devolviera status: "incomplete" (incomplete_details.reason: "max_output_tokens") con output: [] y 0 tokens. @openai/agents lo tomó como "sin salida final", repitió turnos y cada mensaje acabó en MaxTurnsExceededError: Max turns (10) exceeded, sin ninguna pista de la causa.

La causa raíz se arregla en #24. Esta PR endurece el sistema para que un fallo así no vuelva a pasar desapercibido. Complementa a #24 y no depende de ella: se pueden mergear en cualquier orden (#24 solo toca src/agent/tools.ts y su test).

Qué hace

R3 · Detectar respuestas incompletas y fallar rápido

  • ModelIncompleteResponseError (src/application/): error con nombre y reason (mensaje model-incomplete (max_output_tokens)).
  • Guard en infraestructura (src/infrastructure/openai/incomplete-response-guard.ts): decorador de Model + ModelProvider sobre el OpenAIProvider que registra el SDK por defecto. Tras getResponse mira providerData (la respuesta cruda de /v1/responses):
    • status: "incomplete" y output vacío → lanza el error en el primer turno (sin repetir 10 turnos).
    • status: "incomplete" con output (p. ej. truncado) → no corta el flujo, solo lo registra ({"t":"model","kind":"incomplete-partial",...}).
  • Composición: index.ts crea el Runner con el guard (createOpenAIRunner()) y lo inyecta en ConversationService. La aplicación solo conoce el error.
  • ConversationService: registra kind: "error" con model-incomplete (<motivo>), no reintenta (no es transitorio) y envía el aviso genérico que ya existía.

Por qué se engancha así en @openai/agents 0.12:

  • No con new OpenAI({ fetch }): el cliente openai trata un error lanzado dentro de fetch como fallo de conexión, lo reintenta y lo relanza como APIConnectionError, así que se perdería el tipo.
  • No pasando un Model a Agent.model: el SDK solo aplica los modelSettings por defecto del modelo (reasoning.effort: none, verbosity: low) cuando model es un string.
  • Como ModelProvider: el runner resuelve el nombre del modelo con él, y con retry.maxRetries por defecto en 0 el error llega a run() sin envolver.

R4 · Fijar dependencias

Cada "latest" pasa al rango caret de la versión ya resuelta en el lock: @openai/agents ^0.12.0, openai ^6.45.0, telegraf ^4.16.3, dotenv ^17.4.2, @types/node ^26.1.0, tsx ^4.22.5, typescript ^6.0.3. Lock regenerado con npm install --package-lock-only: ninguna versión resuelta cambia. Solo cambian los metadatos raíz: los specs y, de paso, name, license y engines, que en el lock estaban desfasados respecto a package.json.

R5 · Modelo explícito

OPENAI_MODEL vacío → gpt-5.4-mini (DEFAULT_OPENAI_MODEL en src/config/env.ts), y agent.ts pasa siempre model. Los modelSettings efectivos son idénticos a los que aplicaba el default del SDK (comprobado con new Agent(...)). Documentado en .env.example, README.md y README.es.md.

Cómo se ha probado

  • incomplete-response-guard.test.ts (modelo mockeado):
    • respuesta incompleta y vacía → error con reason/responseId;
    • sin motivo → unknown;
    • incompleta con output → se devuelve y se registra;
    • respuesta completa → sin cambios.
  • Con un Runner real del SDK: sin guard reproduce el MaxTurnsExceededError del incidente; con guard falla en la primera llamada con ModelIncompleteResponseError.
  • conversation-service.test.ts: registra model-incomplete (max_output_tokens), 1 sola llamada, aviso genérico; isTransientError no lo captura.
  • env.test.ts: valor por defecto y override de OPENAI_MODEL.
  • Ningún test llama a OpenAI: el test del Runner desactiva el tracing global, porque tracingDisabled del Runner no evita la exportación de trazas.

Gate (igual que el CI):

npm ci                                      ✔ (lock en sincronía)
npm run typecheck                           ✔
npm run build                               ✔
OPENAI_API_KEY=test-dummy-key npm test      ✔ 111 tests · 111 pass · 0 fail

…fallar rápido

Si la Responses API devuelve status "incomplete" con output vacío, el SDK lo
tomaba como "sin salida final", repetía turnos y acababa en
MaxTurnsExceededError sin pista de la causa (incidente 2026-09-11).

- ModelIncompleteResponseError (aplicación) con el motivo de la API.
- Guard en infraestructura (src/infrastructure/openai): decorador de Model y
  ModelProvider sobre el OpenAIProvider por defecto; lanza el error en el
  primer turno. Si la respuesta incompleta trae output, solo se registra.
- index.ts inyecta en ConversationService un Runner con el guard.
- ConversationService lo registra como "model-incomplete (<motivo>)", no
  reintenta y envía el aviso genérico.
- Tests con modelo mockeado, incluido un Runner real del SDK que reproduce
  el MaxTurnsExceededError sin guard.
… latest

Con "latest", cualquier npm install saltaría a @openai/agents 0.18 (exige
openai >=7.2 y cambia el modelo por defecto). Cada spec pasa al rango caret
de la versión ya resuelta en package-lock.json. El lock solo cambia en los
metadatos raíz (specs, name, license, engines); ninguna versión resuelta
cambia.
Con OPENAI_MODEL vacío el modelo lo elegía el SDK, y ese valor ya cambió
entre versiones. env.ts usa gpt-5.4-mini por defecto y agent.ts pasa siempre
model. Los modelSettings efectivos no cambian (reasoning none + verbosity
low, igual que con el default del SDK). Documentado en .env.example y README.
@vgpastor
vgpastor merged commit 7effe7a into main Sep 11, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant