mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
📬 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:
parent
b39ec16ff0
commit
a8a63604b9
5 changed files with 136 additions and 13 deletions
|
|
@ -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
10
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue