diff --git a/api/package.json b/api/package.json index e57f4ad963..965b5a1d64 100644 --- a/api/package.json +++ b/api/package.json @@ -46,7 +46,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.2.33", + "@librechat/agents": "^3.2.34", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/package-lock.json b/package-lock.json index c16d5d06ad..e4d683fcd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,7 +61,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.2.33", + "@librechat/agents": "^3.2.34", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -11684,9 +11684,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.2.33", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.2.33.tgz", - "integrity": "sha512-f1+aV4HgG7H8QPdNZdVAF/qQcscuNhZvXYOpM/FPzAVpkN+Ah+BF4dlksb6MCx2HxrqCdV5AxjwobtlIBI7GLg==", + "version": "3.2.34", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.2.34.tgz", + "integrity": "sha512-Njz5wxlDeTKSWpcUBO3ODLClrrcxfmEn1r3W2WTSeVFAYp8vMqvhFGgPPnG82v9fDdqajuMVKTScb0puhglEqg==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.92.0", @@ -44250,7 +44250,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.2.33", + "@librechat/agents": "^3.2.34", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "^1.9.0", diff --git a/packages/api/package.json b/packages/api/package.json index 104253054e..9f69f62ab9 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -112,7 +112,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.2.33", + "@librechat/agents": "^3.2.34", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "^1.9.0", diff --git a/packages/api/src/agents/handlers.spec.ts b/packages/api/src/agents/handlers.spec.ts index b597e8880a..0d843c9290 100644 --- a/packages/api/src/agents/handlers.spec.ts +++ b/packages/api/src/agents/handlers.spec.ts @@ -2928,3 +2928,108 @@ describe('createToolExecuteHandler', () => { }); }); }); + +describe('per-call onResult reporting', () => { + function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolveFn!: (value: T) => void; + const promise = new Promise((res) => { + resolveFn = res; + }); + return { promise, resolve: resolveFn }; + } + + it('reports each result as it settles, before the batch resolves', async () => { + const slowGate = deferred(); + const timeline: string[] = []; + + const fastTool = { + name: 'fast_tool', + invoke: jest.fn(async () => { + timeline.push('fast:done'); + return { content: 'fast result' }; + }), + }; + const slowTool = { + name: 'slow_tool', + invoke: jest.fn(async () => { + await slowGate.promise; + timeline.push('slow:done'); + return { content: 'slow result' }; + }), + }; + + const loadTools: ToolExecuteOptions['loadTools'] = jest.fn(async () => ({ + loadedTools: [fastTool, slowTool] as never[], + })); + const handler = createToolExecuteHandler({ loadTools }); + + const reported: ToolExecuteResult[] = []; + const batchPromise = new Promise((resolve, reject) => { + const request = { + toolCalls: [ + { id: 'call_fast', name: 'fast_tool', args: {} }, + { id: 'call_slow', name: 'slow_tool', args: {} }, + ], + resolve, + reject, + onResult: (result: ToolExecuteResult) => { + reported.push(result); + timeline.push(`reported:${result.toolCallId}`); + }, + } as ToolExecuteBatchRequest & { + onResult: (result: ToolExecuteResult) => void; + }; + handler.handle('on_tool_execute', request); + }); + + // Let the fast tool settle and report while the slow tool is gated. + await new Promise((res) => setTimeout(res, 0)); + expect(reported.map((r) => r.toolCallId)).toEqual(['call_fast']); + + slowGate.resolve(); + const results = await batchPromise; + + expect(reported.map((r) => r.toolCallId)).toEqual(['call_fast', 'call_slow']); + expect(timeline.indexOf('reported:call_fast')).toBeLessThan(timeline.indexOf('slow:done')); + expect(results.map((r) => r.toolCallId).sort()).toEqual(['call_fast', 'call_slow']); + expect(results.find((r) => r.toolCallId === 'call_fast')?.content).toBe('fast result'); + }); + + it('keeps the batch resolving when onResult throws', async () => { + const tool = { + name: 'sturdy_tool', + invoke: jest.fn(async () => ({ content: 'ok' })), + }; + const loadTools: ToolExecuteOptions['loadTools'] = jest.fn(async () => ({ + loadedTools: [tool] as never[], + })); + const handler = createToolExecuteHandler({ loadTools }); + + const results = await new Promise((resolve, reject) => { + const request = { + toolCalls: [{ id: 'call_1', name: 'sturdy_tool', args: {} }], + resolve, + reject, + onResult: () => { + throw new Error('listener exploded'); + }, + } as ToolExecuteBatchRequest & { onResult: () => void }; + handler.handle('on_tool_execute', request); + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ toolCallId: 'call_1', content: 'ok' }); + }); + + it('behaves identically when no onResult is provided', async () => { + const capturedConfigs: Record[] = []; + const handler = createHandler(capturedConfigs); + + const results = await invokeHandler(handler, [ + { id: 'call_1', name: Constants.EXECUTE_CODE, args: { lang: 'py', code: '1' } }, + ]); + + expect(results).toHaveLength(1); + expect(results[0].status).toBe('success'); + }); +}); diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index 1c9787d8de..2de8884d2c 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -15,17 +15,17 @@ import type { StructuredToolInterface } from '@librechat/agents/langchain/tools' import type { CodeEnvRef } from 'librechat-data-provider'; import type { SkillFileRecord } from './skillFiles'; import type { ServerRequest } from '~/types'; -import { logAxiosError, runOutsideTracing } from '~/utils'; -import { buildSkillPrimeMessage } from './skills'; -import { cleanCodeToolOutput } from './cleanup'; -import { primeSkillFiles } from './skillFiles'; import { CREATE_FILE_TOOL_NAME, EDIT_FILE_TOOL_NAME, HOST_FILE_AUTHORING_ARTIFACT_KEY, isCodeSessionToolName, } from './tools'; +import { logAxiosError, runOutsideTracing } from '~/utils'; import { parseFrontmatter } from '../skills/import'; +import { buildSkillPrimeMessage } from './skills'; +import { cleanCodeToolOutput } from './cleanup'; +import { primeSkillFiles } from './skillFiles'; export interface ToolEndCallbackData { output: { @@ -3151,6 +3151,24 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand return { handle: async (_event: string, data: ToolExecuteBatchRequest) => { const { toolCalls, agentId, configurable, metadata, resolve, reject } = data; + /** Optional per-call channel (agents SDK > 3.2.33); cast keeps older + * installed SDK typings compiling until the release lands. */ + const onResult = ( + data as ToolExecuteBatchRequest & { + onResult?: (result: ToolExecuteResult) => void; + } + ).onResult; + /** Reports a settled result so the agent graph can emit that call's + * completion immediately instead of waiting for the whole batch; + * `resolve` below remains the authoritative batch outcome. */ + const reportResult = (result: ToolExecuteResult): ToolExecuteResult => { + try { + onResult?.(result); + } catch (callbackError) { + logger.warn('[ON_TOOL_EXECUTE] onResult callback error:', callbackError); + } + return result; + }; try { await runOutsideTracing(async () => { @@ -3455,7 +3473,7 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand const queueKey = getFileAuthoringQueueKey(tc, mergedConfigurable); if (!queueKey) { - return await execute(); + return reportResult(await execute()); } let sandboxContext: SandboxSessionContext | undefined; if (queueKey.startsWith('sandbox:')) { @@ -3476,7 +3494,7 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand () => undefined, ), ); - return await resultPromise; + return reportResult(await resultPromise); }), );