🧺 feat: Preserve Partial Work at Tool Call Limits (#15486)

* feat: Handle reaching the agent tool-call limit gracefully

When the graph exhausts its per-turn step budget, persist the turn as
unfinished with finish_reason tool_call_limit instead of a red error.
Warn the model as the budget runs low, and offer Keep going or Answer now.

* docs: Add screenshots of the tool-call-limit notice

Light and dark captures of the unfinished-turn card for the PR description.

* style: Tighten the tool-call-limit notice card

Pin dismiss to the top-right with uniform rounding, use a slightly
smaller hit target, and round the card to xl. Refresh the PR screenshots.

* fix: Address Copilot review findings on the step-budget path

Skip empty and subagent-scoped PostToolBatch events when counting
rounds. Drop the double-cast in the notice test helper and coerce the
tool-call-limit finish reason in the Continue-gate spec.

* fix: Address Codex review findings on budget and dismiss

Pass the resolved recursion limit into the OpenAI-compatible
processStream path so the step-budget hook matches the graph.
Reset the notice dismiss state when the displayed message id changes.

* fix: Unblock CI typecheck and import sorting

Declare recursion_limit on the OpenAI service agent type, sort that
file's local imports, and route the notice test context through unknown
so tsc accepts the partial ChatContext fixture.

* fix: Sort remaining PR imports for static checks

* chore: Remove tool-call-limit notice screenshots

* fix: Keep recovery actions from consuming composer drafts

Pass empty file, skill, and quote overrides on Keep going and Answer
now so a staged compose is not attached or drained.
This commit is contained in:
Marco Beretta 2026-09-02 03:38:02 +02:00 • committed by GitHub
parent 71544f8236
commit 2cf1007f3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1039 additions and 17 deletions

View file

@ -5481,7 +5481,7 @@ describe('ResumableAgentController resume metadata', () => {
getHaltReason: () => 'preempt_incomplete',
};
const runFirstTurn = async ({ run, addTitle: suppliedAddTitle } = {}) => {
const runFirstTurn = async ({ run, addTitle: suppliedAddTitle, clientOverrides } = {}) => {
let signalFinished;
const finished = new Promise((resolve) => {
signalFinished = resolve;
@ -5500,6 +5500,7 @@ describe('ResumableAgentController resume metadata', () => {
savedMessageIds: new Set(),
skipSaveUserMessage: false,
...(run && { run }),
...clientOverrides,
sendMessage: jest.fn(async (_text, options) => {
const userMessage = {
messageId: 'user-msg',
@ -5602,5 +5603,53 @@ describe('ResumableAgentController resume metadata', () => {
expect(addTitle).toHaveBeenCalledTimes(1);
expect(getTitleSignal().aborted).toBe(false);
});
/**
* The hop that makes the client notice possible: `AgentClient` swallows the
* graph's step-limit error and raises `stepLimitReached`, and the controller
* must turn that into an `unfinished` row stamped with the tool-call-limit
* finish reason on BOTH the durable write and the terminal SSE event.
*/
const stepLimitClient = { stepLimitReached: true };
const savedResponseRow = () =>
mockSaveMessage.mock.calls
.map(([, message]) => message)
.find((message) => message?.messageId === 'response-msg');
it('persists a step-limited turn as unfinished with the tool-call-limit finish reason', async () => {
await runFirstTurn({ clientOverrides: stepLimitClient });
expect(savedResponseRow()).toEqual(
expect.objectContaining({
unfinished: true,
finish_reason: Constants.TOOL_CALL_LIMIT_FINISH_REASON,
}),
);
});
it('keeps the partial content on a step-limited turn instead of replacing it with an error', async () => {
await runFirstTurn({ clientOverrides: stepLimitClient });
const saved = savedResponseRow();
expect(saved.content).toEqual([{ type: 'text', text: 'Truncated answer' }]);
expect(saved.error).not.toBe(true);
});
it('publishes the finish reason on the terminal event so the client needs no refetch', async () => {
await runFirstTurn({ clientOverrides: stepLimitClient });
const published = mockGenerationJobManager.publishTerminalClaim.mock.calls.at(-1);
expect(published).toBeDefined();
expect(JSON.stringify(published)).toContain(Constants.TOOL_CALL_LIMIT_FINISH_REASON);
});
it('leaves an ordinary completed turn finished and unstamped', async () => {
await runFirstTurn();
const saved = savedResponseRow();
expect(saved.unfinished).toBe(false);
expect(saved.finish_reason).toBeUndefined();
});
});
});

View file

@ -289,6 +289,7 @@ jest.mock('@librechat/api', () => ({
alwaysApplyDedupedFromManual: 0,
}),
createToolExecuteHandler: jest.fn().mockReturnValue({ handle: jest.fn() }),
resolveRecursionLimit: jest.fn().mockReturnValue(50),
// Responses API
writeDone: jest.fn(),
buildResponse: jest.fn().mockReturnValue({ id: 'resp_123', output: [] }),
@ -709,6 +710,21 @@ describe('createResponse controller', () => {
);
});
it('invokes the graph with the resolved recursion limit rather than the SDK default', async () => {
const api = require('@librechat/api');
const processStream = jest.fn().mockResolvedValue(undefined);
api.createRun.mockResolvedValueOnce({ processStream });
api.resolveRecursionLimit.mockReturnValueOnce(123);
await createResponse(req, res);
expect(processStream).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ recursionLimit: 123 }),
expect.anything(),
);
});
it('returns 503 when an agent expects MCP tools but resolves none', async () => {
const { initializeAgent, sendResponsesErrorResponse } = require('@librechat/api');
const { loadAgentTools } = require('~/server/services/ToolService');

View file

@ -55,6 +55,7 @@ const {
getPluginHookSource,
captureAgentCheckpointGeneration,
isContentFilterError,
isStepLimitError,
deleteAgentCheckpoint,
LIBRECHAT_CHECKPOINT_NAMESPACE_KEY,
LIBRECHAT_EVENT_ACTOR_INVOCATION_KEY,
@ -387,6 +388,11 @@ class AgentClient extends BaseClient {
* these before returning — otherwise job cleanup can race the persist.
* @type {Promise<void>[]} */
this.pendingSubagentEmits = [];
/** Set when the graph exhausted its per-turn step budget (`recursionLimit`).
* Read by `request.js`/`resume.js` to persist the row as `unfinished` with
* `Constants.TOOL_CALL_LIMIT_FINISH_REASON` instead of publishing an error.
* @type {boolean} */
this.stepLimitReached = false;
/** Stable per-generation sequence for subagent usage events. Detached
* usage is billed outside `collectedUsage`, so array length is no longer
* a valid sequence source. @type {number} */
@ -4407,6 +4413,25 @@ class AgentClient extends BaseClient {
'[api/server/controllers/agents/client.js #sendCompletion] Operation aborted by user',
{ conversationId: this.conversationId, ...getSafeErrorMetadata(err) },
);
} else if (isStepLimitError(err)) {
/**
* The graph ran out of supersteps. Everything already streamed is real work,
* so this terminates the turn as incomplete rather than failed: no ERROR part,
* and `request.js` persists the row `unfinished` with the tool-call-limit
* finish reason so the UI can offer to continue. Mirrors the abort contract:
* a turn that stopped early is not a turn that broke.
*/
this.stepLimitReached = true;
logger.warn(
'[api/server/controllers/agents/client.js #sendCompletion] Tool call limit reached; ending the turn as incomplete',
{
conversationId: this.conversationId,
recursionLimit: resolveRecursionLimit(
this.options.req.config?.endpoints?.[EModelEndpoint.agents],
this.options.agent,
),
},
);
} else {
logger.error(
'[api/server/controllers/agents/client.js #sendCompletion] Unhandled error type',
@ -4854,6 +4879,15 @@ class AgentClient extends BaseClient {
...getSafeErrorMetadata(err),
},
);
} else if (isStepLimitError(err)) {
/** Same contract as the initial turn: incomplete, not failed. A resumed run
* inherits the budget of a turn that already spent steps before pausing, so
* this boundary is if anything more likely to be reached here. */
this.stepLimitReached = true;
logger.warn(
'[api/server/controllers/agents/client.js #resumeCompletion] Tool call limit reached; ending the resumed turn as incomplete',
{ conversationId: this.conversationId },
);
} else {
logger.error(
'[api/server/controllers/agents/client.js #resumeCompletion] Unhandled error',

View file

@ -2053,6 +2053,103 @@ describe('AgentClient - startup telemetry', () => {
);
});
it('ends a step-limit turn as incomplete rather than as an error part', async () => {
jest.clearAllMocks();
const { GraphRecursionError } = require('@langchain/langgraph');
/** The exact error LangGraph throws once the graph runs out of supersteps. */
const stepLimitError = new GraphRecursionError(
'Recursion limit of 50 reached without hitting a stop condition.',
{ lc_error_code: 'GRAPH_RECURSION_LIMIT' },
);
mockCreateRun.mockResolvedValue({
Graph: null,
processStream: jest.fn().mockRejectedValue(stepLimitError),
getCalibrationRatio: jest.fn(() => 0),
});
mockIsHITLEnabled.mockReturnValue(false);
const client = new AgentClient({
req: {
user: { id: 'user-123' },
body: {},
config: { endpoints: { [EModelEndpoint.agents]: {} } },
_resumableStreamId: 'conversation-step-limit',
},
res: {},
agent: {
id: 'agent-123',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
model_parameters: { model: 'gpt-4' },
hide_sequential_outputs: false,
},
endpointTokenConfig: {},
eventHandlers: {},
/** Work the turn already produced; it must survive the boundary. */
contentParts: [{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Partial findings' }],
collectedUsage: [],
artifactPromises: [],
});
client.conversationId = 'conversation-step-limit';
client.responseMessageId = 'response-step-limit';
client.parentMessageId = 'parent-step-limit';
client.recordCollectedUsage = jest.fn().mockResolvedValue();
await client.chatCompletion({ payload: [] });
expect(client.stepLimitReached).toBe(true);
expect(client.contentParts).not.toEqual(
expect.arrayContaining([expect.objectContaining({ type: ContentTypes.ERROR })]),
);
expect(client.contentParts).toEqual(
expect.arrayContaining([
expect.objectContaining({ [ContentTypes.TEXT]: 'Partial findings' }),
]),
);
});
it('still surfaces an error part for an ordinary run failure', async () => {
jest.clearAllMocks();
mockCreateRun.mockResolvedValue({
Graph: null,
processStream: jest.fn().mockRejectedValue(new Error('provider exploded')),
getCalibrationRatio: jest.fn(() => 0),
});
mockIsHITLEnabled.mockReturnValue(false);
const client = new AgentClient({
req: {
user: { id: 'user-123' },
body: {},
config: { endpoints: { [EModelEndpoint.agents]: {} } },
_resumableStreamId: 'conversation-plain-error',
},
res: {},
agent: {
id: 'agent-123',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
model_parameters: { model: 'gpt-4' },
hide_sequential_outputs: false,
},
endpointTokenConfig: {},
eventHandlers: {},
contentParts: [],
collectedUsage: [],
artifactPromises: [],
});
client.conversationId = 'conversation-plain-error';
client.responseMessageId = 'response-plain-error';
client.parentMessageId = 'parent-plain-error';
client.recordCollectedUsage = jest.fn().mockResolvedValue();
await client.chatCompletion({ payload: [] });
expect(client.stepLimitReached).toBe(false);
expect(client.contentParts).toEqual(
expect.arrayContaining([expect.objectContaining({ type: ContentTypes.ERROR })]),
);
});
it('evolves warm compaction guidance while injecting only the new event message', async () => {
jest.clearAllMocks();
const history = { _getType: () => 'human', content: 'old turn' };

View file

@ -1892,6 +1892,10 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
let terminalPersistenceChecked = false;
let terminalWasAborted = false;
let preemptIncomplete = false;
/** The graph exhausted its per-turn step budget. Like `preemptIncomplete`, an
* honest `unfinished` outcome rather than an error: the partial turn is real
* work and the user is offered a way to carry on. */
let stepLimitReached = false;
/** A pause-row write failure is terminalized through the exact action/epoch
* barrier. Once that path starts, neither generic background error handler
* may call completeJob: the pause may already have been replaced by a newer
@ -1929,6 +1933,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
preemptIncomplete =
(preemptStats?.emptyBoundaries ?? 0) > 0 ||
client?.run?.getHaltReason?.() === 'preempt_incomplete';
stepLimitReached = client?.stepLimitReached === true;
terminalClaim = await GenerationJobManager.claimTerminalJob(
streamId,
terminalWasAborted ? 'aborted' : 'complete',
@ -2712,13 +2717,19 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
/** BaseClient can add the id to savedMessageIds even when its model-layer
* save resolved falsy. Re-save the terminal row idempotently and require
* the returned durable row before publishing the normal FINAL. */
const responseIsUnfinished = terminalWasAborted || preemptIncomplete;
const responseIsUnfinished = terminalWasAborted || preemptIncomplete || stepLimitReached;
const savedResponseMessage = await saveMessage(
reqCtx,
{
...response,
user: userId,
unfinished: responseIsUnfinished,
/** Distinguishes "ran out of steps" from a user stop, so the client can
* render the actionable tool-call-limit notice rather than the generic
* incomplete-response warning. */
...(stepLimitReached && {
finish_reason: Constants.TOOL_CALL_LIMIT_FINISH_REASON,
}),
},
{
context: responseIsUnfinished
@ -2778,9 +2789,11 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
scheduleCompletionError = 'Scheduled run was stopped';
} else if (preemptIncomplete) {
scheduleCompletionError = 'Scheduled run was interrupted before completion';
} else if (stepLimitReached) {
scheduleCompletionError = 'Scheduled run reached its tool call limit before completion';
}
await settleScheduledRun({
status: terminalWasAborted || preemptIncomplete ? 'interrupted' : 'success',
status: responseIsUnfinished ? 'interrupted' : 'success',
...(scheduleCompletionError != null && { error: scheduleCompletionError }),
});
@ -2794,7 +2807,10 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
requestMessage: sanitizeMessageForTransmit(userMessage),
responseMessage: {
...response,
...((terminalWasAborted || preemptIncomplete) && { unfinished: true }),
...(responseIsUnfinished && { unfinished: true }),
...(stepLimitReached && {
finish_reason: Constants.TOOL_CALL_LIMIT_FINISH_REASON,
}),
},
...(pendingSteers.length > 0 && { pendingSteers }),
};

View file

@ -53,6 +53,7 @@ const {
getSafeErrorMetadata,
getUserFacingProviderError,
createToolExecuteHandler,
resolveRecursionLimit,
getRemoteAgentPermissions,
resolveAgentScopedSkillIds,
// Responses API
@ -1201,6 +1202,7 @@ const executeResponse = async (envelope, { req, res }) => {
requestBody: mcpRequestBody,
...(userMCPAuthMap != null && { userMCPAuthMap }),
},
recursionLimit: resolveRecursionLimit(agentsEConfig, agent),
signal: execution.signal,
streamMode: 'values',
version: 'v2',
@ -1411,6 +1413,7 @@ const executeResponse = async (envelope, { req, res }) => {
requestBody: mcpRequestBody,
...(userMCPAuthMap != null && { userMCPAuthMap }),
},
recursionLimit: resolveRecursionLimit(agentsEConfig, agent),
signal: execution.signal,
streamMode: 'values',
version: 'v2',

View file

@ -447,6 +447,8 @@ async function finalizeResumedTurn({
const preemptIncomplete =
(preemptStats?.emptyBoundaries ?? 0) > 0 ||
client?.run?.getHaltReason?.() === 'preempt_incomplete';
/** Same honest-incomplete contract for a resumed turn that runs out of steps. */
const stepLimitReached = client?.stepLimitReached === true;
const responseMessage = {
messageId: responseMessageId,
@ -457,7 +459,8 @@ async function finalizeResumedTurn({
endpoint: meta.endpoint,
iconURL: meta.iconURL,
model: meta.model,
unfinished: preemptIncomplete,
unfinished: preemptIncomplete || stepLimitReached,
...(stepLimitReached && { finish_reason: Constants.TOOL_CALL_LIMIT_FINISH_REASON }),
error: false,
isCreatedByUser: false,
user: userId,
@ -612,11 +615,15 @@ async function finalizeResumedTurn({
scheduledFor: meta.scheduledFor,
streamId,
jobCreatedAt: job.createdAt,
status: preemptIncomplete ? 'interrupted' : 'success',
status: preemptIncomplete || stepLimitReached ? 'interrupted' : 'success',
conversationId,
...(preemptIncomplete && {
error: 'Scheduled run was interrupted before completion',
}),
...(stepLimitReached &&
!preemptIncomplete && {
error: 'Scheduled run reached its tool call limit before completion',
}),
});
}

View file

@ -1,10 +1,12 @@
import { memo, Suspense, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { Constants } from 'librechat-data-provider';
import { Alert, DelayedRender } from '@librechat/client';
import type { TMessage } from 'librechat-data-provider';
import type { TMessageContentProps, TDisplayProps } from '~/common';
import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming';
import Error from '~/components/Messages/Content/Error';
import ToolCallLimitNotice from './ToolCallLimitNotice';
import CollapsibleText from './Parts/CollapsibleText';
import { useMessageContext } from '~/Providers';
import EmptyText from './Parts/EmptyText';
@ -130,12 +132,21 @@ const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplay
);
};
export const UnfinishedMessage = ({ message }: { message: TMessage }) => (
<ErrorMessage
message={message}
text="The response is incomplete; it's either still processing, was cancelled, or censored. Refresh or try a different prompt."
/>
);
export const UnfinishedMessage = ({ message }: { message: TMessage }) => {
const localize = useLocalize();
/** Ran out of steps, not broken: a distinct, actionable card rather than the
* generic "something went wrong, try again" warning. */
if (message.finish_reason === Constants.TOOL_CALL_LIMIT_FINISH_REASON) {
return (
<Container message={message}>
<ToolCallLimitNotice message={message} />
</Container>
);
}
return <ErrorMessage message={message} text={localize('com_ui_response_incomplete')} />;
};
const MessageContent = ({
text,

View file

@ -0,0 +1,113 @@
import { useContext, useId, useState } from 'react';
import { Button } from '@librechat/client';
import { FastForward, Gauge, MessageSquareText, X } from 'lucide-react';
import type { TMessage } from 'librechat-data-provider';
import { ChatContext } from '~/Providers/ChatContext';
import { useLocalize } from '~/hooks';
/**
* Shown when a turn ended because the agent exhausted its per-turn step budget
* (`recursionLimit`) rather than because anything failed. The server marks that
* row `unfinished` with `Constants.TOOL_CALL_LIMIT_FINISH_REASON`.
*
* Deliberately not an `ErrorBox`: nothing broke, the work above is real, and the
* only thing the user needs is a way forward. Both actions post an ordinary user
* turn, which is what makes them work everywhere:
*
* - the previous turn is already persisted with its tool calls, so the model sees
* everything it did before deciding what to do next, and
* - a new turn gets a fresh step budget without needing a resumable checkpoint,
* which only exists on HITL-capable deployments.
*
* The instruction text is localized because it becomes a visible message in the
* user's own conversation, in their own language.
*/
export default function ToolCallLimitNotice({ message }: { message: TMessage }) {
const localize = useLocalize();
const titleId = useId();
const [dismissed, setDismissed] = useState(false);
const [noticeMessageId, setNoticeMessageId] = useState(message.messageId);
/**
* MultiMessage reuses this row when switching siblings, so dismiss state
* would otherwise hide the notice on a different message. Reset during
* render when the displayed id changes; no effect needed.
*/
if (noticeMessageId !== message.messageId) {
setNoticeMessageId(message.messageId);
setDismissed(false);
}
/**
* Nullable on purpose: this renders inside shared links, search results and
* exports, which mount message content with no live chat behind it. There the
* explanation still helps, so the card degrades to text instead of throwing.
*/
const chat = useContext(ChatContext);
if (dismissed && noticeMessageId === message.messageId) {
return null;
}
/**
* `ask` appends to the tail of the active branch, so offering it on anything
* but the tail would send the follow-up somewhere the user is not looking.
* Same gate `useGenerationsByLatest` applies to the Continue hover button.
*/
const canAct =
chat?.ask != null && chat.isSubmitting !== true && chat.latestMessageId === message.messageId;
/**
* Empty overrides are authoritative: a recovery prompt is not the user's next
* compose, so `ask` must not attach or drain files, skills, or quotes already
* staged in the composer.
*/
const recover = (text: string) =>
chat?.ask({ text }, { overrideFiles: [], overrideManualSkills: [], overrideQuotes: [] });
return (
<div
role="group"
aria-labelledby={titleId}
className="relative my-2 flex w-full flex-col rounded-xl border border-border-light bg-surface-secondary p-3"
>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={localize('com_ui_dismiss')}
className="absolute right-1.5 top-1.5 text-text-secondary focus-visible:ring-inset focus-visible:ring-offset-0"
onClick={() => setDismissed(true)}
>
<X className="h-4 w-4" aria-hidden="true" />
</Button>
<p
id={titleId}
className="flex min-w-0 items-center gap-2 pr-8 text-sm font-medium text-text-primary"
>
<Gauge className="h-4 w-4 shrink-0 text-text-secondary" aria-hidden="true" />
{localize('com_ui_tool_call_limit_title')}
</p>
<p className="mb-3 mt-1 text-sm text-text-secondary">
{localize('com_ui_tool_call_limit_body')}
</p>
{canAct && (
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="submit"
onClick={() => recover(localize('com_ui_tool_call_limit_continue_prompt'))}
>
<FastForward className="icon-md shrink-0" aria-hidden="true" />
{localize('com_ui_tool_call_limit_continue')}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => recover(localize('com_ui_tool_call_limit_answer_prompt'))}
>
<MessageSquareText className="icon-md shrink-0" aria-hidden="true" />
{localize('com_ui_tool_call_limit_answer')}
</Button>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,153 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import type { TMessage } from 'librechat-data-provider';
import ToolCallLimitNotice from '../ToolCallLimitNotice';
import { ChatContext } from '~/Providers/ChatContext';
const TRANSLATIONS: Record<string, string> = {
com_ui_tool_call_limit_title: 'Reached the tool call limit for this turn',
com_ui_tool_call_limit_body: 'The agent used every step allowed in a single turn.',
com_ui_tool_call_limit_continue: 'Keep going',
com_ui_tool_call_limit_continue_prompt: 'Keep going from where you stopped.',
com_ui_tool_call_limit_answer: 'Answer now',
com_ui_tool_call_limit_answer_prompt: 'Stop using tools and answer now.',
com_ui_dismiss: 'Dismiss',
};
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => TRANSLATIONS[key] ?? key,
}));
const MESSAGE = { messageId: 'response-1' } as TMessage;
type NoticeChatContext = NonNullable<React.ContextType<typeof ChatContext>>;
/**
* The notice only reads `ask`, `isSubmitting`, and `latestMessageId`. The real
* context is a large helper return type, so a partial cannot overlap it for
* TypeScript without going through `unknown`.
*/
function chatContextValue(context: Partial<NoticeChatContext>): NoticeChatContext {
return context as unknown as NoticeChatContext;
}
function renderNotice(context?: Partial<NoticeChatContext>, message: TMessage = MESSAGE) {
if (context == null) {
return render(<ToolCallLimitNotice message={message} />);
}
return render(
<ChatContext.Provider value={chatContextValue(context)}>
<ToolCallLimitNotice message={message} />
</ChatContext.Provider>,
);
}
describe('ToolCallLimitNotice', () => {
it('explains what happened rather than reporting an error', () => {
renderNotice();
expect(screen.getByText(TRANSLATIONS.com_ui_tool_call_limit_title)).toBeInTheDocument();
expect(screen.getByText(TRANSLATIONS.com_ui_tool_call_limit_body)).toBeInTheDocument();
/** An `alert` role would announce this as a failure; it is a normal outcome. */
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('names its container for screen readers', () => {
renderNotice();
expect(
screen.getByRole('group', { name: TRANSLATIONS.com_ui_tool_call_limit_title }),
).toBeInTheDocument();
});
it('continues the turn as a new request carrying the continue instruction', () => {
const ask = jest.fn();
renderNotice({ ask, isSubmitting: false, latestMessageId: 'response-1' });
fireEvent.click(
screen.getByRole('button', { name: TRANSLATIONS.com_ui_tool_call_limit_continue }),
);
expect(ask).toHaveBeenCalledTimes(1);
expect(ask).toHaveBeenCalledWith(
{ text: TRANSLATIONS.com_ui_tool_call_limit_continue_prompt },
{ overrideFiles: [], overrideManualSkills: [], overrideQuotes: [] },
);
});
it('asks for an immediate answer instead of more tool use', () => {
const ask = jest.fn();
renderNotice({ ask, isSubmitting: false, latestMessageId: 'response-1' });
fireEvent.click(
screen.getByRole('button', { name: TRANSLATIONS.com_ui_tool_call_limit_answer }),
);
expect(ask).toHaveBeenCalledWith(
{ text: TRANSLATIONS.com_ui_tool_call_limit_answer_prompt },
{ overrideFiles: [], overrideManualSkills: [], overrideQuotes: [] },
);
});
it('offers no actions with no live chat behind it, as in a shared link or export', () => {
renderNotice();
expect(
screen.queryByRole('button', { name: TRANSLATIONS.com_ui_tool_call_limit_continue }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: TRANSLATIONS.com_ui_tool_call_limit_answer }),
).not.toBeInTheDocument();
/** The explanation and the way to hide it must survive. */
expect(screen.getByText(TRANSLATIONS.com_ui_tool_call_limit_body)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument();
});
it('offers no actions on a message that is not the branch tail', () => {
const ask = jest.fn();
renderNotice({ ask, isSubmitting: false, latestMessageId: 'some-newer-message' });
expect(
screen.queryByRole('button', { name: TRANSLATIONS.com_ui_tool_call_limit_continue }),
).not.toBeInTheDocument();
expect(ask).not.toHaveBeenCalled();
});
it('offers no actions while a generation is already running', () => {
renderNotice({ ask: jest.fn(), isSubmitting: true, latestMessageId: 'response-1' });
expect(
screen.queryByRole('button', { name: TRANSLATIONS.com_ui_tool_call_limit_continue }),
).not.toBeInTheDocument();
});
it('can be dismissed', () => {
renderNotice({ ask: jest.fn(), isSubmitting: false, latestMessageId: 'response-1' });
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
expect(screen.queryByText(TRANSLATIONS.com_ui_tool_call_limit_title)).not.toBeInTheDocument();
});
it('shows the notice again when switching to another sibling message', () => {
const ask = jest.fn();
const { rerender } = renderNotice({
ask,
isSubmitting: false,
latestMessageId: 'response-1',
});
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
expect(screen.queryByText(TRANSLATIONS.com_ui_tool_call_limit_title)).not.toBeInTheDocument();
rerender(
<ChatContext.Provider
value={chatContextValue({ ask, isSubmitting: false, latestMessageId: 'response-2' })}
>
<ToolCallLimitNotice message={{ messageId: 'response-2' } as TMessage} />
</ChatContext.Provider>,
);
expect(screen.getByText(TRANSLATIONS.com_ui_tool_call_limit_title)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,60 @@
import React from 'react';
import { Constants } from 'librechat-data-provider';
import { render, screen } from '@testing-library/react';
import type { TMessage } from 'librechat-data-provider';
import { UnfinishedMessage } from '../MessageContent';
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
/** `Container` reads message context for sequential-agent layout; not under test. */
jest.mock('../Container', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
jest.mock('~/components/Messages/Content/Error', () => ({
__esModule: true,
default: ({ text }: { text: string }) => <span>{text}</span>,
}));
jest.mock('../ToolCallLimitNotice', () => ({
__esModule: true,
default: () => <div data-testid="tool-call-limit-notice" />,
}));
/** `Constants` is a heterogeneous enum, so its members are not directly
* assignable to `finish_reason`'s `string`; coerce rather than duplicate the value. */
const TOOL_CALL_LIMIT = String(Constants.TOOL_CALL_LIMIT_FINISH_REASON);
const message = (overrides: Partial<TMessage> = {}) =>
({ messageId: 'response-1', ...overrides }) as TMessage;
describe('UnfinishedMessage', () => {
it('routes a step-limit turn to the actionable notice, not an error box', () => {
render(<UnfinishedMessage message={message({ finish_reason: TOOL_CALL_LIMIT })} />);
expect(screen.getByTestId('tool-call-limit-notice')).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('keeps the generic incomplete warning for every other unfinished turn', () => {
render(<UnfinishedMessage message={message()} />);
expect(screen.queryByTestId('tool-call-limit-notice')).not.toBeInTheDocument();
expect(screen.getByText('com_ui_response_incomplete')).toBeInTheDocument();
});
it('does not mistake an unrelated finish reason for the step limit', () => {
render(<UnfinishedMessage message={message({ finish_reason: 'length' })} />);
expect(screen.queryByTestId('tool-call-limit-notice')).not.toBeInTheDocument();
});
it('localizes the generic warning instead of hardcoding English', () => {
render(<UnfinishedMessage message={message()} />);
expect(screen.queryByText(/The response is incomplete/)).not.toBeInTheDocument();
});
});

View file

@ -1,5 +1,6 @@
import { useCallback, useMemo, memo } from 'react';
import { useRecoilValue } from 'recoil';
import { Constants } from 'librechat-data-provider';
import type { TMessage, TMessageContentParts } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon, TMessageChatContext } from '~/common';
import {
@ -10,6 +11,7 @@ import {
} from '~/utils';
import { revealOnRowHoverClasses, messageFooterClasses } from '~/components/Chat/Messages/styles';
import { useAttachments, useLocalize, useMessageActions, useContentMetadata } from '~/hooks';
import ToolCallLimitNotice from '~/components/Chat/Messages/Content/ToolCallLimitNotice';
import AuthorHeader from '~/components/Chat/Messages/Content/Parts/AuthorHeader';
import Elapsed, { shouldShowElapsed } from '~/components/Chat/Messages/Elapsed';
import { getHeaderModelName } from '~/components/Chat/Messages/ui/HeaderLabel';
@ -220,6 +222,15 @@ const ContentRender = memo(function ContentRender({
conversationId={conversation?.conversationId}
content={msg.content as Array<TMessageContentParts | undefined>}
/>
{/** A turn that ran out of agent steps is incomplete, not broken. Rendered
* here rather than inside `ContentParts` because it is a message-level
* outcome, and `ContentParts` also serves surfaces (subagent panels,
* search) that have no message row behind them. */}
{msg.unfinished === true &&
!isSubmitting &&
msg.finish_reason === Constants.TOOL_CALL_LIMIT_FINISH_REASON && (
<ToolCallLimitNotice message={msg} />
)}
</MessageRow>
);
}, areContentRenderPropsEqual);

View file

@ -0,0 +1,30 @@
import { Constants, EModelEndpoint } from 'librechat-data-provider';
import useGenerationsByLatest from '../useGenerationsByLatest';
const base = {
endpoint: EModelEndpoint.agents,
messageId: 'response-1',
latestMessageId: 'response-1',
isSubmitting: false,
isCreatedByUser: false,
};
describe('useGenerationsByLatest', () => {
it('withholds the hover Continue control when the turn ran out of tool steps', () => {
const result = useGenerationsByLatest({
...base,
finish_reason: String(Constants.TOOL_CALL_LIMIT_FINISH_REASON),
});
expect(result.continueSupported).toBe(false);
});
it('still offers Continue for other incomplete finish reasons', () => {
const result = useGenerationsByLatest({
...base,
finish_reason: 'length',
});
expect(result.continueSupported).toBe(true);
});
});

View file

@ -1,4 +1,4 @@
import { EModelEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
import { Constants, EModelEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
type TUseGenerations = {
error?: boolean;
@ -35,10 +35,14 @@ export default function useGenerationsByLatest({
].find((e) => e === endpoint),
);
/** The tool-call-limit notice already offers Keep going / Answer now. The hover
* Continue would re-submit the parent user turn with `isContinued`, a different
* and weaker path sitting next to the intended one. */
const continueSupported =
latestMessageId === messageId &&
finish_reason &&
Boolean(finish_reason) &&
finish_reason !== 'stop' &&
finish_reason !== Constants.TOOL_CALL_LIMIT_FINISH_REASON &&
!isEditing &&
!isSubmitting &&
!searchResult &&

View file

@ -1189,6 +1189,7 @@
"com_ui_details": "Details",
"com_ui_disabled": "Disabled",
"com_ui_disabling": "Disabling...",
"com_ui_dismiss": "Dismiss",
"com_ui_done": "Done",
"com_ui_download": "Download",
"com_ui_download_artifact": "Download Artifact",
@ -1850,6 +1851,7 @@
"com_ui_resource": "resource",
"com_ui_respond": "Respond",
"com_ui_response": "Response",
"com_ui_response_incomplete": "The response is incomplete; it's either still processing, was cancelled, or censored. Refresh or try a different prompt.",
"com_ui_result": "Result",
"com_ui_result_found": "{{count}} result found",
"com_ui_results_found": "{{count}} results found",
@ -2335,6 +2337,12 @@
"com_ui_token_url": "Token URL",
"com_ui_tokens": "tokens",
"com_ui_tool_background": "Background execution",
"com_ui_tool_call_limit_answer": "Answer now",
"com_ui_tool_call_limit_answer_prompt": "Stop using tools and answer now with what you already have. Say briefly what you could not finish checking.",
"com_ui_tool_call_limit_body": "The agent used every step allowed in a single turn, so it stopped before finishing. Everything above is saved. Choose how to carry on.",
"com_ui_tool_call_limit_continue": "Keep going",
"com_ui_tool_call_limit_continue_prompt": "Keep going from where you stopped.",
"com_ui_tool_call_limit_title": "Reached the tool call limit for this turn",
"com_ui_tool_collection_prefix": "A collection of tools from",
"com_ui_tool_credentials_saved": "Credentials saved",
"com_ui_tool_failed": "failed",

View file

@ -300,6 +300,7 @@ const makeFieldsMsg = (over: Partial<TMessage> = {}): TMessage =>
text: 'hello',
error: false,
unfinished: false,
finish_reason: 'stop',
createdAt: '2026-07-01T00:00:00.000Z',
depth: 0,
isCreatedByUser: false,
@ -321,6 +322,7 @@ const FIELD_MUTATIONS: Array<[string, Partial<TMessage>]> = [
['text', { text: 'changed' }],
['error', { error: true }],
['unfinished', { unfinished: true }],
['finish_reason', { finish_reason: 'tool_call_limit' }],
['createdAt', { createdAt: '2026-07-02T00:00:00.000Z' }],
['depth', { depth: 3 }],
['isCreatedByUser', { isCreatedByUser: true }],

View file

@ -826,6 +826,9 @@ export function areMessageFieldsEqual(
prevMsg.text === nextMsg.text &&
prevMsg.error === nextMsg.error &&
prevMsg.unfinished === nextMsg.unfinished &&
/** Read by the row: `useGenerationsByLatest` gates the Continue button on it and
* `ContentRender` renders the tool-call-limit notice from it. */
prevMsg.finish_reason === nextMsg.finish_reason &&
prevMsg.createdAt === nextMsg.createdAt &&
prevMsg.depth === nextMsg.depth &&
prevMsg.isCreatedByUser === nextMsg.isCreatedByUser &&

View file

@ -3132,12 +3132,24 @@ describe('ask_user_question run wiring', () => {
const firstAgent = (config: Record<string, unknown>) =>
(config.graphConfig as { agents: Array<Record<string, unknown>> }).agents[0];
/**
* Every run now carries a `PostToolBatch`-only registry for step-budget
* awareness, so registry presence no longer proves HITL wiring. What still
* distinguishes an approval-gated run is the `PreToolUse` policy hook, and
* `PostToolBatch` is deliberately outside the SDK's
* `RESULT_ALTERING_HOOK_EVENTS`, so it cannot disable eager tool prestart.
*/
const hasToolApprovalPolicyHook = (config: Record<string, unknown>) =>
(config.hooks as { hasHookFor?: (event: string) => boolean } | undefined)?.hasHookFor?.(
'PreToolUse',
) === true;
it('attaches the checkpointer WITHOUT humanInTheLoop when hitlCapable and the ask tool is present (approval disabled)', async () => {
const config = await runAndGetConfig(makeAgent({ tools: [askToolInstance] }), {
hitlCapable: true,
});
expect(config).not.toHaveProperty('humanInTheLoop');
expect(config).not.toHaveProperty('hooks');
expect(hasToolApprovalPolicyHook(config)).toBe(false);
expect(getCheckpointer(config)).toBeDefined();
const agent = firstAgent(config);
// The tool rides the in-graph direct path (graphTools) — never the

View file

@ -1,4 +1,5 @@
import { ErrorTypes } from 'librechat-data-provider';
import { GraphRecursionError } from '@langchain/langgraph';
import {
GENERIC_PROVIDER_ERROR,
getLangChainErrorCode,
@ -6,6 +7,7 @@ import {
getUserFacingProviderError,
isFatalAgentInitializationError,
AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE,
isStepLimitError,
} from './errors';
describe('isFatalAgentInitializationError', () => {
@ -106,3 +108,53 @@ describe('LangChain provider error text', () => {
});
});
});
describe('isStepLimitError', () => {
it('recognizes the real error LangGraph throws when a graph runs out of supersteps', () => {
/** Constructed exactly as `pregel/index.js` does on `loop.status === 'out_of_steps'`. */
const thrown = new GraphRecursionError(
'Recursion limit of 50 reached without hitting a stop condition. You can increase the limit by setting the "recursionLimit" config key.',
{ lc_error_code: 'GRAPH_RECURSION_LIMIT' },
);
expect(isStepLimitError(thrown)).toBe(true);
});
it('matches on `lc_error_code` alone, so a minified class name cannot break detection', () => {
expect(isStepLimitError({ lc_error_code: 'GRAPH_RECURSION_LIMIT' })).toBe(true);
});
it('matches on `name` alone, so an error rebuilt without fields is still recognized', () => {
expect(isStepLimitError({ name: 'GraphRecursionError' })).toBe(true);
});
it('unwraps a graph error rethrown inside a wrapper', () => {
const wrapper = new Error('agent run failed', {
cause: new Error('node failed', {
cause: new GraphRecursionError('Recursion limit of 50 reached', {
lc_error_code: 'GRAPH_RECURSION_LIMIT',
}),
}),
});
expect(isStepLimitError(wrapper)).toBe(true);
});
it('terminates on a self-referential cause chain instead of looping forever', () => {
const looping: { name: string; cause?: unknown } = { name: 'SomeError' };
looping.cause = looping;
expect(isStepLimitError(looping)).toBe(false);
});
it.each([
undefined,
null,
'GraphRecursionError',
new Error('rate limited'),
{ lc_error_code: 'GRAPH_VALUE_ERROR' },
{ name: 'GraphInterrupt' },
])('leaves case %# on the ordinary error path', (error) => {
expect(isStepLimitError(error)).toBe(false);
});
});

View file

@ -101,3 +101,44 @@ export function getUserFacingProviderError(error: unknown, protectionEnabled: bo
}
return stripLangChainTroubleshootingUrl(error.message) || GENERIC_PROVIDER_ERROR;
}
/**
* LangGraph's stable machine identifier for "the graph ran out of supersteps".
* Set as `lc_error_code` on the `GraphRecursionError` thrown by the Pregel loop
* when `loop.status === 'out_of_steps'`.
*/
const GRAPH_RECURSION_LIMIT_CODE = 'GRAPH_RECURSION_LIMIT';
/** Bounded `cause` walk: a graph error may be rethrown wrapped by an outer node. */
const MAX_CAUSE_DEPTH = 4;
/**
* Whether `error` is the agent graph exhausting its per-turn step budget
* (`recursionLimit`), as opposed to anything actually going wrong.
*
* This is a normal terminal condition, not a failure: the turn is persisted as
* `unfinished` with `Constants.TOOL_CALL_LIMIT_FINISH_REASON` so the UI can offer
* to continue, instead of surfacing a red error bubble the user cannot act on.
*
* Both markers are checked because they fail independently. `lc_error_code` is the
* documented contract but is only present on errors constructed with the fields
* argument, while `name` is assigned in the constructor body and therefore survives
* class-name minification. Either one alone is sufficient evidence.
*/
export function isStepLimitError(error: unknown): boolean {
let current = error;
for (let depth = 0; depth < MAX_CAUSE_DEPTH && current != null; depth++) {
if (typeof current !== 'object') {
return false;
}
const candidate = current as { lc_error_code?: unknown; name?: unknown; cause?: unknown };
if (
candidate.lc_error_code === GRAPH_RECURSION_LIMIT_CODE ||
candidate.name === 'GraphRecursionError'
) {
return true;
}
current = candidate.cause;
}
return false;
}

View file

@ -42,6 +42,7 @@ export * from './resources';
export * from './responses';
export * from './skills';
export * from './phases';
export * from './stepBudget';
export * from './startup';
export * from './subagentThreads';
export * from './subagentActivity';

View file

@ -19,7 +19,7 @@ type CreateRunArgs = {
appConfig?: Record<string, unknown>;
requestBody?: Record<string, unknown>;
};
type ProcessStreamConfig = { configurable?: Record<string, unknown> };
type ProcessStreamConfig = { configurable?: Record<string, unknown>; recursionLimit?: number };
function createMockReq(
user?: Record<string, unknown>,
@ -126,6 +126,19 @@ describe('createAgentChatCompletion - MCP permission user propagation', () => {
expect(streamConfig.configurable?.user_id).toBe('user-123');
});
it('invokes the graph with the same resolved recursion limit the step-budget hook uses', async () => {
deps.appConfig = { endpoints: { agents: { recursionLimit: 123 } } };
await createAgentChatCompletion(
createMockReq({ id: 'user-123', role: 'USER' }),
createMockRes(),
deps,
);
const streamConfig = processStream.mock.calls[0][1] as ProcessStreamConfig;
expect(streamConfig.recursionLimit).toBe(123);
});
it('falls back to a bare id when no authenticated user is attached', async () => {
const req = createMockReq(undefined);

View file

@ -25,6 +25,7 @@ import type {
MessageFilterConfig,
MessageFilterPiiConfig,
StatefulCodeEnvironment,
TAgentsEndpoint,
} from 'librechat-data-provider';
import type { Response as ServerResponse, Request } from 'express';
import type {
@ -73,6 +74,7 @@ import { contentFilterUninspectableResponse } from '~/protection/files';
import { createMCPRuntimeRequestBody } from '~/mcp/request';
import { getUserFacingProviderError } from '../errors';
import { collectReachableAgents } from '../traversal';
import { resolveRecursionLimit } from '../config';
import { getDynamicToolContexts } from '../hitl';
import { createSafeUser } from '~/utils';
@ -164,6 +166,7 @@ interface InitializedAgent {
subagentAgentConfigs?: InitializedAgent[];
/** Names of tools with the host-injected `intent` label param (see `agents/intent.ts`). */
intentToolNames?: string[];
recursion_limit?: number;
[key: string]: unknown;
}
@ -819,6 +822,10 @@ export async function createAgentChatCompletion(
? { intentToolNames: initializedAgent.intentToolNames }
: {}),
},
recursionLimit: resolveRecursionLimit(
agentsConfig as Partial<TAgentsEndpoint> | undefined,
initializedAgent,
),
signal: abortController.signal,
streamMode: 'values',
version: 'v2',

View file

@ -79,11 +79,15 @@ import {
ASK_USER_QUESTION_TOOL_NAME,
createAskUserQuestionTool,
} from '~/agents/hitl/askUserQuestionTool';
import {
resolveStreamLimits,
resolveSubagentMaxTurns,
resolveRecursionLimit,
} from '~/agents/config';
import { applyCustomHandoffPromptKeyCompatibility } from '~/agents/handoffPromptKeyCompatibility';
import { stripIntentFromToolRegistry, stripIntentFromToolDefinitions } from '~/agents/intent';
import { extractDefaultParams, resolveReasoningParams } from '~/endpoints/openai/llm';
import { getLLMConfig as getAnthropicLLMConfig } from '~/endpoints/anthropic/llm';
import { resolveStreamLimits, resolveSubagentMaxTurns } from '~/agents/config';
import { CREATE_FILE_TOOL_NAME, EDIT_FILE_TOOL_NAME } from '~/agents/tools';
import { buildAgentInitialToolSessions } from '~/agents/codeFilesSession';
import { getProviderConfig } from '~/endpoints/config/providers';
@ -91,6 +95,7 @@ import { resolveHeaders, createSafeUser } from '~/utils/env';
import { getAgentCheckpointer } from '~/agents/checkpointer';
import { getPluginHookSource } from '~/agents/hooks/source';
import { getOpenAIConfig } from '~/endpoints/openai/config';
import { createStepBudgetHook } from '~/agents/stepBudget';
import { buildHITLRunWiring } from '~/agents/hitl/runtime';
import { buildLangfuseConfig } from '~/langfuse/config';
import { resolveConfigHeaders } from '~/utils/headers';
@ -1996,6 +2001,23 @@ export async function createRun({
stopFinalizeRegistry.register('StopFinalize', { hooks: [steering.terminalHook] });
}
}
/**
* Step-budget awareness. Registered unconditionally (no config, no checkpointer,
* no SDK capability gate, since `additionalContext` has been part of `BaseHookOutput`
* since hooks shipped) because running out of steps mid-turn is a failure mode on
* every ingress, and a model that knows its budget is running low usually avoids
* it. Registered after the label/steer hooks so their content-slot ordering is
* untouched; `additionalContexts` accumulate independently of injected messages.
*/
hooks = hooks ?? new HookRegistry();
hooks.register('PostToolBatch', {
hooks: [
createStepBudgetHook({
recursionLimit: resolveRecursionLimit(agentsEndpointConfig, agents[0]),
}),
],
internal: true,
});
/**
* Deployment-plugin hooks (Agent Plugins `ai.librechat/hooks/hooks.json`)
* register last so internal policy hooks (HITL, labels, steering) keep

View file

@ -0,0 +1,142 @@
import type { PostToolBatchHookInput, PostToolBatchHookOutput } from '@librechat/agents';
import { createStepBudgetHook, remainingToolRounds, buildBudgetNotice } from './stepBudget';
jest.mock('@librechat/data-schemas', () => ({
logger: { debug: jest.fn(), warn: jest.fn(), error: jest.fn(), info: jest.fn() },
}));
function batch(toolNames: string[] = ['search']): PostToolBatchHookInput {
return {
hook_event_name: 'PostToolBatch',
runId: 'run-1',
entries: toolNames.map((toolName, index) => ({
toolName,
toolInput: {},
toolUseId: `t${index}`,
status: 'success',
})),
};
}
const signal = new AbortController().signal;
/** Drives `rounds` tool batches through one hook and returns each round's context. */
async function drain(recursionLimit: number, rounds: number): Promise<Array<string | undefined>> {
const hook = createStepBudgetHook({ recursionLimit });
const emitted: Array<string | undefined> = [];
for (let i = 0; i < rounds; i++) {
const output: PostToolBatchHookOutput = await hook(batch(), signal);
emitted.push(output.additionalContext);
}
return emitted;
}
describe('remainingToolRounds', () => {
it('reserves a step for the final answer so a budget is never spent entirely on tools', () => {
/** 50 steps: round 24 ends on step 48, leaving step 49 to answer. */
expect(remainingToolRounds(50, 24)).toBe(0);
expect(remainingToolRounds(50, 23)).toBe(1);
});
it('never reports negative headroom once the budget is already overspent', () => {
expect(remainingToolRounds(50, 25)).toBe(0);
expect(remainingToolRounds(50, 400)).toBe(0);
});
it('reports no headroom for a limit too small to afford a round plus an answer', () => {
expect(remainingToolRounds(2, 0)).toBe(0);
expect(remainingToolRounds(3, 1)).toBe(0);
});
it('decreases by exactly one round per round spent', () => {
const start = remainingToolRounds(101, 0);
expect(start - remainingToolRounds(101, 1)).toBe(1);
expect(remainingToolRounds(101, 1) - remainingToolRounds(101, 2)).toBe(1);
});
});
describe('createStepBudgetHook', () => {
it('stays silent while the budget is ample, so an ordinary turn never sees a notice', async () => {
const emitted = await drain(50, 20);
expect(emitted.every((context) => context === undefined)).toBe(true);
});
it('counts rounds, not calls: a parallel batch costs one round', async () => {
const hook = createStepBudgetHook({ recursionLimit: 9 });
/** 9 steps affords 4 rounds; one batch of three calls must consume only one. */
expect(await hook(batch(['a', 'b', 'c']), signal)).toEqual({
additionalContext: buildBudgetNotice(3),
});
});
it('counts down over the final rounds instead of repeating one warning', async () => {
const emitted = await drain(50, 24);
const warnings = emitted.filter((context): context is string => context != null);
expect(warnings).toEqual([
buildBudgetNotice(3),
buildBudgetNotice(2),
buildBudgetNotice(1),
buildBudgetNotice(0),
]);
expect(new Set(warnings).size).toBe(warnings.length);
});
it('tells the model to stop calling tools once nothing is left for a result', async () => {
const emitted = await drain(50, 24);
const last = emitted[emitted.length - 1];
expect(last).toContain('no tool-calling budget left');
expect(last).toContain('Do not call any more tools');
});
it('warns from the very first round when the configured limit is tiny', async () => {
/** 7 steps affords 3 rounds, so round 1 already sits inside the warning band. */
const emitted = await drain(7, 1);
expect(emitted[0]).toBe(buildBudgetNotice(2));
});
it('keeps warning after the budget is blown rather than falling silent', async () => {
const emitted = await drain(50, 30);
expect(emitted.slice(24).every((context) => context === buildBudgetNotice(0))).toBe(true);
});
it('tracks each run independently', async () => {
const first = createStepBudgetHook({ recursionLimit: 7 });
const second = createStepBudgetHook({ recursionLimit: 7 });
/** Spends the first run's whole budget. */
await first(batch(), signal);
await first(batch(), signal);
expect(await first(batch(), signal)).toEqual({ additionalContext: buildBudgetNotice(0) });
/** The second run must still see a full budget, not the first run's tally. */
expect(await second(batch(), signal)).toEqual({ additionalContext: buildBudgetNotice(2) });
});
it('does not count an empty batch against the budget', async () => {
const hook = createStepBudgetHook({ recursionLimit: 7 });
expect(await hook(batch([]), signal)).toEqual({});
expect(await hook(batch(), signal)).toEqual({ additionalContext: buildBudgetNotice(2) });
});
it('does not count a subagent-scoped batch against the parent budget', async () => {
const hook = createStepBudgetHook({ recursionLimit: 7 });
expect(await hook({ ...batch(), agentId: 'child' }, signal)).toEqual({});
expect(await hook(batch(), signal)).toEqual({ additionalContext: buildBudgetNotice(2) });
});
});
describe('buildBudgetNotice', () => {
it('hedges the estimate, because unseen graph nodes can also spend steps', () => {
expect(buildBudgetNotice(3)).toContain('about 3 more tool-calling rounds');
});
it('drops the hedge when exactly one round remains', () => {
expect(buildBudgetNotice(1)).toContain('1 more tool-calling round left');
expect(buildBudgetNotice(1)).not.toContain('about');
});
});

View file

@ -0,0 +1,106 @@
import { logger } from '@librechat/data-schemas';
import type { HookCallback } from '@librechat/agents';
/**
* Graph supersteps a single tool-calling round costs: the tool node that runs the
* batch, plus the model node that consumes its results. Derived from the agent
* graph topology (`agentNode -> toolNode -> agentNode`), the same relationship the
* `com_ui_agent_recursion_limit_info` copy describes to admins and that
* `SUBAGENT_RECURSION_MULTIPLIER` encodes for child runs.
*/
const STEPS_PER_TOOL_ROUND = 2;
/**
* Supersteps reserved for the final model call that answers without calling tools.
* A turn that spends its last step on a tool node produces no answer at all, so the
* budget must always hold one step back.
*/
const STEPS_RESERVED_FOR_FINAL_ANSWER = 1;
/**
* Remaining-round count at or below which the model is told to start wrapping up.
* Three rounds is enough runway to finish a partially gathered answer while being
* late enough that a normal short turn never sees the notice at all.
*/
const WARN_AT_REMAINING_ROUNDS = 3;
/**
* Upper bound on the tool-calling rounds still affordable after `roundsUsed` have
* completed, leaving room for a final answer.
*
* An upper bound rather than an exact figure: summarization, handoff and multi-agent
* routing nodes also consume supersteps but are invisible at the tool boundary, so
* the real number can only be lower. The notice wording is hedged accordingly, because it
* exists to convey urgency, and {@link isStepLimitError} still catches the wall.
*/
export function remainingToolRounds(recursionLimit: number, roundsUsed: number): number {
const spent = roundsUsed * STEPS_PER_TOOL_ROUND + STEPS_RESERVED_FOR_FINAL_ANSWER;
const affordable = Math.floor((recursionLimit - spent) / STEPS_PER_TOOL_ROUND);
return Math.max(0, affordable);
}
/**
* The context injected once the budget is nearly spent. Returned as
* `additionalContext`, which the SDK consolidates into a single system-flavored
* message placed immediately before the next model call, the last thing the model
* reads before deciding whether to call another tool.
*
* Graph-state only: nothing here is persisted as message content, so the pressure
* applies to this turn and does not accumulate across the conversation.
*/
export function buildBudgetNotice(remaining: number): string {
if (remaining <= 0) {
return [
'System notice: this turn has no tool-calling budget left.',
'Do not call any more tools; another tool call will be cut off before you can use its result.',
'Write your final answer now from what you already have, and state briefly what remains unresolved.',
].join(' ');
}
const rounds =
remaining === 1 ? '1 more tool-calling round' : `about ${remaining} more tool-calling rounds`;
return [
`System notice: this turn has ${rounds} left before it is cut off.`,
'Stop broadening the search and start converging: use what you already have,',
'make only calls you cannot finish without, and leave room to write the final answer.',
].join(' ');
}
export interface StepBudgetHookOptions {
/** The resolved graph `recursionLimit` this run was invoked with. */
recursionLimit: number;
}
/**
* Builds the run-scoped `PostToolBatch` hook that keeps the model aware of its
* per-turn step budget.
*
* Hitting `recursionLimit` mid-turn is recoverable but never good: the user waits
* for a turn that ends without an answer. Models are poor at tracking how many
* calls they have made, so the fix is to tell them, counting down over the last
* few rounds rather than announcing a budget they will ignore while it is ample.
*
* Counts tool *rounds*, not tool calls: a parallel batch of six calls is one node
* execution and costs one step, so per-call counting would overstate consumption
* six-fold. Empty batches and subagent-scoped events are skipped so only a real
* root tool round advances the countdown.
*/
export function createStepBudgetHook({
recursionLimit,
}: StepBudgetHookOptions): HookCallback<'PostToolBatch'> {
let roundsUsed = 0;
return async (input) => {
if (input.agentId != null || input.entries.length === 0) {
return {};
}
roundsUsed += 1;
const remaining = remainingToolRounds(recursionLimit, roundsUsed);
if (remaining > WARN_AT_REMAINING_ROUNDS) {
return {};
}
logger.debug(
`[stepBudget] Tool round ${roundsUsed} of a ${recursionLimit}-step budget; ~${remaining} rounds left, nudging the model to converge.`,
);
return { additionalContext: buildBudgetNotice(remaining) };
};
}

View file

@ -3346,6 +3346,15 @@ export enum Constants {
SUBAGENT = 'subagent',
/** Poll tool for retrieving the status/result of a backgrounded tool call. */
CHECK_BACKGROUND_TASK = 'check_background_task',
/**
* `finish_reason` stamped on an assistant message whose turn ended because the
* agent exhausted its per-turn graph step budget (`recursionLimit`) rather than
* because the model chose to stop. Distinct from a user abort: nothing failed and
* nothing was cancelled, the turn simply ran out of room. The UI keys its
* "tool call limit reached" notice off this value. The hover Continue control
* is withheld for this reason because the notice already offers the way forward.
*/
TOOL_CALL_LIMIT_FINISH_REASON = 'tool_call_limit',
}
/**