@librechat/agents 3.2.34 → 3.2.36

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.
Files changed (128) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +119 -9
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/agents/projection.cjs +25 -0
  4. package/dist/cjs/agents/projection.cjs.map +1 -0
  5. package/dist/cjs/common/enum.cjs +13 -0
  6. package/dist/cjs/common/enum.cjs.map +1 -1
  7. package/dist/cjs/graphs/Graph.cjs +106 -3
  8. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  9. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +26 -4
  10. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  11. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +20 -0
  12. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  13. package/dist/cjs/llm/invoke.cjs +49 -8
  14. package/dist/cjs/llm/invoke.cjs.map +1 -1
  15. package/dist/cjs/main.cjs +7 -0
  16. package/dist/cjs/messages/budget.cjs +23 -0
  17. package/dist/cjs/messages/budget.cjs.map +1 -0
  18. package/dist/cjs/messages/cache.cjs +1 -0
  19. package/dist/cjs/messages/cache.cjs.map +1 -1
  20. package/dist/cjs/messages/content.cjs +12 -14
  21. package/dist/cjs/messages/content.cjs.map +1 -1
  22. package/dist/cjs/messages/index.cjs +1 -0
  23. package/dist/cjs/messages/prune.cjs +31 -13
  24. package/dist/cjs/messages/prune.cjs.map +1 -1
  25. package/dist/cjs/run.cjs +7 -2
  26. package/dist/cjs/run.cjs.map +1 -1
  27. package/dist/cjs/summarization/node.cjs +12 -1
  28. package/dist/cjs/summarization/node.cjs.map +1 -1
  29. package/dist/cjs/tools/search/format.cjs +91 -2
  30. package/dist/cjs/tools/search/format.cjs.map +1 -1
  31. package/dist/cjs/tools/search/tool.cjs +4 -3
  32. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  33. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +138 -2
  34. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  35. package/dist/cjs/utils/tokens.cjs +30 -0
  36. package/dist/cjs/utils/tokens.cjs.map +1 -1
  37. package/dist/esm/agents/AgentContext.mjs +121 -11
  38. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  39. package/dist/esm/agents/projection.mjs +25 -0
  40. package/dist/esm/agents/projection.mjs.map +1 -0
  41. package/dist/esm/common/enum.mjs +13 -0
  42. package/dist/esm/common/enum.mjs.map +1 -1
  43. package/dist/esm/graphs/Graph.mjs +107 -4
  44. package/dist/esm/graphs/Graph.mjs.map +1 -1
  45. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +26 -4
  46. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  47. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +20 -0
  48. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  49. package/dist/esm/llm/invoke.mjs +49 -8
  50. package/dist/esm/llm/invoke.mjs.map +1 -1
  51. package/dist/esm/main.mjs +6 -4
  52. package/dist/esm/messages/budget.mjs +23 -0
  53. package/dist/esm/messages/budget.mjs.map +1 -0
  54. package/dist/esm/messages/cache.mjs +1 -1
  55. package/dist/esm/messages/cache.mjs.map +1 -1
  56. package/dist/esm/messages/content.mjs +12 -15
  57. package/dist/esm/messages/content.mjs.map +1 -1
  58. package/dist/esm/messages/index.mjs +1 -0
  59. package/dist/esm/messages/prune.mjs +31 -13
  60. package/dist/esm/messages/prune.mjs.map +1 -1
  61. package/dist/esm/run.mjs +7 -2
  62. package/dist/esm/run.mjs.map +1 -1
  63. package/dist/esm/summarization/node.mjs +12 -1
  64. package/dist/esm/summarization/node.mjs.map +1 -1
  65. package/dist/esm/tools/search/format.mjs +91 -2
  66. package/dist/esm/tools/search/format.mjs.map +1 -1
  67. package/dist/esm/tools/search/tool.mjs +4 -3
  68. package/dist/esm/tools/search/tool.mjs.map +1 -1
  69. package/dist/esm/tools/subagent/SubagentExecutor.mjs +138 -2
  70. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  71. package/dist/esm/utils/tokens.mjs +30 -1
  72. package/dist/esm/utils/tokens.mjs.map +1 -1
  73. package/dist/types/agents/AgentContext.d.ts +37 -4
  74. package/dist/types/agents/projection.d.ts +26 -0
  75. package/dist/types/common/enum.d.ts +13 -0
  76. package/dist/types/graphs/Graph.d.ts +8 -1
  77. package/dist/types/index.d.ts +1 -0
  78. package/dist/types/llm/invoke.d.ts +1 -1
  79. package/dist/types/messages/budget.d.ts +11 -0
  80. package/dist/types/messages/cache.d.ts +7 -0
  81. package/dist/types/messages/content.d.ts +5 -0
  82. package/dist/types/messages/index.d.ts +1 -0
  83. package/dist/types/messages/prune.d.ts +4 -0
  84. package/dist/types/run.d.ts +1 -0
  85. package/dist/types/tools/search/format.d.ts +4 -1
  86. package/dist/types/tools/search/types.d.ts +7 -0
  87. package/dist/types/tools/subagent/SubagentExecutor.d.ts +11 -1
  88. package/dist/types/types/graph.d.ts +89 -3
  89. package/dist/types/types/run.d.ts +13 -0
  90. package/dist/types/utils/tokens.d.ts +7 -0
  91. package/package.json +1 -1
  92. package/src/agents/AgentContext.ts +172 -8
  93. package/src/agents/__tests__/AgentContext.test.ts +235 -2
  94. package/src/agents/__tests__/projection.test.ts +73 -0
  95. package/src/agents/projection.ts +46 -0
  96. package/src/common/enum.ts +13 -0
  97. package/src/graphs/Graph.ts +168 -0
  98. package/src/index.ts +3 -0
  99. package/src/llm/anthropic/utils/cross-provider-reasoning.test.ts +317 -0
  100. package/src/llm/anthropic/utils/message_inputs.ts +78 -16
  101. package/src/llm/bedrock/utils/cross-provider-reasoning.test.ts +131 -0
  102. package/src/llm/bedrock/utils/message_inputs.ts +35 -0
  103. package/src/llm/invoke.test.ts +79 -1
  104. package/src/llm/invoke.ts +58 -4
  105. package/src/messages/budget.ts +32 -0
  106. package/src/messages/cache.ts +1 -1
  107. package/src/messages/content.ts +24 -32
  108. package/src/messages/index.ts +1 -0
  109. package/src/messages/prune.ts +39 -2
  110. package/src/run.ts +5 -0
  111. package/src/scripts/subagent-usage-sink.ts +176 -0
  112. package/src/specs/context-accuracy.live.test.ts +409 -0
  113. package/src/specs/context-usage-event.test.ts +117 -0
  114. package/src/specs/context-usage.live.test.ts +297 -0
  115. package/src/specs/prune.test.ts +51 -1
  116. package/src/specs/subagent.test.ts +124 -1
  117. package/src/summarization/__tests__/node.test.ts +60 -1
  118. package/src/summarization/node.ts +20 -1
  119. package/src/tools/__tests__/SubagentExecutor.test.ts +443 -1
  120. package/src/tools/search/format.test.ts +242 -0
  121. package/src/tools/search/format.ts +122 -5
  122. package/src/tools/search/tool.ts +5 -1
  123. package/src/tools/search/types.ts +7 -0
  124. package/src/tools/subagent/SubagentExecutor.ts +221 -3
  125. package/src/types/graph.ts +94 -1
  126. package/src/types/run.ts +13 -0
  127. package/src/utils/__tests__/apportion.test.ts +32 -0
  128. package/src/utils/tokens.ts +33 -0
@@ -4,7 +4,9 @@ import type { BaseMessage } from '@langchain/core/messages';
4
4
  import type {
5
5
  AgentInputs,
6
6
  ResolvedSubagentConfig,
7
+ StandardGraphInput,
7
8
  SubagentUpdateEvent,
9
+ SubagentUsageEvent,
8
10
  ToolExecuteBatchRequest,
9
11
  ToolExecuteResult,
10
12
  } from '@/types';
@@ -17,7 +19,7 @@ import {
17
19
  summarizeEvent,
18
20
  } from '../subagent';
19
21
  import { sanitizeForwardedSubagentUpdateData } from '../subagent/SubagentExecutor';
20
- import { Providers, GraphEvents, StepTypes } from '@/common';
22
+ import { Constants, Providers, GraphEvents, StepTypes } from '@/common';
21
23
  import { AgentContext } from '@/agents/AgentContext';
22
24
  import { HookRegistry } from '@/hooks/HookRegistry';
23
25
  import { HandlerRegistry } from '@/events';
@@ -456,6 +458,446 @@ describe('SubagentExecutor', () => {
456
458
  expect(observedLangfuse).toBe(langfuse);
457
459
  });
458
460
 
461
+ describe('usage sink', () => {
462
+ type CapturedCallbackHandler = {
463
+ handleChatModelStart?: (
464
+ llm: unknown,
465
+ messages: unknown,
466
+ runId: string,
467
+ parentRunId?: string,
468
+ extraParams?: Record<string, unknown>,
469
+ tags?: string[],
470
+ metadata?: Record<string, unknown>
471
+ ) => unknown;
472
+ handleLLMEnd?: (output: unknown, runId: string) => unknown;
473
+ handleLLMError?: (err: unknown, runId: string) => unknown;
474
+ };
475
+ type CapturedInvokeOptions = { callbacks?: CapturedCallbackHandler[] };
476
+
477
+ /**
478
+ * Stub factory that records the `StandardGraphInput` the executor
479
+ * builds and the options passed to `workflow.invoke`, so tests can
480
+ * drive the attached usage-capture callback directly (the stubbed
481
+ * invoke never makes real model calls, so callbacks would otherwise
482
+ * never fire).
483
+ */
484
+ function makeCapturingGraphFactory(driveDuringInvoke?: {
485
+ drive: (handler: CapturedCallbackHandler) => void | Promise<void>;
486
+ }): {
487
+ factory: (input: StandardGraphInput) => StandardGraph;
488
+ getInput: () => StandardGraphInput | undefined;
489
+ getInvokeOptions: () => CapturedInvokeOptions | undefined;
490
+ } {
491
+ let capturedInput: StandardGraphInput | undefined;
492
+ let capturedOptions: CapturedInvokeOptions | undefined;
493
+ const factory = (input: StandardGraphInput): StandardGraph => {
494
+ capturedInput = input;
495
+ return {
496
+ createWorkflow: (): { invoke: jest.Mock } => ({
497
+ invoke: jest
498
+ .fn()
499
+ .mockImplementation(
500
+ async (_input: unknown, options: CapturedInvokeOptions) => {
501
+ capturedOptions = options;
502
+ const usageHandler = options.callbacks?.find(
503
+ (cb) => cb.handleLLMEnd != null
504
+ );
505
+ if (driveDuringInvoke && usageHandler) {
506
+ await driveDuringInvoke.drive(usageHandler);
507
+ }
508
+ return { messages: [new AIMessage('child done')] };
509
+ }
510
+ ),
511
+ }),
512
+ clearHeavyState: jest.fn(),
513
+ } as unknown as StandardGraph;
514
+ };
515
+ return {
516
+ factory,
517
+ getInput: () => capturedInput,
518
+ getInvokeOptions: () => capturedOptions,
519
+ };
520
+ }
521
+
522
+ const makeChoice = (
523
+ usage: Record<string, number> | undefined
524
+ ): unknown => ({
525
+ text: 'ok',
526
+ message: new AIMessage({
527
+ content: 'ok',
528
+ ...(usage
529
+ ? {
530
+ usage_metadata: usage as unknown as AIMessage['usage_metadata'],
531
+ }
532
+ : {}),
533
+ }),
534
+ });
535
+
536
+ const makeLLMEndOutput = (
537
+ usage: Record<string, number> | undefined
538
+ ): unknown => ({
539
+ generations: [[makeChoice(usage)]],
540
+ });
541
+
542
+ it('forwards a wrapped sink into the child graph input that rewrites runId to the root run', async () => {
543
+ const events: SubagentUsageEvent[] = [];
544
+ const { factory, getInput } = makeCapturingGraphFactory();
545
+ const executor = createExecutor({
546
+ usageSink: (event) => {
547
+ events.push(event);
548
+ },
549
+ createChildGraph: factory,
550
+ });
551
+
552
+ await executor.execute({
553
+ description: 'Research this topic',
554
+ subagentType: 'researcher',
555
+ });
556
+
557
+ const forwarded = getInput()?.subagentUsageSink;
558
+ expect(typeof forwarded).toBe('function');
559
+ /**
560
+ * Simulate a NESTED child's emission: its executor stamps `runId`
561
+ * with its own parent (an intermediate `*_sub_*` id). The wrapper
562
+ * must rewrite it to THIS executor's parent run so the host always
563
+ * sees root-run attribution, while the emitting child's identity
564
+ * (`subagentRunId`) is preserved.
565
+ */
566
+ forwarded?.({
567
+ usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
568
+ model: 'gpt-4o-mini',
569
+ provider: Providers.OPENAI,
570
+ subagentType: 'nested-grandchild',
571
+ subagentRunId: 'test-run_sub_a_sub_b',
572
+ subagentAgentId: 'grandchild',
573
+ runId: 'test-run_sub_a',
574
+ });
575
+
576
+ expect(events).toHaveLength(1);
577
+ expect(events[0].runId).toBe('test-run');
578
+ expect(events[0].subagentRunId).toBe('test-run_sub_a_sub_b');
579
+ expect(events[0].subagentType).toBe('nested-grandchild');
580
+ });
581
+
582
+ it('does not attach a capture callback when no sink is provided', async () => {
583
+ const { factory, getInvokeOptions } = makeCapturingGraphFactory();
584
+ const executor = createExecutor({ createChildGraph: factory });
585
+
586
+ await executor.execute({
587
+ description: 'Research this topic',
588
+ subagentType: 'researcher',
589
+ });
590
+
591
+ expect(getInvokeOptions()?.callbacks).toEqual([]);
592
+ });
593
+
594
+ it('emits tagged usage events with per-call ls_model_name', async () => {
595
+ const events: SubagentUsageEvent[] = [];
596
+ const { factory } = makeCapturingGraphFactory({
597
+ drive: async (handler) => {
598
+ await handler.handleChatModelStart?.(
599
+ {},
600
+ [[]],
601
+ 'call-1',
602
+ undefined,
603
+ undefined,
604
+ undefined,
605
+ { ls_model_name: 'gpt-4o-mini-2024-07-18' }
606
+ );
607
+ await handler.handleLLMEnd?.(
608
+ makeLLMEndOutput({
609
+ input_tokens: 11,
610
+ output_tokens: 7,
611
+ total_tokens: 18,
612
+ }),
613
+ 'call-1'
614
+ );
615
+ },
616
+ });
617
+ const executor = createExecutor({
618
+ usageSink: (event) => {
619
+ events.push(event);
620
+ },
621
+ createChildGraph: factory,
622
+ });
623
+
624
+ await executor.execute({
625
+ description: 'Research this topic',
626
+ subagentType: 'researcher',
627
+ });
628
+
629
+ expect(events).toHaveLength(1);
630
+ const event = events[0];
631
+ expect(event.usage).toEqual({
632
+ input_tokens: 11,
633
+ output_tokens: 7,
634
+ total_tokens: 18,
635
+ });
636
+ expect(event.model).toBe('gpt-4o-mini-2024-07-18');
637
+ expect(event.provider).toBe(Providers.OPENAI);
638
+ expect(event.subagentType).toBe('researcher');
639
+ expect(event.subagentAgentId).toBe('child-agent');
640
+ expect(event.subagentRunId).toContain('test-run_sub_');
641
+ expect(event.runId).toBe('test-run');
642
+ });
643
+
644
+ it('falls back to the configured model when a call has no ls_model_name', async () => {
645
+ const events: SubagentUsageEvent[] = [];
646
+ const { factory } = makeCapturingGraphFactory({
647
+ drive: async (handler) => {
648
+ await handler.handleLLMEnd?.(
649
+ makeLLMEndOutput({
650
+ input_tokens: 3,
651
+ output_tokens: 2,
652
+ total_tokens: 5,
653
+ }),
654
+ 'call-1'
655
+ );
656
+ },
657
+ });
658
+ const executor = createExecutor({
659
+ usageSink: (event) => {
660
+ events.push(event);
661
+ },
662
+ createChildGraph: factory,
663
+ });
664
+
665
+ await executor.execute({
666
+ description: 'Research this topic',
667
+ subagentType: 'researcher',
668
+ });
669
+
670
+ expect(events).toHaveLength(1);
671
+ /** `makeChildInputs` configures `clientOptions.modelName`. */
672
+ expect(events[0].model).toBe('gpt-4o-mini');
673
+ });
674
+
675
+ it('emits one event per generation group when a call has multiple completions (n > 1)', async () => {
676
+ const usage = { input_tokens: 10, output_tokens: 4, total_tokens: 14 };
677
+ const events: SubagentUsageEvent[] = [];
678
+ const { factory } = makeCapturingGraphFactory({
679
+ drive: async (handler) => {
680
+ /**
681
+ * One provider request with two choices — both carry the same
682
+ * request-level usage. Emitting per choice would double-bill.
683
+ */
684
+ await handler.handleLLMEnd?.(
685
+ { generations: [[makeChoice(usage), makeChoice(usage)]] },
686
+ 'call-1'
687
+ );
688
+ /** Batched prompts: two groups = two requests = two events. */
689
+ await handler.handleLLMEnd?.(
690
+ { generations: [[makeChoice(usage)], [makeChoice(usage)]] },
691
+ 'call-2'
692
+ );
693
+ },
694
+ });
695
+ const executor = createExecutor({
696
+ usageSink: (event) => {
697
+ events.push(event);
698
+ },
699
+ createChildGraph: factory,
700
+ });
701
+
702
+ await executor.execute({
703
+ description: 'Research this topic',
704
+ subagentType: 'researcher',
705
+ });
706
+
707
+ expect(events).toHaveLength(3);
708
+ });
709
+
710
+ it('prefers INVOKED_PROVIDER/INVOKED_MODEL metadata for fallback-served calls', async () => {
711
+ const events: SubagentUsageEvent[] = [];
712
+ const { factory } = makeCapturingGraphFactory({
713
+ drive: async (handler) => {
714
+ /**
715
+ * Mirror a fallback-served call: `attemptInvoke` stamps the
716
+ * serving provider, `tryFallbackProviders` stamps the fallback's
717
+ * configured model, and the provider reports no `ls_model_name`.
718
+ */
719
+ await handler.handleChatModelStart?.(
720
+ {},
721
+ [[]],
722
+ 'call-1',
723
+ undefined,
724
+ undefined,
725
+ undefined,
726
+ {
727
+ [Constants.INVOKED_PROVIDER]: Providers.ANTHROPIC,
728
+ [Constants.INVOKED_MODEL]: 'claude-fallback-1',
729
+ }
730
+ );
731
+ await handler.handleLLMEnd?.(
732
+ makeLLMEndOutput({
733
+ input_tokens: 5,
734
+ output_tokens: 3,
735
+ total_tokens: 8,
736
+ }),
737
+ 'call-1'
738
+ );
739
+ },
740
+ });
741
+ const executor = createExecutor({
742
+ usageSink: (event) => {
743
+ events.push(event);
744
+ },
745
+ createChildGraph: factory,
746
+ });
747
+
748
+ await executor.execute({
749
+ description: 'Research this topic',
750
+ subagentType: 'researcher',
751
+ });
752
+
753
+ expect(events).toHaveLength(1);
754
+ /** Not the configured primary (openAI / gpt-4o-mini). */
755
+ expect(events[0].provider).toBe(Providers.ANTHROPIC);
756
+ expect(events[0].model).toBe('claude-fallback-1');
757
+ });
758
+
759
+ it('prefers provider-reported ls_model_name over INVOKED_MODEL', async () => {
760
+ const events: SubagentUsageEvent[] = [];
761
+ const { factory } = makeCapturingGraphFactory({
762
+ drive: async (handler) => {
763
+ await handler.handleChatModelStart?.(
764
+ {},
765
+ [[]],
766
+ 'call-1',
767
+ undefined,
768
+ undefined,
769
+ undefined,
770
+ {
771
+ ls_model_name: 'claude-fallback-1-20260101',
772
+ [Constants.INVOKED_PROVIDER]: Providers.ANTHROPIC,
773
+ [Constants.INVOKED_MODEL]: 'claude-fallback-1',
774
+ }
775
+ );
776
+ await handler.handleLLMEnd?.(
777
+ makeLLMEndOutput({
778
+ input_tokens: 5,
779
+ output_tokens: 3,
780
+ total_tokens: 8,
781
+ }),
782
+ 'call-1'
783
+ );
784
+ },
785
+ });
786
+ const executor = createExecutor({
787
+ usageSink: (event) => {
788
+ events.push(event);
789
+ },
790
+ createChildGraph: factory,
791
+ });
792
+
793
+ await executor.execute({
794
+ description: 'Research this topic',
795
+ subagentType: 'researcher',
796
+ });
797
+
798
+ expect(events[0].model).toBe('claude-fallback-1-20260101');
799
+ });
800
+
801
+ it('skips model calls that report no usage_metadata', async () => {
802
+ const events: SubagentUsageEvent[] = [];
803
+ const { factory } = makeCapturingGraphFactory({
804
+ drive: async (handler) => {
805
+ await handler.handleLLMEnd?.(makeLLMEndOutput(undefined), 'call-1');
806
+ },
807
+ });
808
+ const executor = createExecutor({
809
+ usageSink: (event) => {
810
+ events.push(event);
811
+ },
812
+ createChildGraph: factory,
813
+ });
814
+
815
+ await executor.execute({
816
+ description: 'Research this topic',
817
+ subagentType: 'researcher',
818
+ });
819
+
820
+ expect(events).toEqual([]);
821
+ });
822
+
823
+ it('swallows sink errors without breaking the child run', async () => {
824
+ const { factory } = makeCapturingGraphFactory({
825
+ drive: async (handler) => {
826
+ await handler.handleLLMEnd?.(
827
+ makeLLMEndOutput({
828
+ input_tokens: 1,
829
+ output_tokens: 1,
830
+ total_tokens: 2,
831
+ }),
832
+ 'call-1'
833
+ );
834
+ },
835
+ });
836
+ const executor = createExecutor({
837
+ usageSink: () => {
838
+ throw new Error('host sink exploded');
839
+ },
840
+ createChildGraph: factory,
841
+ });
842
+
843
+ const result = await executor.execute({
844
+ description: 'Research this topic',
845
+ subagentType: 'researcher',
846
+ });
847
+
848
+ expect(result.content).toBe('child done');
849
+ });
850
+
851
+ it('awaits async sinks and swallows their rejections', async () => {
852
+ const settled: string[] = [];
853
+ const { factory } = makeCapturingGraphFactory({
854
+ drive: async (handler) => {
855
+ await handler.handleLLMEnd?.(
856
+ makeLLMEndOutput({
857
+ input_tokens: 1,
858
+ output_tokens: 1,
859
+ total_tokens: 2,
860
+ }),
861
+ 'call-1'
862
+ );
863
+ await handler.handleLLMEnd?.(
864
+ makeLLMEndOutput({
865
+ input_tokens: 2,
866
+ output_tokens: 2,
867
+ total_tokens: 4,
868
+ }),
869
+ 'call-2'
870
+ );
871
+ /**
872
+ * Both sink dispatches must have settled by the time
873
+ * `handleLLMEnd` resolves — a dropped promise would leave
874
+ * `recorded` missing here and surface the second call's
875
+ * rejection as unhandled.
876
+ */
877
+ settled.push('drive-done');
878
+ },
879
+ });
880
+ const executor = createExecutor({
881
+ usageSink: async (event) => {
882
+ await new Promise((resolve) => setTimeout(resolve, 5));
883
+ if (event.usage.input_tokens === 2) {
884
+ throw new Error('async host sink rejected');
885
+ }
886
+ settled.push('recorded');
887
+ },
888
+ createChildGraph: factory,
889
+ });
890
+
891
+ const result = await executor.execute({
892
+ description: 'Research this topic',
893
+ subagentType: 'researcher',
894
+ });
895
+
896
+ expect(result.content).toBe('child done');
897
+ expect(settled).toEqual(['recorded', 'drive-done']);
898
+ });
899
+ });
900
+
459
901
  it('returns error message when child graph throws', async () => {
460
902
  const executor = createExecutor({
461
903
  createChildGraph: makeThrowingGraphFactory(
@@ -0,0 +1,242 @@
1
+ import type * as t from './types';
2
+ import { formatResultsForLLM, resolveMaxLLMOutputChars } from './format';
3
+
4
+ const makeOrganic = (
5
+ link: string,
6
+ highlights: t.Highlight[]
7
+ ): t.ProcessedOrganic => ({
8
+ link,
9
+ title: `Title for ${link}`,
10
+ snippet: `Snippet for ${link}`,
11
+ highlights,
12
+ });
13
+
14
+ const highlight = (text: string, score = 0.9): t.Highlight => ({ text, score });
15
+
16
+ const reference = (url: string, originalIndex = 0): t.UsedReferences[number] => ({
17
+ type: 'link',
18
+ originalIndex,
19
+ reference: { originalUrl: url, title: 'Ref', text: 'ref' },
20
+ });
21
+
22
+ const countHighlightBlocks = (output: string): number =>
23
+ (output.match(/### Highlight \d+/g) ?? []).length;
24
+
25
+ const OMISSION_MARKER = 'omitted to fit the context budget';
26
+
27
+ describe('resolveMaxLLMOutputChars', () => {
28
+ const originalEnv = process.env.SEARCH_MAX_LLM_OUTPUT_CHARS;
29
+
30
+ afterEach(() => {
31
+ if (originalEnv == null) {
32
+ delete process.env.SEARCH_MAX_LLM_OUTPUT_CHARS;
33
+ } else {
34
+ process.env.SEARCH_MAX_LLM_OUTPUT_CHARS = originalEnv;
35
+ }
36
+ });
37
+
38
+ test('falls back to the 50,000 char default when nothing is configured', () => {
39
+ delete process.env.SEARCH_MAX_LLM_OUTPUT_CHARS;
40
+ expect(resolveMaxLLMOutputChars()).toBe(50000);
41
+ expect(resolveMaxLLMOutputChars(0)).toBe(50000);
42
+ expect(resolveMaxLLMOutputChars(-100)).toBe(50000);
43
+ });
44
+
45
+ test('honors the SEARCH_MAX_LLM_OUTPUT_CHARS env var', () => {
46
+ process.env.SEARCH_MAX_LLM_OUTPUT_CHARS = '777';
47
+ expect(resolveMaxLLMOutputChars()).toBe(777);
48
+ expect(resolveMaxLLMOutputChars(0)).toBe(777);
49
+ });
50
+
51
+ test('an explicit positive config value wins over env and default', () => {
52
+ process.env.SEARCH_MAX_LLM_OUTPUT_CHARS = '777';
53
+ expect(resolveMaxLLMOutputChars(1234)).toBe(1234);
54
+ });
55
+
56
+ test('ignores a non-numeric env var', () => {
57
+ process.env.SEARCH_MAX_LLM_OUTPUT_CHARS = 'not-a-number';
58
+ expect(resolveMaxLLMOutputChars()).toBe(50000);
59
+ });
60
+ });
61
+
62
+ describe('formatResultsForLLM highlight budget', () => {
63
+ test('keeps whole highlights in relevance order until the budget is hit', () => {
64
+ const results: t.SearchResultData = {
65
+ organic: [
66
+ makeOrganic('https://a.com', [highlight('A'.repeat(100))]),
67
+ makeOrganic('https://b.com', [highlight('B'.repeat(100))]),
68
+ ],
69
+ };
70
+
71
+ const { output } = formatResultsForLLM(0, results, 100);
72
+
73
+ expect(output).toContain('A'.repeat(100));
74
+ expect(output).not.toContain('B'.repeat(100));
75
+ expect(countHighlightBlocks(output)).toBe(1);
76
+ expect(output).toContain('_[1 additional highlight omitted to fit the context budget');
77
+ });
78
+
79
+ test('truncates the boundary highlight when meaningful room remains', () => {
80
+ const results: t.SearchResultData = {
81
+ organic: [makeOrganic('https://a.com', [highlight('A'.repeat(1000))])],
82
+ };
83
+
84
+ const { output } = formatResultsForLLM(0, results, 500);
85
+
86
+ expect(output).toContain('…[truncated]');
87
+ expect(output).toContain('A'.repeat(500));
88
+ expect(output).not.toContain('A'.repeat(501));
89
+ expect(output).toContain('_[1 additional highlight omitted to fit the context budget');
90
+ });
91
+
92
+ test('drops the boundary highlight entirely when too little room remains', () => {
93
+ const results: t.SearchResultData = {
94
+ organic: [
95
+ makeOrganic('https://a.com', [highlight('A'.repeat(100))]),
96
+ makeOrganic('https://b.com', [highlight('B'.repeat(100))]),
97
+ ],
98
+ };
99
+
100
+ const { output } = formatResultsForLLM(0, results, 150);
101
+
102
+ expect(output).toContain('A'.repeat(100));
103
+ expect(output).not.toContain('…[truncated]');
104
+ expect(output).not.toContain('B');
105
+ expect(countHighlightBlocks(output)).toBe(1);
106
+ });
107
+
108
+ test('always keeps snippets, titles, and URLs even when all highlights are dropped', () => {
109
+ const results: t.SearchResultData = {
110
+ organic: [makeOrganic('https://a.com', [highlight('A'.repeat(100))])],
111
+ };
112
+
113
+ const { output } = formatResultsForLLM(0, results, 10);
114
+
115
+ expect(output).toContain('URL: https://a.com');
116
+ expect(output).toContain('Summary: Snippet for https://a.com');
117
+ expect(output).toContain('"Title for https://a.com"');
118
+ expect(countHighlightBlocks(output)).toBe(0);
119
+ expect(output).toContain('_[1 additional highlight omitted to fit the context budget');
120
+ });
121
+
122
+ test('emits no omission marker when every highlight fits the budget', () => {
123
+ const results: t.SearchResultData = {
124
+ organic: [
125
+ makeOrganic('https://a.com', [highlight('A'.repeat(100))]),
126
+ makeOrganic('https://b.com', [highlight('B'.repeat(100))]),
127
+ ],
128
+ };
129
+
130
+ const { output } = formatResultsForLLM(0, results, 50000);
131
+
132
+ expect(output).toContain('A'.repeat(100));
133
+ expect(output).toContain('B'.repeat(100));
134
+ expect(countHighlightBlocks(output)).toBe(2);
135
+ expect(output).not.toContain(OMISSION_MARKER);
136
+ });
137
+
138
+ test('drops references with no surviving marker when truncating', () => {
139
+ const withRefs = highlight('A'.repeat(1000));
140
+ withRefs.references = [reference('https://cited.example')];
141
+ const results: t.SearchResultData = {
142
+ organic: [makeOrganic('https://a.com', [withRefs])],
143
+ };
144
+
145
+ const { output, references } = formatResultsForLLM(0, results, 500);
146
+
147
+ expect(output).toContain('…[truncated]');
148
+ expect(output).not.toContain('Core References');
149
+ expect(output).not.toContain('https://cited.example');
150
+ expect(references).toHaveLength(0);
151
+ });
152
+
153
+ test('keeps references whose marker survives truncation and drops the rest', () => {
154
+ const withRefs = highlight(`(link#1) ${'A'.repeat(1000)} (link#2)`);
155
+ withRefs.references = [
156
+ reference('https://one.example', 0),
157
+ reference('https://two.example', 1),
158
+ ];
159
+ const results: t.SearchResultData = {
160
+ organic: [makeOrganic('https://a.com', [withRefs])],
161
+ };
162
+
163
+ const { output, references } = formatResultsForLLM(0, results, 500);
164
+
165
+ expect(output).toContain('…[truncated]');
166
+ expect(output).toContain('https://one.example');
167
+ expect(output).not.toContain('https://two.example');
168
+ expect(references).toHaveLength(1);
169
+ expect(references[0].link).toBe('https://one.example');
170
+ });
171
+
172
+ test('stops at the boundary highlight — no lower-ranked highlight slips in', () => {
173
+ const results: t.SearchResultData = {
174
+ organic: [
175
+ makeOrganic('https://a.com', [
176
+ highlight('A'.repeat(100), 0.9),
177
+ highlight('B'.repeat(300), 0.8),
178
+ highlight('C'.repeat(10), 0.7),
179
+ ]),
180
+ ],
181
+ };
182
+
183
+ const { output } = formatResultsForLLM(0, results, 150);
184
+
185
+ expect(output).toContain('A'.repeat(100));
186
+ expect(output).not.toContain('B'.repeat(300));
187
+ expect(output).not.toContain('C'.repeat(10));
188
+ expect(output).not.toContain('…[truncated]');
189
+ expect(countHighlightBlocks(output)).toBe(1);
190
+ });
191
+
192
+ test('keeps references on a whole highlight that fits the budget', () => {
193
+ const withRefs = highlight('A'.repeat(100));
194
+ withRefs.references = [reference('https://cited.example')];
195
+ const results: t.SearchResultData = {
196
+ organic: [makeOrganic('https://a.com', [withRefs])],
197
+ };
198
+
199
+ const { output, references } = formatResultsForLLM(0, results, 50000);
200
+
201
+ expect(output).toContain('Core References');
202
+ expect(references).toHaveLength(1);
203
+ expect(references[0].link).toBe('https://cited.example');
204
+ });
205
+
206
+ test('skips blank highlights instead of charging them against the budget', () => {
207
+ const results: t.SearchResultData = {
208
+ organic: [
209
+ makeOrganic('https://a.com', [
210
+ highlight(' \n\t '),
211
+ highlight('A'.repeat(100)),
212
+ ]),
213
+ ],
214
+ };
215
+
216
+ const { output } = formatResultsForLLM(0, results, 100);
217
+
218
+ expect(output).toContain('A'.repeat(100));
219
+ expect(output).not.toContain('…[truncated]');
220
+ expect(countHighlightBlocks(output)).toBe(1);
221
+ expect(output).not.toContain(OMISSION_MARKER);
222
+ });
223
+
224
+ test('spends the budget across organic results before news results', () => {
225
+ const results: t.SearchResultData = {
226
+ organic: [makeOrganic('https://a.com', [highlight('A'.repeat(100))])],
227
+ topStories: [
228
+ {
229
+ link: 'https://news.com',
230
+ title: 'Story',
231
+ highlights: [highlight('N'.repeat(100))],
232
+ },
233
+ ],
234
+ };
235
+
236
+ const { output } = formatResultsForLLM(0, results, 100);
237
+
238
+ expect(output).toContain('A'.repeat(100));
239
+ expect(output).not.toContain('N'.repeat(100));
240
+ expect(output).toContain('_[1 additional highlight omitted to fit the context budget');
241
+ });
242
+ });