From 2cf1007f3c0acc8036705eb5e2f49592864d0e86 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:38:02 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=BA=20feat:=20Preserve=20Partial=20Wor?= =?UTF-8?q?k=20at=20Tool=20Call=20Limits=20(#15486)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .../__tests__/request.resumeMetadata.spec.js | 51 +++++- .../agents/__tests__/responses.unit.spec.js | 16 ++ api/server/controllers/agents/client.js | 34 ++++ api/server/controllers/agents/client.test.js | 97 +++++++++++ api/server/controllers/agents/request.js | 22 ++- api/server/controllers/agents/responses.js | 3 + api/server/controllers/agents/resume.js | 11 +- .../Chat/Messages/Content/MessageContent.tsx | 23 ++- .../Messages/Content/ToolCallLimitNotice.tsx | 113 +++++++++++++ .../__tests__/ToolCallLimitNotice.test.tsx | 153 ++++++++++++++++++ .../__tests__/UnfinishedMessage.test.tsx | 60 +++++++ .../src/components/Messages/ContentRender.tsx | 11 ++ .../__tests__/useGenerationsByLatest.spec.ts | 30 ++++ client/src/hooks/useGenerationsByLatest.ts | 8 +- client/src/locales/en/translation.json | 8 + client/src/utils/__tests__/messages.test.ts | 2 + client/src/utils/messages.ts | 3 + .../__tests__/run-summarization.test.ts | 14 +- packages/api/src/agents/errors.spec.ts | 52 ++++++ packages/api/src/agents/errors.ts | 41 +++++ packages/api/src/agents/index.ts | 1 + .../api/src/agents/openai/service.spec.ts | 15 +- packages/api/src/agents/openai/service.ts | 7 + packages/api/src/agents/run.ts | 24 ++- packages/api/src/agents/stepBudget.spec.ts | 142 ++++++++++++++++ packages/api/src/agents/stepBudget.ts | 106 ++++++++++++ packages/data-provider/src/config.ts | 9 ++ 27 files changed, 1039 insertions(+), 17 deletions(-) create mode 100644 client/src/components/Chat/Messages/Content/ToolCallLimitNotice.tsx create mode 100644 client/src/components/Chat/Messages/Content/__tests__/ToolCallLimitNotice.test.tsx create mode 100644 client/src/components/Chat/Messages/Content/__tests__/UnfinishedMessage.test.tsx create mode 100644 client/src/hooks/__tests__/useGenerationsByLatest.spec.ts create mode 100644 packages/api/src/agents/stepBudget.spec.ts create mode 100644 packages/api/src/agents/stepBudget.ts diff --git a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js index b1f49b8ab8..a9e2e6475e 100644 --- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js +++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js @@ -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(); + }); }); }); diff --git a/api/server/controllers/agents/__tests__/responses.unit.spec.js b/api/server/controllers/agents/__tests__/responses.unit.spec.js index 49b53bac89..2570a070b0 100644 --- a/api/server/controllers/agents/__tests__/responses.unit.spec.js +++ b/api/server/controllers/agents/__tests__/responses.unit.spec.js @@ -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'); diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 9b9abe2f57..388bddda5f 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -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[]} */ 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', diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 7e04e768f4..39a1648729 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -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' }; diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index 5f57af82c5..8e8880fc52 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -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 }), }; diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index f80f0ebd57..79ce4ea3e2 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -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', diff --git a/api/server/controllers/agents/resume.js b/api/server/controllers/agents/resume.js index cd19216c76..5d767a7c69 100644 --- a/api/server/controllers/agents/resume.js +++ b/api/server/controllers/agents/resume.js @@ -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', + }), }); } diff --git a/client/src/components/Chat/Messages/Content/MessageContent.tsx b/client/src/components/Chat/Messages/Content/MessageContent.tsx index 419625eb6e..a1291c76b1 100644 --- a/client/src/components/Chat/Messages/Content/MessageContent.tsx +++ b/client/src/components/Chat/Messages/Content/MessageContent.tsx @@ -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 }) => ( - -); +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 ( + + + + ); + } + + return ; +}; const MessageContent = ({ text, diff --git a/client/src/components/Chat/Messages/Content/ToolCallLimitNotice.tsx b/client/src/components/Chat/Messages/Content/ToolCallLimitNotice.tsx new file mode 100644 index 0000000000..b4aeaca9a2 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/ToolCallLimitNotice.tsx @@ -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 ( +
+ +

+

+

+ {localize('com_ui_tool_call_limit_body')} +

+ {canAct && ( +
+ + +
+ )} +
+ ); +} diff --git a/client/src/components/Chat/Messages/Content/__tests__/ToolCallLimitNotice.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ToolCallLimitNotice.test.tsx new file mode 100644 index 0000000000..5deb83ef7b --- /dev/null +++ b/client/src/components/Chat/Messages/Content/__tests__/ToolCallLimitNotice.test.tsx @@ -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 = { + 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>; + +/** + * 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 { + return context as unknown as NoticeChatContext; +} + +function renderNotice(context?: Partial, message: TMessage = MESSAGE) { + if (context == null) { + return render(); + } + return render( + + + , + ); +} + +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( + + + , + ); + + expect(screen.getByText(TRANSLATIONS.com_ui_tool_call_limit_title)).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/__tests__/UnfinishedMessage.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/UnfinishedMessage.test.tsx new file mode 100644 index 0000000000..1960f7f324 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/__tests__/UnfinishedMessage.test.tsx @@ -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 }) =>
{children}
, +})); + +jest.mock('~/components/Messages/Content/Error', () => ({ + __esModule: true, + default: ({ text }: { text: string }) => {text}, +})); + +jest.mock('../ToolCallLimitNotice', () => ({ + __esModule: true, + default: () =>
, +})); + +/** `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 = {}) => + ({ messageId: 'response-1', ...overrides }) as TMessage; + +describe('UnfinishedMessage', () => { + it('routes a step-limit turn to the actionable notice, not an error box', () => { + render(); + + 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(); + + 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(); + + expect(screen.queryByTestId('tool-call-limit-notice')).not.toBeInTheDocument(); + }); + + it('localizes the generic warning instead of hardcoding English', () => { + render(); + + expect(screen.queryByText(/The response is incomplete/)).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Messages/ContentRender.tsx b/client/src/components/Messages/ContentRender.tsx index 6416188286..51a9279e27 100644 --- a/client/src/components/Messages/ContentRender.tsx +++ b/client/src/components/Messages/ContentRender.tsx @@ -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} /> + {/** 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 && ( + + )} ); }, areContentRenderPropsEqual); diff --git a/client/src/hooks/__tests__/useGenerationsByLatest.spec.ts b/client/src/hooks/__tests__/useGenerationsByLatest.spec.ts new file mode 100644 index 0000000000..f281edf8b4 --- /dev/null +++ b/client/src/hooks/__tests__/useGenerationsByLatest.spec.ts @@ -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); + }); +}); diff --git a/client/src/hooks/useGenerationsByLatest.ts b/client/src/hooks/useGenerationsByLatest.ts index 88c02511f7..4349291177 100644 --- a/client/src/hooks/useGenerationsByLatest.ts +++ b/client/src/hooks/useGenerationsByLatest.ts @@ -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 && diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index db284326f6..3550b892e3 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -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", diff --git a/client/src/utils/__tests__/messages.test.ts b/client/src/utils/__tests__/messages.test.ts index 517e1a4014..492d942e33 100644 --- a/client/src/utils/__tests__/messages.test.ts +++ b/client/src/utils/__tests__/messages.test.ts @@ -300,6 +300,7 @@ const makeFieldsMsg = (over: Partial = {}): 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]> = [ ['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 }], diff --git a/client/src/utils/messages.ts b/client/src/utils/messages.ts index e8afaac986..532add8de9 100644 --- a/client/src/utils/messages.ts +++ b/client/src/utils/messages.ts @@ -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 && diff --git a/packages/api/src/agents/__tests__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index 5f5e7f17e3..5132ba889b 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -3132,12 +3132,24 @@ describe('ask_user_question run wiring', () => { const firstAgent = (config: Record) => (config.graphConfig as { agents: Array> }).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) => + (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 diff --git a/packages/api/src/agents/errors.spec.ts b/packages/api/src/agents/errors.spec.ts index 8eb490912e..866236e9ef 100644 --- a/packages/api/src/agents/errors.spec.ts +++ b/packages/api/src/agents/errors.spec.ts @@ -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); + }); +}); diff --git a/packages/api/src/agents/errors.ts b/packages/api/src/agents/errors.ts index 1cb6a7bc6a..9a98ca9894 100644 --- a/packages/api/src/agents/errors.ts +++ b/packages/api/src/agents/errors.ts @@ -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; +} diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index 2f1d2c6002..81326fb658 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -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'; diff --git a/packages/api/src/agents/openai/service.spec.ts b/packages/api/src/agents/openai/service.spec.ts index a1f7a25dba..8c91c71212 100644 --- a/packages/api/src/agents/openai/service.spec.ts +++ b/packages/api/src/agents/openai/service.spec.ts @@ -19,7 +19,7 @@ type CreateRunArgs = { appConfig?: Record; requestBody?: Record; }; -type ProcessStreamConfig = { configurable?: Record }; +type ProcessStreamConfig = { configurable?: Record; recursionLimit?: number }; function createMockReq( user?: Record, @@ -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); diff --git a/packages/api/src/agents/openai/service.ts b/packages/api/src/agents/openai/service.ts index f8f5b01bed..9713486b9d 100644 --- a/packages/api/src/agents/openai/service.ts +++ b/packages/api/src/agents/openai/service.ts @@ -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 | undefined, + initializedAgent, + ), signal: abortController.signal, streamMode: 'values', version: 'v2', diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 619846bf54..a76221b03f 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -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 diff --git a/packages/api/src/agents/stepBudget.spec.ts b/packages/api/src/agents/stepBudget.spec.ts new file mode 100644 index 0000000000..0e780ea2e7 --- /dev/null +++ b/packages/api/src/agents/stepBudget.spec.ts @@ -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> { + const hook = createStepBudgetHook({ recursionLimit }); + const emitted: Array = []; + 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'); + }); +}); diff --git a/packages/api/src/agents/stepBudget.ts b/packages/api/src/agents/stepBudget.ts new file mode 100644 index 0000000000..4622da34a3 --- /dev/null +++ b/packages/api/src/agents/stepBudget.ts @@ -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) }; + }; +} diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index a2fdbe96df..857067b784 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -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', } /**