@librechat/agents 3.2.57 → 3.2.59

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 (120) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +7 -1
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +2 -2
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/langfuseToolOutputTracing.cjs +4 -0
  6. package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
  7. package/dist/cjs/langfuseTraceShaping.cjs +172 -0
  8. package/dist/cjs/langfuseTraceShaping.cjs.map +1 -0
  9. package/dist/cjs/main.cjs +7 -0
  10. package/dist/cjs/run.cjs +2 -2
  11. package/dist/cjs/run.cjs.map +1 -1
  12. package/dist/cjs/stream.cjs +2 -1
  13. package/dist/cjs/stream.cjs.map +1 -1
  14. package/dist/cjs/tools/BashExecutor.cjs +58 -9
  15. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  16. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +4 -2
  17. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
  18. package/dist/cjs/tools/CodeExecutor.cjs +57 -7
  19. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  20. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +9 -3
  21. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  22. package/dist/cjs/tools/ToolNode.cjs +19 -1
  23. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  24. package/dist/cjs/tools/eagerEventExecution.cjs +18 -1
  25. package/dist/cjs/tools/eagerEventExecution.cjs.map +1 -1
  26. package/dist/cjs/tools/search/keenable-search.cjs +68 -0
  27. package/dist/cjs/tools/search/keenable-search.cjs.map +1 -0
  28. package/dist/cjs/tools/search/rerankers.cjs +28 -11
  29. package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
  30. package/dist/cjs/tools/search/search.cjs +30 -4
  31. package/dist/cjs/tools/search/search.cjs.map +1 -1
  32. package/dist/cjs/tools/search/tool.cjs +14 -6
  33. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  34. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +12 -1
  35. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  36. package/dist/cjs/utils/title.cjs +9 -9
  37. package/dist/cjs/utils/title.cjs.map +1 -1
  38. package/dist/esm/agents/AgentContext.mjs +7 -1
  39. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  40. package/dist/esm/graphs/Graph.mjs +2 -2
  41. package/dist/esm/graphs/Graph.mjs.map +1 -1
  42. package/dist/esm/langfuseToolOutputTracing.mjs +4 -0
  43. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  44. package/dist/esm/langfuseTraceShaping.mjs +171 -0
  45. package/dist/esm/langfuseTraceShaping.mjs.map +1 -0
  46. package/dist/esm/main.mjs +3 -3
  47. package/dist/esm/run.mjs +2 -2
  48. package/dist/esm/run.mjs.map +1 -1
  49. package/dist/esm/stream.mjs +2 -1
  50. package/dist/esm/stream.mjs.map +1 -1
  51. package/dist/esm/tools/BashExecutor.mjs +56 -10
  52. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  53. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +4 -2
  54. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
  55. package/dist/esm/tools/CodeExecutor.mjs +54 -8
  56. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  57. package/dist/esm/tools/ProgrammaticToolCalling.mjs +9 -3
  58. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  59. package/dist/esm/tools/ToolNode.mjs +20 -2
  60. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  61. package/dist/esm/tools/eagerEventExecution.mjs +18 -2
  62. package/dist/esm/tools/eagerEventExecution.mjs.map +1 -1
  63. package/dist/esm/tools/search/keenable-search.mjs +66 -0
  64. package/dist/esm/tools/search/keenable-search.mjs.map +1 -0
  65. package/dist/esm/tools/search/rerankers.mjs +28 -11
  66. package/dist/esm/tools/search/rerankers.mjs.map +1 -1
  67. package/dist/esm/tools/search/search.mjs +30 -4
  68. package/dist/esm/tools/search/search.mjs.map +1 -1
  69. package/dist/esm/tools/search/tool.mjs +14 -6
  70. package/dist/esm/tools/search/tool.mjs.map +1 -1
  71. package/dist/esm/tools/subagent/SubagentExecutor.mjs +12 -1
  72. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  73. package/dist/esm/utils/title.mjs +9 -9
  74. package/dist/esm/utils/title.mjs.map +1 -1
  75. package/dist/types/langfuseTraceShaping.d.ts +20 -0
  76. package/dist/types/tools/BashExecutor.d.ts +13 -0
  77. package/dist/types/tools/CodeExecutor.d.ts +14 -0
  78. package/dist/types/tools/ToolNode.d.ts +3 -0
  79. package/dist/types/tools/eagerEventExecution.d.ts +8 -0
  80. package/dist/types/tools/search/keenable-search.d.ts +4 -0
  81. package/dist/types/tools/search/rerankers.d.ts +7 -2
  82. package/dist/types/tools/search/types.d.ts +38 -1
  83. package/dist/types/types/graph.d.ts +22 -0
  84. package/dist/types/types/tools.d.ts +58 -0
  85. package/package.json +1 -1
  86. package/src/agents/AgentContext.ts +9 -0
  87. package/src/agents/__tests__/AgentContext.test.ts +40 -0
  88. package/src/graphs/Graph.ts +25 -2
  89. package/src/graphs/__tests__/composition.smoke.test.ts +53 -0
  90. package/src/langfuseToolOutputTracing.ts +11 -0
  91. package/src/langfuseTraceShaping.ts +280 -0
  92. package/src/llm/anthropic/inherited-stream-events.spec.ts +6 -3
  93. package/src/run.ts +3 -3
  94. package/src/specs/langfuse-routing.integration.test.ts +1 -1
  95. package/src/specs/langfuse-trace-shaping.test.ts +194 -0
  96. package/src/stream.ts +17 -1
  97. package/src/tools/BashExecutor.ts +107 -14
  98. package/src/tools/BashProgrammaticToolCalling.ts +20 -1
  99. package/src/tools/CodeExecutor.ts +113 -9
  100. package/src/tools/ProgrammaticToolCalling.ts +27 -1
  101. package/src/tools/ToolNode.ts +36 -4
  102. package/src/tools/__tests__/BashExecutor.test.ts +39 -0
  103. package/src/tools/__tests__/CodeExecutor.stateful.test.ts +113 -0
  104. package/src/tools/__tests__/SubagentExecutor.test.ts +44 -0
  105. package/src/tools/__tests__/ToolNode.session.test.ts +86 -0
  106. package/src/tools/__tests__/eagerEventExecution.session.test.ts +92 -0
  107. package/src/tools/__tests__/hitl.test.ts +85 -0
  108. package/src/tools/eagerEventExecution.ts +32 -5
  109. package/src/tools/search/jina-reranker.test.ts +70 -1
  110. package/src/tools/search/keenable-search.ts +98 -0
  111. package/src/tools/search/keenable.test.ts +183 -0
  112. package/src/tools/search/rerankers.ts +41 -4
  113. package/src/tools/search/search.ts +58 -2
  114. package/src/tools/search/source-processing.test.ts +86 -0
  115. package/src/tools/search/tool.ts +29 -4
  116. package/src/tools/search/types.ts +42 -1
  117. package/src/tools/subagent/SubagentExecutor.ts +11 -0
  118. package/src/types/graph.ts +22 -0
  119. package/src/types/tools.ts +65 -0
  120. package/src/utils/title.ts +9 -9
@@ -292,6 +292,50 @@ describe('buildChildInputs', () => {
292
292
  expect(result.toolDefinitions).toBeUndefined();
293
293
  });
294
294
 
295
+ it('scrubs INHERITED graphTools on self-spawn (parent-spread config) but keeps an explicit child config’s own', () => {
296
+ const askLikeTool = { name: 'ask_user_question' } as unknown as NonNullable<
297
+ AgentInputs['graphTools']
298
+ >[number];
299
+ const inputsWithGraphTools: AgentInputs = {
300
+ ...parentAgentInputs,
301
+ graphTools: [askLikeTool],
302
+ };
303
+
304
+ /**
305
+ * Self-spawn: `resolveSubagentConfigs` fills `agentInputs` as a shallow
306
+ * spread of the parent's `_sourceInputs`, so the parent-scoped direct
307
+ * tool would leak into a checkpointer-less child graph and fail with
308
+ * `No checkpointer set` — must be scrubbed.
309
+ */
310
+ const selfConfig: ResolvedSubagentConfig = {
311
+ type: 'self',
312
+ name: 'Self',
313
+ description: 'd',
314
+ self: true,
315
+ agentInputs: { ...inputsWithGraphTools },
316
+ };
317
+ expect(
318
+ buildChildInputs(selfConfig, 'child-self', 3).graphTools
319
+ ).toBeUndefined();
320
+
321
+ /**
322
+ * Explicit child config: a host that deliberately attaches its own
323
+ * in-process direct tools to a child keeps them (Codex #289 P2 —
324
+ * interrupt-capable tools still fail in children, but non-interrupting
325
+ * direct tools are legitimate there).
326
+ */
327
+ const explicitConfig: ResolvedSubagentConfig = {
328
+ type: 'researcher',
329
+ name: 'R',
330
+ description: 'd',
331
+ agentInputs: inputsWithGraphTools,
332
+ };
333
+ expect(
334
+ buildChildInputs(explicitConfig, 'child-explicit', 3).graphTools
335
+ ).toEqual([askLikeTool]);
336
+ expect(inputsWithGraphTools.graphTools).toHaveLength(1); // parent untouched
337
+ });
338
+
295
339
  it('strips parent-run-scoped initialSummary and discoveredTools from child inputs', () => {
296
340
  /**
297
341
  * Codex P1: a child inheriting `initialSummary` or `discoveredTools` from
@@ -1263,4 +1263,90 @@ describe('ToolNode code execution session management', () => {
1263
1263
  );
1264
1264
  });
1265
1265
  });
1266
+
1267
+ describe('stateful runtime session hint injection', () => {
1268
+ it('injects the explicit runtimeSessionHint when statefulSessions is on', async () => {
1269
+ const capturedConfigs: Record<string, unknown>[] = [];
1270
+ const mockTool = createMockCodeTool({ capturedConfigs });
1271
+ const toolNode = new ToolNode({
1272
+ tools: [mockTool],
1273
+ sessions: new Map(),
1274
+ toolExecution: {
1275
+ engine: 'sandbox',
1276
+ sandbox: {
1277
+ statefulSessions: true,
1278
+ runtimeSessionHint: 'conv-explicit',
1279
+ },
1280
+ },
1281
+ });
1282
+
1283
+ await toolNode.invoke({ messages: [createAIMessageWithCodeCall('c1')] });
1284
+
1285
+ expect(capturedConfigs[0]._runtime_session_hint).toBe('conv-explicit');
1286
+ });
1287
+
1288
+ it('falls back to configurable.thread_id when no explicit hint is given', async () => {
1289
+ const capturedConfigs: Record<string, unknown>[] = [];
1290
+ const mockTool = createMockCodeTool({ capturedConfigs });
1291
+ const toolNode = new ToolNode({
1292
+ tools: [mockTool],
1293
+ sessions: new Map(),
1294
+ toolExecution: {
1295
+ engine: 'sandbox',
1296
+ sandbox: { statefulSessions: true },
1297
+ },
1298
+ });
1299
+
1300
+ await toolNode.invoke(
1301
+ { messages: [createAIMessageWithCodeCall('c1')] },
1302
+ { configurable: { thread_id: 'thread-abc' } }
1303
+ );
1304
+
1305
+ expect(capturedConfigs[0]._runtime_session_hint).toBe('thread-abc');
1306
+ });
1307
+
1308
+ it('does not inject a hint when statefulSessions is off', async () => {
1309
+ const capturedConfigs: Record<string, unknown>[] = [];
1310
+ const mockTool = createMockCodeTool({ capturedConfigs });
1311
+ const toolNode = new ToolNode({
1312
+ tools: [mockTool],
1313
+ sessions: new Map(),
1314
+ toolExecution: {
1315
+ engine: 'sandbox',
1316
+ sandbox: { statefulSessions: false },
1317
+ },
1318
+ });
1319
+
1320
+ await toolNode.invoke(
1321
+ { messages: [createAIMessageWithCodeCall('c1')] },
1322
+ { configurable: { thread_id: 'thread-abc' } }
1323
+ );
1324
+
1325
+ expect(capturedConfigs[0]._runtime_session_hint).toBeUndefined();
1326
+ });
1327
+
1328
+ it('injects the hint independently of an existing exec session', async () => {
1329
+ const capturedConfigs: Record<string, unknown>[] = [];
1330
+ const sessions: t.ToolSessionMap = new Map();
1331
+ sessions.set(Constants.EXECUTE_CODE, {
1332
+ session_id: 'exec-1',
1333
+ files: [],
1334
+ lastUpdated: 0,
1335
+ });
1336
+ const mockTool = createMockCodeTool({ capturedConfigs });
1337
+ const toolNode = new ToolNode({
1338
+ tools: [mockTool],
1339
+ sessions,
1340
+ toolExecution: {
1341
+ engine: 'sandbox',
1342
+ sandbox: { statefulSessions: true, runtimeSessionHint: 'conv-1' },
1343
+ },
1344
+ });
1345
+
1346
+ await toolNode.invoke({ messages: [createAIMessageWithCodeCall('c1')] });
1347
+
1348
+ expect(capturedConfigs[0].session_id).toBe('exec-1');
1349
+ expect(capturedConfigs[0]._runtime_session_hint).toBe('conv-1');
1350
+ });
1351
+ });
1266
1352
  });
@@ -0,0 +1,92 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import type * as t from '@/types';
3
+ import {
4
+ buildToolExecutionRequestPlan,
5
+ resolveRuntimeSessionHint,
6
+ } from '../eagerEventExecution';
7
+
8
+ describe('buildToolExecutionRequestPlan — runtimeSessionHint', () => {
9
+ const usageCount = () => new Map<string, number>();
10
+
11
+ it('carries runtimeSessionHint onto the built ToolCallRequest', () => {
12
+ const plan = buildToolExecutionRequestPlan({
13
+ toolCalls: [
14
+ {
15
+ id: 'call_1',
16
+ name: 'execute_code',
17
+ args: { lang: 'py', code: 'print(1)' },
18
+ runtimeSessionHint: 'conv-42',
19
+ },
20
+ ],
21
+ usageCount: usageCount(),
22
+ });
23
+ expect(plan?.requests[0].runtimeSessionHint).toBe('conv-42');
24
+ });
25
+
26
+ it('omits the field entirely when the hint is absent or empty', () => {
27
+ const plan = buildToolExecutionRequestPlan({
28
+ toolCalls: [{ id: 'c1', name: 'execute_code', args: {} }],
29
+ usageCount: usageCount(),
30
+ });
31
+ expect('runtimeSessionHint' in (plan?.requests[0] as object)).toBe(false);
32
+
33
+ const empty = buildToolExecutionRequestPlan({
34
+ toolCalls: [
35
+ { id: 'c2', name: 'execute_code', args: {}, runtimeSessionHint: '' },
36
+ ],
37
+ usageCount: usageCount(),
38
+ });
39
+ expect('runtimeSessionHint' in (empty?.requests[0] as object)).toBe(false);
40
+ });
41
+
42
+ it('carries the hint onto invalid-arg (rejected) requests too', () => {
43
+ const plan = buildToolExecutionRequestPlan({
44
+ toolCalls: [
45
+ {
46
+ id: 'c1',
47
+ name: 'execute_code',
48
+ args: 'not-an-object',
49
+ runtimeSessionHint: 'conv-9',
50
+ },
51
+ ],
52
+ usageCount: usageCount(),
53
+ invalidArgsBehavior: 'error-result',
54
+ });
55
+ expect(plan?.allRequests[0].runtimeSessionHint).toBe('conv-9');
56
+ expect(plan?.rejectedResults).toHaveLength(1);
57
+ });
58
+ });
59
+
60
+ describe('resolveRuntimeSessionHint', () => {
61
+ const sandbox = (
62
+ o: Partial<t.SandboxExecutionConfig>
63
+ ): t.ToolExecutionConfig => ({
64
+ sandbox: o,
65
+ });
66
+
67
+ it('returns undefined unless statefulSessions is on', () => {
68
+ expect(resolveRuntimeSessionHint(undefined, 'thread-1')).toBeUndefined();
69
+ expect(resolveRuntimeSessionHint(sandbox({}), 'thread-1')).toBeUndefined();
70
+ expect(
71
+ resolveRuntimeSessionHint(
72
+ sandbox({ statefulSessions: false }),
73
+ 'thread-1'
74
+ )
75
+ ).toBeUndefined();
76
+ });
77
+
78
+ it('prefers an explicit hint, else falls back to thread_id', () => {
79
+ expect(
80
+ resolveRuntimeSessionHint(
81
+ sandbox({ statefulSessions: true, runtimeSessionHint: 'explicit' }),
82
+ 'thread-1'
83
+ )
84
+ ).toBe('explicit');
85
+ expect(
86
+ resolveRuntimeSessionHint(sandbox({ statefulSessions: true }), 'thread-1')
87
+ ).toBe('thread-1');
88
+ expect(
89
+ resolveRuntimeSessionHint(sandbox({ statefulSessions: true }), '')
90
+ ).toBeUndefined();
91
+ });
92
+ });
@@ -3996,6 +3996,91 @@ describe('AskUserQuestion — interrupt + resume', () => {
3996
3996
  expect(resumedAnswer).toBe('production');
3997
3997
  });
3998
3998
 
3999
+ it('a DIRECT tool in event-driven mode can raise ask_user_question from its body and resume with the answer as its ToolMessage', async () => {
4000
+ /**
4001
+ * The production host shape (e.g. LibreChat's `AgentInputs.graphTools`
4002
+ * plumb): the run is event-driven (other tools are schema-only
4003
+ * definitions dispatched to the host), but an interrupt-capable tool is
4004
+ * supplied as a real instance and marked direct so it executes inside
4005
+ * the Pregel task frame. Event dispatch must never see the call — a
4006
+ * host-side handler runs outside the graph, where `interrupt()` throws.
4007
+ */
4008
+ const { askUserQuestion } = await import('@/hitl');
4009
+
4010
+ const dispatchSpy = jest
4011
+ .spyOn(events, 'safeDispatchCustomEvent')
4012
+ .mockImplementation(async () => {});
4013
+
4014
+ let bodyRuns = 0;
4015
+ const askTool = tool(
4016
+ async (input: { question: string }) => {
4017
+ bodyRuns += 1;
4018
+ const resolution = askUserQuestion(input);
4019
+ return resolution.answer;
4020
+ },
4021
+ {
4022
+ name: 'ask_user_question',
4023
+ description: 'Ask the user a clarifying question.',
4024
+ schema: z.object({ question: z.string() }),
4025
+ }
4026
+ ) as unknown as StructuredToolInterface;
4027
+
4028
+ const node = new ToolNode({
4029
+ tools: [createSchemaStub('echo'), askTool],
4030
+ toolMap: new Map([
4031
+ ['echo', createSchemaStub('echo')],
4032
+ ['ask_user_question', askTool],
4033
+ ]),
4034
+ eventDrivenMode: true,
4035
+ agentId: 'agent-ask-direct',
4036
+ toolCallStepIds: new Map([['call_ask_1', 'step_call_ask_1']]),
4037
+ directToolNames: new Set(['ask_user_question']),
4038
+ });
4039
+
4040
+ const graph = buildHITLGraph(node, [
4041
+ {
4042
+ id: 'call_ask_1',
4043
+ name: 'ask_user_question',
4044
+ args: { question: 'Which environment?' },
4045
+ },
4046
+ ]);
4047
+ const config = { configurable: { thread_id: 'thread-ask-direct' } };
4048
+
4049
+ const interrupted = await graph.invoke({ messages: [] }, config);
4050
+ expect(isInterrupted<t.HumanInterruptPayload>(interrupted)).toBe(true);
4051
+ if (!isInterrupted<t.HumanInterruptPayload>(interrupted)) {
4052
+ throw new Error('expected interrupt');
4053
+ }
4054
+ const payload = interrupted.__interrupt__[0].value!;
4055
+ if (payload.type !== 'ask_user_question') {
4056
+ throw new Error('expected ask_user_question payload');
4057
+ }
4058
+ expect(payload.question.question).toBe('Which environment?');
4059
+ expect(bodyRuns).toBe(1);
4060
+
4061
+ /** The interrupt came from the direct path — never dispatched to the host. */
4062
+ const toolExecuteDispatches = dispatchSpy.mock.calls.filter(
4063
+ ([event]) => event === 'on_tool_execute'
4064
+ );
4065
+ expect(toolExecuteDispatches).toHaveLength(0);
4066
+
4067
+ const resumed = (await resumeGraph(
4068
+ graph,
4069
+ interrupted,
4070
+ { answer: 'staging' } satisfies t.AskUserQuestionResolution,
4071
+ config
4072
+ )) as MessagesUpdate;
4073
+
4074
+ expect(bodyRuns).toBe(2); // body re-runs from the top on the resume pass
4075
+ const toolMessage = resumed.messages.find(
4076
+ (m): m is ToolMessage =>
4077
+ m._getType() === 'tool' &&
4078
+ (m as ToolMessage).tool_call_id === 'call_ask_1'
4079
+ );
4080
+ expect(toolMessage).toBeDefined();
4081
+ expect(String(toolMessage!.content)).toBe('staging');
4082
+ });
4083
+
3999
4084
  it('isAskUserQuestionInterrupt narrows the payload union correctly', async () => {
4000
4085
  const { isAskUserQuestionInterrupt, isToolApprovalInterrupt } =
4001
4086
  await import('@/types/hitl');
@@ -52,8 +52,30 @@ export type ToolExecutionPlanCall = {
52
52
  args: unknown;
53
53
  stepId?: string;
54
54
  codeSessionContext?: t.ToolCallRequest['codeSessionContext'];
55
+ runtimeSessionHint?: string;
55
56
  };
56
57
 
58
+ /**
59
+ * Stateful runtime session hint for the remote sandbox: only when
60
+ * `toolExecution.sandbox.statefulSessions` is on; explicit host hint else the
61
+ * conversation `thread_id`. Undefined disables the wire field. Shared by the
62
+ * direct ToolNode path and both event-driven planners so they stay in lockstep.
63
+ */
64
+ export function resolveRuntimeSessionHint(
65
+ toolExecution: t.ToolExecutionConfig | undefined,
66
+ threadId: string | undefined
67
+ ): string | undefined {
68
+ const sandbox = toolExecution?.sandbox;
69
+ if (sandbox?.statefulSessions !== true) {
70
+ return undefined;
71
+ }
72
+ const explicit = sandbox.runtimeSessionHint;
73
+ if (explicit != null && explicit !== '') {
74
+ return explicit;
75
+ }
76
+ return threadId != null && threadId !== '' ? threadId : undefined;
77
+ }
78
+
57
79
  export type ToolExecutionRequestPlan = {
58
80
  allRequests: t.ToolCallRequest[];
59
81
  requests: t.ToolCallRequest[];
@@ -73,15 +95,12 @@ export function buildToolExecutionRequestPlan(args: {
73
95
  args: Record<string, unknown>;
74
96
  stepId?: string;
75
97
  codeSessionContext?: t.ToolCallRequest['codeSessionContext'];
98
+ runtimeSessionHint?: string;
76
99
  rejectedErrorMessage?: string;
77
100
  }> = [];
78
101
 
79
102
  for (const toolCall of args.toolCalls) {
80
- if (
81
- toolCall.id == null ||
82
- toolCall.id === '' ||
83
- toolCall.name === ''
84
- ) {
103
+ if (toolCall.id == null || toolCall.id === '' || toolCall.name === '') {
85
104
  return undefined;
86
105
  }
87
106
  const coercedArgs = coerceRecordArgs(toolCall.args);
@@ -95,6 +114,7 @@ export function buildToolExecutionRequestPlan(args: {
95
114
  args: {},
96
115
  stepId: toolCall.stepId,
97
116
  codeSessionContext: toolCall.codeSessionContext,
117
+ runtimeSessionHint: toolCall.runtimeSessionHint,
98
118
  rejectedErrorMessage:
99
119
  'Invalid tool call arguments: expected a JSON object.',
100
120
  });
@@ -106,6 +126,7 @@ export function buildToolExecutionRequestPlan(args: {
106
126
  args: coercedArgs,
107
127
  stepId: toolCall.stepId,
108
128
  codeSessionContext: toolCall.codeSessionContext,
129
+ runtimeSessionHint: toolCall.runtimeSessionHint,
109
130
  });
110
131
  }
111
132
 
@@ -123,6 +144,12 @@ export function buildToolExecutionRequestPlan(args: {
123
144
  if (toolCall.codeSessionContext != null) {
124
145
  request.codeSessionContext = toolCall.codeSessionContext;
125
146
  }
147
+ if (
148
+ toolCall.runtimeSessionHint != null &&
149
+ toolCall.runtimeSessionHint !== ''
150
+ ) {
151
+ request.runtimeSessionHint = toolCall.runtimeSessionHint;
152
+ }
126
153
  return request;
127
154
  });
128
155
  const requests = allRequests.filter(
@@ -150,7 +150,8 @@ describe('JinaReranker', () => {
150
150
  });
151
151
 
152
152
  it('should log compact Axios errors without request internals', async () => {
153
- const customUrl = 'https://test-jina-endpoint.com/v1/rerank?api_key=hidden';
153
+ const customUrl =
154
+ 'https://test-jina-endpoint.com/v1/rerank?api_key=hidden';
154
155
  const reranker = new JinaReranker({
155
156
  apiKey: 'test-key',
156
157
  apiUrl: customUrl,
@@ -201,6 +202,49 @@ describe('JinaReranker', () => {
201
202
  expect(serializedMetadata).not.toContain('test-key');
202
203
  expect(serializedMetadata.length).toBeLessThan(500);
203
204
  });
205
+
206
+ it('should bound the rerank request with the default timeout', async () => {
207
+ const customUrl = 'https://test-jina-endpoint.com/v1/rerank';
208
+ const reranker = new JinaReranker({
209
+ apiKey: 'test-key',
210
+ apiUrl: customUrl,
211
+ logger: mockLogger,
212
+ });
213
+ jest.spyOn(mockLogger, 'debug').mockImplementation(() => mockLogger);
214
+ const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
215
+ data: { results: [{ index: 0, relevance_score: 0.9 }] },
216
+ });
217
+
218
+ await reranker.rerank('test query', ['document1'], 1);
219
+
220
+ expect(postSpy).toHaveBeenCalledWith(
221
+ customUrl,
222
+ expect.any(Object),
223
+ expect.objectContaining({ timeout: 10000 })
224
+ );
225
+ });
226
+
227
+ it('should bound the rerank request with a custom timeout', async () => {
228
+ const customUrl = 'https://test-jina-endpoint.com/v1/rerank';
229
+ const reranker = new JinaReranker({
230
+ apiKey: 'test-key',
231
+ apiUrl: customUrl,
232
+ timeout: 2500,
233
+ logger: mockLogger,
234
+ });
235
+ jest.spyOn(mockLogger, 'debug').mockImplementation(() => mockLogger);
236
+ const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
237
+ data: { results: [{ index: 0, relevance_score: 0.9 }] },
238
+ });
239
+
240
+ await reranker.rerank('test query', ['document1'], 1);
241
+
242
+ expect(postSpy).toHaveBeenCalledWith(
243
+ customUrl,
244
+ expect.any(Object),
245
+ expect.objectContaining({ timeout: 2500 })
246
+ );
247
+ });
204
248
  });
205
249
  });
206
250
 
@@ -234,4 +278,29 @@ describe('createReranker', () => {
234
278
  const apiUrl = getApiUrl(reranker);
235
279
  expect(apiUrl).toBe('https://api.jina.ai/v1/rerank');
236
280
  });
281
+
282
+ it('should pass rerankerTimeout through to the rerank request', async () => {
283
+ const customUrl = 'https://custom-jina-endpoint.com/v1/rerank';
284
+ const reranker = createReranker({
285
+ rerankerType: 'jina',
286
+ jinaApiKey: 'test-key',
287
+ jinaApiUrl: customUrl,
288
+ rerankerTimeout: 4000,
289
+ logger: createDefaultLogger(),
290
+ });
291
+ if (!(reranker instanceof JinaReranker)) {
292
+ throw new Error('Expected createReranker to return a JinaReranker.');
293
+ }
294
+ const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
295
+ data: { results: [{ index: 0, relevance_score: 0.9 }] },
296
+ });
297
+
298
+ await reranker.rerank('test query', ['document1'], 1);
299
+
300
+ expect(postSpy).toHaveBeenCalledWith(
301
+ customUrl,
302
+ expect.any(Object),
303
+ expect.objectContaining({ timeout: 4000 })
304
+ );
305
+ });
237
306
  });
@@ -0,0 +1,98 @@
1
+ import axios from 'axios';
2
+ import type * as t from './types';
3
+ import { DATE_RANGE } from './schema';
4
+
5
+ const DEFAULT_KEENABLE_TIMEOUT = 15000;
6
+
7
+ /** Authenticated and keyless endpoints. Keenable works without an API key by
8
+ * falling back to the public endpoint; a key only lifts rate limits. */
9
+ const KEENABLE_DEFAULT_API_URL = 'https://api.keenable.ai/v1/search';
10
+ const KEENABLE_PUBLIC_API_URL = 'https://api.keenable.ai/v1/search/public';
11
+ const KEENABLE_DATE_RANGES: Record<DATE_RANGE, string> = {
12
+ [DATE_RANGE.PAST_HOUR]: '1h',
13
+ [DATE_RANGE.PAST_24_HOURS]: '1d',
14
+ [DATE_RANGE.PAST_WEEK]: '7d',
15
+ [DATE_RANGE.PAST_MONTH]: '1mo',
16
+ [DATE_RANGE.PAST_YEAR]: '1y',
17
+ };
18
+
19
+ export const createKeenableAPI = (
20
+ apiKey?: string,
21
+ apiUrl?: string,
22
+ options?: t.KeenableSearchOptions
23
+ ): {
24
+ getSources: (params: t.GetSourcesParams) => Promise<t.SearchResult>;
25
+ } => {
26
+ const resolvedKey = apiKey ?? process.env.KEENABLE_API_KEY;
27
+ const hasKey = resolvedKey != null && resolvedKey !== '';
28
+ const timeout = options?.timeout ?? DEFAULT_KEENABLE_TIMEOUT;
29
+ const resolvedUrl =
30
+ apiUrl ??
31
+ process.env.KEENABLE_API_URL ??
32
+ (hasKey ? KEENABLE_DEFAULT_API_URL : KEENABLE_PUBLIC_API_URL);
33
+
34
+ /** Constant for the provider's lifetime. X-Keenable-Title is required for
35
+ * keyless requests and used for traffic attribution; the API key only lifts
36
+ * rate limits, so it is sent only when present. */
37
+ const headers: Record<string, string> = {
38
+ 'Content-Type': 'application/json',
39
+ 'X-Keenable-Title': options?.attributionTitle ?? 'LibreChat',
40
+ };
41
+ if (hasKey) {
42
+ headers['X-API-Key'] = resolvedKey;
43
+ }
44
+
45
+ const getSources = async ({
46
+ query,
47
+ date,
48
+ numResults = 8,
49
+ }: t.GetSourcesParams): Promise<t.SearchResult> => {
50
+ if (!query.trim()) {
51
+ return { success: false, error: 'Query cannot be empty' };
52
+ }
53
+
54
+ try {
55
+ /** Keenable's endpoint has no result-count parameter; the count is
56
+ * applied client-side after the response (see slice below). */
57
+ const payload: t.KeenableSearchPayload = { query };
58
+ if (options?.site != null && options.site !== '') {
59
+ payload.site = options.site;
60
+ }
61
+ if (date != null) {
62
+ payload.published_after = KEENABLE_DATE_RANGES[date];
63
+ }
64
+
65
+ const response = await axios.post<t.KeenableSearchResponse>(
66
+ resolvedUrl,
67
+ payload,
68
+ { headers, timeout }
69
+ );
70
+
71
+ const maxResults = Math.min(
72
+ Math.max(1, options?.maxResults ?? numResults),
73
+ 20
74
+ );
75
+ const rawResults = Array.isArray(response.data.results)
76
+ ? response.data.results.slice(0, maxResults)
77
+ : [];
78
+
79
+ const organic: t.OrganicResult[] = rawResults.map((result) => ({
80
+ title: result.title ?? '',
81
+ link: result.url ?? '',
82
+ snippet: result.description ?? result.snippet ?? '',
83
+ date: result.published_at,
84
+ }));
85
+
86
+ return { success: true, data: { organic } };
87
+ } catch (error) {
88
+ const errorMessage =
89
+ error instanceof Error ? error.message : String(error);
90
+ return {
91
+ success: false,
92
+ error: `Keenable API request failed: ${errorMessage}`,
93
+ };
94
+ }
95
+ };
96
+
97
+ return { getSources };
98
+ };