mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 17:44:11 +00:00
🧺 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:
parent
71544f8236
commit
2cf1007f3c
27 changed files with 1039 additions and 17 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue