feat(ai): typed CONTEXT_WINDOW_EXCEEDED error that hides the pointless Retry (#22488)

## Rationale

When message pruning can't fit the conversation into the model's context
window, `chat-execution.service.ts` throws a **raw `Error`**.
`mapErrorToStreamError` classifies it as generic
`STREAM_EXECUTION_FAILED`, so the client renders a standard failure with
a **Retry button that deterministically fails again** — the conversation
doesn't get shorter by retrying. Users loop on Retry against a
permanently-failing thread.

## Why this is the root cause, not a symptom patch

The failure is *terminal for the thread by construction*, and the error
channel already distinguishes terminal-vs-retryable via typed
`AiExceptionCode`s — this failure just never got one. Adding
`CONTEXT_WINDOW_EXCEEDED` (typed exception → `UserInputError` mapping
instead of a 500 → both error surfaces render the start-a-new-thread
message without `onRetry`) puts it on the same rails as
`API_KEY_NOT_CONFIGURED` and the other special-cased codes. Both
frontend error surfaces route through `AiChatErrorRenderer`, so one case
covers the in-message and under-list renderings.

The deeper endgame (auto-summarize/compact older turns so threads never
brick) is a multi-week feature — and this typed error remains necessary
even then, as its terminal fallback.

## User impact

Instead of an opaque error and a Retry that never works, users hitting
the context limit get told exactly what happened and what to do (start a
new thread), and monitoring stops counting a user-condition as a server
error.

## Test plan

- [ ] CI green
- [ ] Manual: fill a thread past the model limit → typed message, no
Retry on either error surface

https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22488?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Félix Malfait
2026-07-02 21:26:41 +02:00
committed by GitHub
parent 56a6f9419b
commit a505ed3245
5 changed files with 15 additions and 1 deletions
@@ -41,6 +41,10 @@ import { getToolMetricName } from 'src/engine/core-modules/tool-provider/utils/g
import { isToolOutputSuccessful } from 'src/engine/core-modules/tool-provider/utils/is-tool-output-successful.util';
import { resolveToolName } from 'src/engine/core-modules/tool-provider/utils/resolve-tool-name.util';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import {
AiException,
AiExceptionCode,
} from 'src/engine/metadata-modules/ai/ai.exception';
import { AgentActorContextService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service';
import { finalizeDanglingToolParts } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/finalize-dangling-tool-parts.util';
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const';
@@ -302,8 +306,9 @@ export class ChatExecutionService {
);
if (pruningResult.isStillOverLimit) {
throw new Error(
throw new AiException(
'This conversation is too long for the model to process. Please start a new thread.',
AiExceptionCode.CONTEXT_WINDOW_EXCEEDED,
);
}
@@ -12,6 +12,7 @@ export enum AiExceptionCode {
INVALID_AGENT_INPUT = 'INVALID_AGENT_INPUT',
THREAD_NOT_FOUND = 'THREAD_NOT_FOUND',
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
CONTEXT_WINDOW_EXCEEDED = 'CONTEXT_WINDOW_EXCEEDED',
INVALID_CHAT_THREAD_TITLE = 'INVALID_CHAT_THREAD_TITLE',
MESSAGE_NOT_FOUND = 'MESSAGE_NOT_FOUND',
QUESTION_NOT_PENDING = 'QUESTION_NOT_PENDING',
@@ -39,6 +40,8 @@ const getAiExceptionUserFriendlyMessage = (code: AiExceptionCode) => {
return msg`Chat thread not found.`;
case AiExceptionCode.WORKSPACE_NOT_FOUND:
return msg`Workspace not found.`;
case AiExceptionCode.CONTEXT_WINDOW_EXCEEDED:
return msg`This conversation is too long for the model. Start a new thread to continue.`;
case AiExceptionCode.INVALID_CHAT_THREAD_TITLE:
return msg`Chat thread title cannot be empty.`;
case AiExceptionCode.MESSAGE_NOT_FOUND:
@@ -27,6 +27,7 @@ export const aiGraphqlApiExceptionHandler = (error: Error) => {
case AiExceptionCode.MESSAGE_NOT_FOUND:
case AiExceptionCode.ROLE_NOT_FOUND:
throw new NotFoundError(error);
case AiExceptionCode.CONTEXT_WINDOW_EXCEEDED:
case AiExceptionCode.INVALID_AGENT_INPUT:
case AiExceptionCode.INVALID_CHAT_THREAD_TITLE:
case AiExceptionCode.QUESTION_NOT_PENDING: