📬 feat: Report Tool Results Per Call via onResult Channel (#13698)

* 📬 feat: Report Tool Results Per Call via onResult Channel

Tool batches already execute in parallel here, but results were only
delivered to the agent graph through the single resolve(results[])
call — so a fast tool's completion event waited on the slowest call
in the batch. Report each result through the optional onResult channel
(agents SDK > 3.2.33) as it settles, letting the graph emit that
call's completion immediately. resolve remains the authoritative batch
outcome; the callback is optional-chained, so this is a no-op until
the SDK release lands and remains backward compatible after.

* 🧹 chore: Prettier Formatting in onResult Spec

* 🧹 chore: Sort Imports in handlers.ts

* 🔧 chore: Update @librechat/agents dependency to version 3.2.34 in package-lock.json and related package.json files
This commit is contained in:
Danny Avila 2026-06-11 20:38:27 -04:00 committed by GitHub
parent b39ec16ff0
commit a8a63604b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 136 additions and 13 deletions

View file

@ -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",

10
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -2928,3 +2928,108 @@ describe('createToolExecuteHandler', () => {
});
});
});
describe('per-call onResult reporting', () => {
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolveFn!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolveFn = res;
});
return { promise, resolve: resolveFn };
}
it('reports each result as it settles, before the batch resolves', async () => {
const slowGate = deferred<void>();
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<ToolExecuteResult[]>((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<ToolExecuteResult[]>((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<string, unknown>[] = [];
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');
});
});

View file

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