@librechat/agents 3.2.62 → 3.2.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@ import type { ToolCall } from '@langchain/core/messages/tool';
4
4
  import type {
5
5
  HookCallback,
6
6
  PermissionDeniedHookOutput,
7
+ PostToolBatchHookOutput,
7
8
  PostToolUseHookOutput,
8
9
  PreToolUseHookOutput,
9
10
  SubagentStartHookInput,
@@ -365,6 +366,129 @@ describe('Subagent hook integration (end-to-end via Run)', () => {
365
366
  expect(postExecEvents).toContain('researcher-child:calculator');
366
367
  });
367
368
 
369
+ it('top-level event-driven dispatches leave agentId unset (subagent-scope marker)', async () => {
370
+ getChatModelClassSpy.mockImplementation(((provider: Providers) => {
371
+ if (provider === Providers.OPENAI) {
372
+ return class extends FakeChatModel {
373
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
374
+ constructor(_options: any) {
375
+ super({
376
+ responses: ['Calculating.', 'All done.'],
377
+ sleep: 1,
378
+ toolCalls: [createCalculatorToolCall()],
379
+ });
380
+ }
381
+ bindTools(tools: unknown): ReturnType<FakeChatModel['withConfig']> {
382
+ const config = {
383
+ tools,
384
+ } as Parameters<FakeChatModel['withConfig']>[0];
385
+ return this.withConfig(config);
386
+ }
387
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
388
+ } as any;
389
+ }
390
+ return originalGetChatModelClass(provider);
391
+ }) as typeof providers.getChatModelClass);
392
+
393
+ const registry = new HookRegistry();
394
+ const scopeEvents: string[] = [];
395
+
396
+ const preHook: HookCallback<'PreToolUse'> = async (
397
+ input
398
+ ): Promise<PreToolUseHookOutput> => {
399
+ scopeEvents.push(
400
+ `pre:${input.agentId ?? '-'}:${input.executingAgentId ?? '-'}`
401
+ );
402
+ return { decision: 'allow' };
403
+ };
404
+ registry.register('PreToolUse', { hooks: [preHook] });
405
+
406
+ const postHook: HookCallback<'PostToolUse'> = async (
407
+ input
408
+ ): Promise<PostToolUseHookOutput> => {
409
+ scopeEvents.push(
410
+ `post:${input.agentId ?? '-'}:${input.executingAgentId ?? '-'}`
411
+ );
412
+ return {};
413
+ };
414
+ registry.register('PostToolUse', { hooks: [postHook] });
415
+
416
+ const batchHook: HookCallback<'PostToolBatch'> = async (
417
+ input
418
+ ): Promise<PostToolBatchHookOutput> => {
419
+ scopeEvents.push(
420
+ `batch:${input.agentId ?? '-'}:${input.executingAgentId ?? '-'}`
421
+ );
422
+ return {};
423
+ };
424
+ registry.register('PostToolBatch', { hooks: [batchHook] });
425
+
426
+ const dispatchAgentIds: Array<string | undefined> = [];
427
+ const customHandlers: Record<string, t.EventHandler> = {
428
+ [GraphEvents.TOOL_END]: new ToolEndHandler(),
429
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
430
+ [GraphEvents.ON_TOOL_EXECUTE]: {
431
+ handle: (_event, rawData): void => {
432
+ const request = rawData as t.ToolExecuteBatchRequest;
433
+ dispatchAgentIds.push(request.agentId);
434
+ const results: t.ToolExecuteResult[] = request.toolCalls.map(
435
+ (call) => ({
436
+ toolCallId: call.id,
437
+ status: 'success',
438
+ content: '42',
439
+ })
440
+ );
441
+ request.resolve(results);
442
+ },
443
+ },
444
+ };
445
+
446
+ /**
447
+ * The LibreChat shape: a TOP-LEVEL agent whose tools ride
448
+ * `toolDefinitions` (event-driven ToolNode, host executes via
449
+ * ON_TOOL_EXECUTE). Hook inputs here must NOT carry the subagent-scope
450
+ * marker — a host hook keying on `agentId != null` (e.g. a steering
451
+ * drain that must never inject into child state) would otherwise skip
452
+ * every top-level batch. The DISPATCH payload is the opposite: hosts
453
+ * key tool/credential lookup on `request.agentId`, so it must keep
454
+ * identifying the owning agent.
455
+ */
456
+ const run = await Run.create<t.IState>({
457
+ runId: `toplevel-event-hook-${Date.now()}`,
458
+ graphConfig: {
459
+ type: 'standard',
460
+ agents: [
461
+ {
462
+ agentId: 'hook-parent',
463
+ provider: Providers.OPENAI,
464
+ clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
465
+ instructions: 'Use the calculator.',
466
+ maxContextTokens: 8000,
467
+ toolDefinitions: [calculatorDef],
468
+ },
469
+ ],
470
+ },
471
+ returnContent: true,
472
+ skipCleanup: true,
473
+ customHandlers,
474
+ hooks: registry,
475
+ });
476
+
477
+ run.Graph!.overrideTestModel(['Calculating.', 'All done.'], 5, [
478
+ createCalculatorToolCall(),
479
+ ]);
480
+
481
+ await run.processStream(
482
+ { messages: [new HumanMessage('what is 21 * 2?')] },
483
+ callerConfig
484
+ );
485
+
486
+ expect(scopeEvents).toContain('pre:-:hook-parent');
487
+ expect(scopeEvents).toContain('post:-:hook-parent');
488
+ expect(scopeEvents).toContain('batch:-:hook-parent');
489
+ expect(dispatchAgentIds).toEqual(['hook-parent']);
490
+ });
491
+
368
492
  it('child subagent tool ask hooks fail closed instead of starting unsupported nested HITL', async () => {
369
493
  getChatModelClassSpy.mockImplementation(((provider: Providers) => {
370
494
  if (provider === Providers.OPENAI) {
@@ -373,6 +373,7 @@ export class SubagentExecutor {
373
373
  agents: [childInputs],
374
374
  langfuse: this.langfuse,
375
375
  tokenCounter: this.tokenCounter,
376
+ subagentScope: true,
376
377
  /**
377
378
  * Forwarded so the child graph's own `SubagentExecutor` (created in
378
379
  * its `createAgentNode` when `allowNested` keeps subagentConfigs)
@@ -336,6 +336,16 @@ export type StandardGraphInput = {
336
336
  * they already flow through the registry's `CHAT_MODEL_END` handler.
337
337
  */
338
338
  subagentUsageSink?: SubagentUsageSink;
339
+ /**
340
+ * True when this graph IS a subagent child run (set by `SubagentExecutor`
341
+ * when it constructs the child graph). Drives the hook-input `agentId`
342
+ * subagent-scope marker: hook dispatches from this graph's tool nodes
343
+ * carry `agentId` so run-scoped host hooks — which fire for child scopes
344
+ * too, because children inherit the parent's `run_id` — can tell child
345
+ * scope from the top level. Top-level graphs leave this unset and their
346
+ * hook inputs carry only `executingAgentId`.
347
+ */
348
+ subagentScope?: boolean;
339
349
  };
340
350
 
341
351
  export type GraphEdge = {