@librechat/agents 3.2.56 → 3.2.58
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.
- package/dist/cjs/agents/AgentContext.cjs +7 -1
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +7 -2
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/langfuseToolOutputTracing.cjs +4 -0
- package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
- package/dist/cjs/langfuseTraceShaping.cjs +172 -0
- package/dist/cjs/langfuseTraceShaping.cjs.map +1 -0
- package/dist/cjs/run.cjs +6 -2
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/stream.cjs +3 -2
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +19 -5
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/search/keenable-search.cjs +68 -0
- package/dist/cjs/tools/search/keenable-search.cjs.map +1 -0
- package/dist/cjs/tools/search/rerankers.cjs +28 -11
- package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
- package/dist/cjs/tools/search/search.cjs +30 -4
- package/dist/cjs/tools/search/search.cjs.map +1 -1
- package/dist/cjs/tools/search/tool.cjs +14 -6
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +12 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/cjs/utils/title.cjs +9 -9
- package/dist/cjs/utils/title.cjs.map +1 -1
- package/dist/esm/agents/AgentContext.mjs +7 -1
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +7 -2
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/langfuseToolOutputTracing.mjs +4 -0
- package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
- package/dist/esm/langfuseTraceShaping.mjs +171 -0
- package/dist/esm/langfuseTraceShaping.mjs.map +1 -0
- package/dist/esm/run.mjs +6 -2
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/stream.mjs +3 -2
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +19 -5
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/search/keenable-search.mjs +66 -0
- package/dist/esm/tools/search/keenable-search.mjs.map +1 -0
- package/dist/esm/tools/search/rerankers.mjs +28 -11
- package/dist/esm/tools/search/rerankers.mjs.map +1 -1
- package/dist/esm/tools/search/search.mjs +30 -4
- package/dist/esm/tools/search/search.mjs.map +1 -1
- package/dist/esm/tools/search/tool.mjs +14 -6
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +12 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/esm/utils/title.mjs +9 -9
- package/dist/esm/utils/title.mjs.map +1 -1
- package/dist/types/graphs/Graph.d.ts +1 -0
- package/dist/types/langfuseTraceShaping.d.ts +20 -0
- package/dist/types/run.d.ts +1 -0
- package/dist/types/tools/ToolNode.d.ts +11 -1
- package/dist/types/tools/search/keenable-search.d.ts +4 -0
- package/dist/types/tools/search/rerankers.d.ts +7 -2
- package/dist/types/tools/search/types.d.ts +38 -1
- package/dist/types/types/graph.d.ts +22 -0
- package/dist/types/types/run.d.ts +9 -0
- package/dist/types/types/tools.d.ts +6 -0
- package/package.json +1 -1
- package/src/__tests__/stream.eagerEventExecution.test.ts +44 -0
- package/src/agents/AgentContext.ts +9 -0
- package/src/agents/__tests__/AgentContext.test.ts +40 -0
- package/src/graphs/Graph.ts +31 -3
- package/src/graphs/__tests__/composition.smoke.test.ts +53 -0
- package/src/langfuseToolOutputTracing.ts +11 -0
- package/src/langfuseTraceShaping.ts +280 -0
- package/src/llm/anthropic/inherited-stream-events.spec.ts +6 -3
- package/src/run.ts +7 -3
- package/src/specs/langfuse-routing.integration.test.ts +1 -1
- package/src/specs/langfuse-trace-shaping.test.ts +194 -0
- package/src/stream.ts +10 -2
- package/src/tools/ToolNode.ts +28 -4
- package/src/tools/__tests__/SubagentExecutor.test.ts +44 -0
- package/src/tools/__tests__/ToolNode.session.test.ts +160 -0
- package/src/tools/__tests__/hitl.test.ts +85 -0
- package/src/tools/search/jina-reranker.test.ts +70 -1
- package/src/tools/search/keenable-search.ts +98 -0
- package/src/tools/search/keenable.test.ts +183 -0
- package/src/tools/search/rerankers.ts +41 -4
- package/src/tools/search/search.ts +58 -2
- package/src/tools/search/source-processing.test.ts +86 -0
- package/src/tools/search/tool.ts +29 -4
- package/src/tools/search/types.ts +42 -1
- package/src/tools/subagent/SubagentExecutor.ts +11 -0
- package/src/types/graph.ts +22 -0
- package/src/types/run.ts +9 -0
- package/src/types/tools.ts +6 -0
- 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
|
|
@@ -140,6 +140,52 @@ describe('ToolNode code execution session management', () => {
|
|
|
140
140
|
expect(capturedConfigs[0]._injected_files).toBeUndefined();
|
|
141
141
|
});
|
|
142
142
|
|
|
143
|
+
it('injects the existing session into declared host tools on the direct path', async () => {
|
|
144
|
+
const capturedConfigs: Record<string, unknown>[] = [];
|
|
145
|
+
const sessions: t.ToolSessionMap = new Map();
|
|
146
|
+
sessions.set(Constants.EXECUTE_CODE, {
|
|
147
|
+
session_id: 'host-session',
|
|
148
|
+
files: [
|
|
149
|
+
{ id: 'g1', name: 'gen.png', storage_session_id: 'host-session' },
|
|
150
|
+
],
|
|
151
|
+
lastUpdated: Date.now(),
|
|
152
|
+
} satisfies t.CodeSessionContext);
|
|
153
|
+
|
|
154
|
+
const cfTool = tool(
|
|
155
|
+
async (_input, config) => {
|
|
156
|
+
capturedConfigs.push({ ...(config.toolCall ?? {}) });
|
|
157
|
+
return 'Created /mnt/data/x.py';
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: 'create_file',
|
|
161
|
+
description: 'write a file',
|
|
162
|
+
schema: z.object({ path: z.string(), content: z.string() }),
|
|
163
|
+
}
|
|
164
|
+
) as unknown as StructuredToolInterface;
|
|
165
|
+
|
|
166
|
+
const toolNode = new ToolNode({
|
|
167
|
+
tools: [cfTool],
|
|
168
|
+
sessions,
|
|
169
|
+
codeSessionToolNames: ['create_file', 'edit_file'],
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const aiMsg = new AIMessage({
|
|
173
|
+
content: '',
|
|
174
|
+
tool_calls: [
|
|
175
|
+
{
|
|
176
|
+
id: 'call_cf',
|
|
177
|
+
name: 'create_file',
|
|
178
|
+
args: { path: '/mnt/data/x.py', content: 'print(1)' },
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
});
|
|
182
|
+
await toolNode.invoke({ messages: [aiMsg] });
|
|
183
|
+
|
|
184
|
+
// Declared host tool writes into the current sandbox, not a fresh one.
|
|
185
|
+
expect(capturedConfigs).toHaveLength(1);
|
|
186
|
+
expect(capturedConfigs[0].session_id).toBe('host-session');
|
|
187
|
+
});
|
|
188
|
+
|
|
143
189
|
it('preserves per-file storage_session_id for multi-session files', async () => {
|
|
144
190
|
const capturedConfigs: Record<string, unknown>[] = [];
|
|
145
191
|
const sessions: t.ToolSessionMap = new Map();
|
|
@@ -774,6 +820,78 @@ describe('ToolNode code execution session management', () => {
|
|
|
774
820
|
expect(sessions.has(Constants.EXECUTE_CODE)).toBe(false);
|
|
775
821
|
});
|
|
776
822
|
|
|
823
|
+
it('stores the exec session for host tools declared in codeSessionToolNames', () => {
|
|
824
|
+
const sessions: t.ToolSessionMap = new Map();
|
|
825
|
+
const toolNode = new ToolNode({
|
|
826
|
+
tools: [createMockCodeTool({ capturedConfigs: [] })],
|
|
827
|
+
sessions,
|
|
828
|
+
eventDrivenMode: true,
|
|
829
|
+
codeSessionToolNames: ['create_file', 'edit_file'],
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
const storeMethod = (
|
|
833
|
+
toolNode as unknown as {
|
|
834
|
+
storeCodeSessionFromResults: (
|
|
835
|
+
results: t.ToolExecuteResult[],
|
|
836
|
+
requestMap: Map<string, t.ToolCallRequest>
|
|
837
|
+
) => void;
|
|
838
|
+
}
|
|
839
|
+
).storeCodeSessionFromResults.bind(toolNode);
|
|
840
|
+
|
|
841
|
+
storeMethod(
|
|
842
|
+
[
|
|
843
|
+
{
|
|
844
|
+
toolCallId: 'tc-cf',
|
|
845
|
+
content: 'Created /mnt/data/x.py (10 chars).',
|
|
846
|
+
artifact: { session_id: 'authoring-session' },
|
|
847
|
+
status: 'success',
|
|
848
|
+
},
|
|
849
|
+
],
|
|
850
|
+
new Map([['tc-cf', { id: 'tc-cf', name: 'create_file', args: {} }]])
|
|
851
|
+
);
|
|
852
|
+
|
|
853
|
+
// create_file's exec session is folded into the shared code session, so a
|
|
854
|
+
// later bash_tool/execute_code call reuses the same sandbox.
|
|
855
|
+
const stored = sessions.get(Constants.EXECUTE_CODE) as
|
|
856
|
+
| t.CodeSessionContext
|
|
857
|
+
| undefined;
|
|
858
|
+
expect(stored?.session_id).toBe('authoring-session');
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
it('does not store a host authoring tool session when not declared', () => {
|
|
862
|
+
const sessions: t.ToolSessionMap = new Map();
|
|
863
|
+
const toolNode = new ToolNode({
|
|
864
|
+
tools: [createMockCodeTool({ capturedConfigs: [] })],
|
|
865
|
+
sessions,
|
|
866
|
+
eventDrivenMode: true,
|
|
867
|
+
// codeSessionToolNames omitted — create_file must NOT be treated as a
|
|
868
|
+
// code-session participant.
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
const storeMethod = (
|
|
872
|
+
toolNode as unknown as {
|
|
873
|
+
storeCodeSessionFromResults: (
|
|
874
|
+
results: t.ToolExecuteResult[],
|
|
875
|
+
requestMap: Map<string, t.ToolCallRequest>
|
|
876
|
+
) => void;
|
|
877
|
+
}
|
|
878
|
+
).storeCodeSessionFromResults.bind(toolNode);
|
|
879
|
+
|
|
880
|
+
storeMethod(
|
|
881
|
+
[
|
|
882
|
+
{
|
|
883
|
+
toolCallId: 'tc-cf2',
|
|
884
|
+
content: 'Created /mnt/data/x.py (10 chars).',
|
|
885
|
+
artifact: { session_id: 'authoring-session' },
|
|
886
|
+
status: 'success',
|
|
887
|
+
},
|
|
888
|
+
],
|
|
889
|
+
new Map([['tc-cf2', { id: 'tc-cf2', name: 'create_file', args: {} }]])
|
|
890
|
+
);
|
|
891
|
+
|
|
892
|
+
expect(sessions.has(Constants.EXECUTE_CODE)).toBe(false);
|
|
893
|
+
});
|
|
894
|
+
|
|
777
895
|
it('ignores error results', () => {
|
|
778
896
|
const sessions: t.ToolSessionMap = new Map();
|
|
779
897
|
|
|
@@ -1102,5 +1220,47 @@ describe('ToolNode code execution session management', () => {
|
|
|
1102
1220
|
expect(capturedRequests[0].name).toBe('web_search');
|
|
1103
1221
|
expect(capturedRequests[0].codeSessionContext).toBeUndefined();
|
|
1104
1222
|
});
|
|
1223
|
+
|
|
1224
|
+
it('attaches codeSessionContext to declared host tools so they write into the current sandbox', async () => {
|
|
1225
|
+
const sessions: t.ToolSessionMap = new Map();
|
|
1226
|
+
sessions.set(Constants.EXECUTE_CODE, {
|
|
1227
|
+
session_id: 'cf-session',
|
|
1228
|
+
files: [
|
|
1229
|
+
{ id: 'g1', name: 'gen.png', storage_session_id: 'cf-session' },
|
|
1230
|
+
],
|
|
1231
|
+
lastUpdated: Date.now(),
|
|
1232
|
+
} satisfies t.CodeSessionContext);
|
|
1233
|
+
|
|
1234
|
+
const { capturedRequests } = captureBatchRequests();
|
|
1235
|
+
|
|
1236
|
+
const toolNode = new ToolNode({
|
|
1237
|
+
tools: [createDummyTool('create_file')],
|
|
1238
|
+
sessions,
|
|
1239
|
+
eventDrivenMode: true,
|
|
1240
|
+
codeSessionToolNames: ['create_file', 'edit_file'],
|
|
1241
|
+
toolCallStepIds: new Map([['call_cf', 'step_cf']]),
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
const aiMsg = new AIMessage({
|
|
1245
|
+
content: '',
|
|
1246
|
+
tool_calls: [
|
|
1247
|
+
{
|
|
1248
|
+
id: 'call_cf',
|
|
1249
|
+
name: 'create_file',
|
|
1250
|
+
args: { path: '/mnt/data/x.py', content: 'print(1)' },
|
|
1251
|
+
},
|
|
1252
|
+
],
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
await toolNode.invoke({ messages: [aiMsg] });
|
|
1256
|
+
|
|
1257
|
+
expect(capturedRequests).toHaveLength(1);
|
|
1258
|
+
expect(capturedRequests[0].name).toBe('create_file');
|
|
1259
|
+
// Existing session is injected so create_file writes into the current
|
|
1260
|
+
// sandbox instead of spawning a separate one.
|
|
1261
|
+
expect(capturedRequests[0].codeSessionContext?.session_id).toBe(
|
|
1262
|
+
'cf-session'
|
|
1263
|
+
);
|
|
1264
|
+
});
|
|
1105
1265
|
});
|
|
1106
1266
|
});
|
|
@@ -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');
|
|
@@ -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 =
|
|
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
|
+
};
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { createSearchAPI } from './search';
|
|
3
|
+
import { createSearchTool } from './tool';
|
|
4
|
+
import { DATE_RANGE } from './schema';
|
|
5
|
+
|
|
6
|
+
jest.mock('axios');
|
|
7
|
+
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
|
8
|
+
|
|
9
|
+
const sampleResponse = {
|
|
10
|
+
data: {
|
|
11
|
+
results: [
|
|
12
|
+
{
|
|
13
|
+
title: 'TypeScript Best Practices 2026',
|
|
14
|
+
url: 'https://example.com/ts',
|
|
15
|
+
description: 'A comprehensive guide to TypeScript.',
|
|
16
|
+
published_at: '2026-01-15T10:30:00Z',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
title: 'Second result',
|
|
20
|
+
url: 'https://example.com/second',
|
|
21
|
+
snippet: 'Snippet fallback when description is absent.',
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe('Keenable search API', () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
jest.clearAllMocks();
|
|
30
|
+
delete process.env.KEENABLE_API_KEY;
|
|
31
|
+
delete process.env.KEENABLE_API_URL;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns an error for empty queries without calling the API', async () => {
|
|
35
|
+
const searchAPI = createSearchAPI({ searchProvider: 'keenable' });
|
|
36
|
+
const result = await searchAPI.getSources({ query: ' ' });
|
|
37
|
+
|
|
38
|
+
expect(result).toEqual({ success: false, error: 'Query cannot be empty' });
|
|
39
|
+
expect(mockedAxios.post).not.toHaveBeenCalled();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('hits the public endpoint and omits the API key header when keyless', async () => {
|
|
43
|
+
mockedAxios.post.mockResolvedValueOnce(sampleResponse);
|
|
44
|
+
|
|
45
|
+
const searchAPI = createSearchAPI({ searchProvider: 'keenable' });
|
|
46
|
+
const result = await searchAPI.getSources({ query: 'typescript' });
|
|
47
|
+
|
|
48
|
+
expect(mockedAxios.post).toHaveBeenCalledWith(
|
|
49
|
+
'https://api.keenable.ai/v1/search/public',
|
|
50
|
+
{ query: 'typescript' },
|
|
51
|
+
expect.objectContaining({
|
|
52
|
+
headers: expect.objectContaining({ 'X-Keenable-Title': 'LibreChat' }),
|
|
53
|
+
})
|
|
54
|
+
);
|
|
55
|
+
const headers = mockedAxios.post.mock.calls[0][2]?.headers as Record<
|
|
56
|
+
string,
|
|
57
|
+
string
|
|
58
|
+
>;
|
|
59
|
+
expect(headers['X-API-Key']).toBeUndefined();
|
|
60
|
+
expect(result.success).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('hits the authenticated endpoint and sends the API key when a key is set', async () => {
|
|
64
|
+
mockedAxios.post.mockResolvedValueOnce(sampleResponse);
|
|
65
|
+
|
|
66
|
+
const searchAPI = createSearchAPI({
|
|
67
|
+
searchProvider: 'keenable',
|
|
68
|
+
keenableApiKey: 'secret-key',
|
|
69
|
+
});
|
|
70
|
+
await searchAPI.getSources({ query: 'typescript' });
|
|
71
|
+
|
|
72
|
+
expect(mockedAxios.post).toHaveBeenCalledWith(
|
|
73
|
+
'https://api.keenable.ai/v1/search',
|
|
74
|
+
{ query: 'typescript' },
|
|
75
|
+
expect.objectContaining({
|
|
76
|
+
headers: expect.objectContaining({ 'X-API-Key': 'secret-key' }),
|
|
77
|
+
})
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('maps results into organic sources (description and snippet fallback)', async () => {
|
|
82
|
+
mockedAxios.post.mockResolvedValueOnce(sampleResponse);
|
|
83
|
+
|
|
84
|
+
const searchAPI = createSearchAPI({ searchProvider: 'keenable' });
|
|
85
|
+
const result = await searchAPI.getSources({ query: 'typescript' });
|
|
86
|
+
|
|
87
|
+
expect(result.data?.organic).toEqual([
|
|
88
|
+
{
|
|
89
|
+
title: 'TypeScript Best Practices 2026',
|
|
90
|
+
link: 'https://example.com/ts',
|
|
91
|
+
snippet: 'A comprehensive guide to TypeScript.',
|
|
92
|
+
date: '2026-01-15T10:30:00Z',
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
title: 'Second result',
|
|
96
|
+
link: 'https://example.com/second',
|
|
97
|
+
snippet: 'Snippet fallback when description is absent.',
|
|
98
|
+
date: undefined,
|
|
99
|
+
},
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('applies the site filter and limits results client-side', async () => {
|
|
104
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
105
|
+
data: {
|
|
106
|
+
results: [{ url: '1' }, { url: '2' }, { url: '3' }],
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const searchAPI = createSearchAPI({
|
|
111
|
+
searchProvider: 'keenable',
|
|
112
|
+
keenableSearchOptions: { site: 'github.com', maxResults: 2 },
|
|
113
|
+
});
|
|
114
|
+
const result = await searchAPI.getSources({ query: 'typescript' });
|
|
115
|
+
|
|
116
|
+
expect(mockedAxios.post).toHaveBeenCalledWith(
|
|
117
|
+
expect.any(String),
|
|
118
|
+
{ query: 'typescript', site: 'github.com' },
|
|
119
|
+
expect.any(Object)
|
|
120
|
+
);
|
|
121
|
+
expect(result.data?.organic).toHaveLength(2);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('maps date ranges to Keenable published filters', async () => {
|
|
125
|
+
mockedAxios.post.mockResolvedValueOnce(sampleResponse);
|
|
126
|
+
|
|
127
|
+
const searchAPI = createSearchAPI({ searchProvider: 'keenable' });
|
|
128
|
+
await searchAPI.getSources({
|
|
129
|
+
query: 'typescript',
|
|
130
|
+
date: DATE_RANGE.PAST_WEEK,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(mockedAxios.post).toHaveBeenCalledWith(
|
|
134
|
+
expect.any(String),
|
|
135
|
+
{ query: 'typescript', published_after: '7d' },
|
|
136
|
+
expect.any(Object)
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('surfaces request failures as a structured error', async () => {
|
|
141
|
+
mockedAxios.post.mockRejectedValueOnce(new Error('Network error'));
|
|
142
|
+
|
|
143
|
+
const searchAPI = createSearchAPI({ searchProvider: 'keenable' });
|
|
144
|
+
const result = await searchAPI.getSources({ query: 'typescript' });
|
|
145
|
+
|
|
146
|
+
expect(result.success).toBe(false);
|
|
147
|
+
expect(result.error).toBe('Keenable API request failed: Network error');
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('Keenable capability gating', () => {
|
|
152
|
+
beforeEach(() => {
|
|
153
|
+
jest.clearAllMocks();
|
|
154
|
+
delete process.env.KEENABLE_API_KEY;
|
|
155
|
+
delete process.env.KEENABLE_API_URL;
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('skips image/news/video sub-searches (organic-only provider)', async () => {
|
|
159
|
+
mockedAxios.post.mockResolvedValueOnce(sampleResponse).mockResolvedValue({
|
|
160
|
+
data: { success: true, data: { markdown: '# A', html: '<p>a</p>' } },
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const searchTool = createSearchTool({
|
|
164
|
+
searchProvider: 'keenable',
|
|
165
|
+
scraperProvider: 'firecrawl',
|
|
166
|
+
firecrawlApiKey: 'k',
|
|
167
|
+
topResults: 1,
|
|
168
|
+
rerankerType: 'none',
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
await searchTool.invoke({
|
|
172
|
+
query: 'typescript',
|
|
173
|
+
images: true,
|
|
174
|
+
news: true,
|
|
175
|
+
videos: true,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const searchCalls = mockedAxios.post.mock.calls.filter(([url]) =>
|
|
179
|
+
(url as string).includes('keenable')
|
|
180
|
+
);
|
|
181
|
+
expect(searchCalls).toHaveLength(1);
|
|
182
|
+
});
|
|
183
|
+
});
|