🧺 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',
}),
});
}