@librechat/agents 3.2.65 → 3.2.67

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 (78) hide show
  1. package/dist/cjs/graphs/Graph.cjs +15 -2
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/hooks/createToolPolicyHook.cjs +5 -5
  4. package/dist/cjs/hooks/createToolPolicyHook.cjs.map +1 -1
  5. package/dist/cjs/instrumentation.cjs +15 -3
  6. package/dist/cjs/instrumentation.cjs.map +1 -1
  7. package/dist/cjs/langfuseToolOutputTracing.cjs +1 -2
  8. package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
  9. package/dist/cjs/langfuseTraceShaping.cjs +51 -24
  10. package/dist/cjs/langfuseTraceShaping.cjs.map +1 -1
  11. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +8 -0
  12. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  13. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +8 -0
  14. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  15. package/dist/cjs/tools/BashExecutor.cjs +9 -8
  16. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  17. package/dist/cjs/tools/CodeExecutor.cjs +9 -7
  18. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  19. package/dist/cjs/tools/ToolSearch.cjs +59 -14
  20. package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
  21. package/dist/cjs/tools/search/tool.cjs +5 -3
  22. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  23. package/dist/esm/graphs/Graph.mjs +15 -2
  24. package/dist/esm/graphs/Graph.mjs.map +1 -1
  25. package/dist/esm/hooks/createToolPolicyHook.mjs +5 -5
  26. package/dist/esm/hooks/createToolPolicyHook.mjs.map +1 -1
  27. package/dist/esm/instrumentation.mjs +15 -3
  28. package/dist/esm/instrumentation.mjs.map +1 -1
  29. package/dist/esm/langfuseToolOutputTracing.mjs +1 -2
  30. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  31. package/dist/esm/langfuseTraceShaping.mjs +51 -24
  32. package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
  33. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +8 -0
  34. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  35. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +8 -0
  36. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  37. package/dist/esm/tools/BashExecutor.mjs +9 -8
  38. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  39. package/dist/esm/tools/CodeExecutor.mjs +9 -7
  40. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  41. package/dist/esm/tools/ToolSearch.mjs +59 -14
  42. package/dist/esm/tools/ToolSearch.mjs.map +1 -1
  43. package/dist/esm/tools/search/tool.mjs +5 -3
  44. package/dist/esm/tools/search/tool.mjs.map +1 -1
  45. package/dist/types/graphs/Graph.d.ts +2 -0
  46. package/dist/types/hooks/createToolPolicyHook.d.ts +12 -15
  47. package/dist/types/langfuseTraceShaping.d.ts +12 -6
  48. package/dist/types/tools/BashExecutor.d.ts +7 -6
  49. package/dist/types/tools/CodeExecutor.d.ts +7 -5
  50. package/dist/types/tools/search/types.d.ts +2 -0
  51. package/dist/types/types/graph.d.ts +10 -3
  52. package/package.json +1 -1
  53. package/src/graphs/Graph.ts +21 -3
  54. package/src/hooks/__tests__/createToolPolicyHook.test.ts +13 -4
  55. package/src/hooks/createToolPolicyHook.ts +16 -19
  56. package/src/instrumentation.ts +20 -0
  57. package/src/langfuseToolOutputTracing.ts +2 -4
  58. package/src/langfuseTraceShaping.ts +73 -20
  59. package/src/llm/anthropic/utils/cross-provider-server-tools.test.ts +110 -0
  60. package/src/llm/anthropic/utils/message_inputs.ts +15 -0
  61. package/src/llm/bedrock/utils/cross-provider-server-tools.test.ts +122 -0
  62. package/src/llm/bedrock/utils/message_inputs.ts +13 -0
  63. package/src/specs/langfuse-instrumentation.test.ts +64 -0
  64. package/src/specs/langfuse-routing.integration.test.ts +49 -4
  65. package/src/specs/langfuse-tool-output-tracing.test.ts +5 -4
  66. package/src/specs/langfuse-trace-shaping.test.ts +80 -9
  67. package/src/specs/subagent.test.ts +180 -0
  68. package/src/tools/BashExecutor.ts +9 -8
  69. package/src/tools/CodeExecutor.ts +9 -7
  70. package/src/tools/ToolSearch.ts +81 -23
  71. package/src/tools/__tests__/BashExecutor.test.ts +16 -5
  72. package/src/tools/__tests__/CodeExecutor.stateful.test.ts +17 -6
  73. package/src/tools/__tests__/ToolSearch.test.ts +151 -0
  74. package/src/tools/__tests__/hitl.test.ts +114 -1
  75. package/src/tools/search/source-processing.test.ts +57 -0
  76. package/src/tools/search/tool.ts +13 -1
  77. package/src/tools/search/types.ts +2 -0
  78. package/src/types/graph.ts +10 -3
@@ -26,6 +26,8 @@ const INPUT = LangfuseOtelSpanAttributes.OBSERVATION_INPUT;
26
26
  const OUTPUT = LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT;
27
27
  const TRACE_INPUT = LangfuseOtelSpanAttributes.TRACE_INPUT;
28
28
  const TRACE_OUTPUT = LangfuseOtelSpanAttributes.TRACE_OUTPUT;
29
+ const OBSERVATION_TYPE = LangfuseOtelSpanAttributes.OBSERVATION_TYPE;
30
+ const TRACE_TAGS = LangfuseOtelSpanAttributes.TRACE_TAGS;
29
31
 
30
32
  describe('shouldDropLangfuseSpan', () => {
31
33
  it('drops langgraph __start__ seed spans', () => {
@@ -40,6 +42,7 @@ describe('shouldDropLangfuseSpan', () => {
40
42
  expect(shouldDropLangfuseSpan('GenerateTitle')).toBe(false);
41
43
  expect(shouldDropLangfuseSpan('agent=openAI__gpt-5.4')).toBe(false);
42
44
  expect(shouldDropLangfuseSpan('ChatOpenAI')).toBe(false);
45
+ expect(shouldDropLangfuseSpan('tool_batch')).toBe(false);
43
46
  });
44
47
  });
45
48
 
@@ -48,9 +51,10 @@ describe('shapeLangfuseSpan', () => {
48
51
  const span = createSpan('agent=openAI__gpt-5.4', {}, 'parent-1');
49
52
  shapeLangfuseSpan(span);
50
53
  expect(span.name).toBe('agent');
54
+ expect(span.attributes[OBSERVATION_TYPE]).toBe('agent');
51
55
  });
52
56
 
53
- it('renames tool node spans to the pending tool names and scopes input to args', () => {
57
+ it('shapes tool nodes as stable dispatch chains with scoped call inputs', () => {
54
58
  const messages = [
55
59
  { type: 'human', content: 'hello' },
56
60
  {
@@ -71,13 +75,14 @@ describe('shapeLangfuseSpan', () => {
71
75
  'parent-1'
72
76
  );
73
77
  shapeLangfuseSpan(span);
74
- expect(span.name).toBe('get_service_details');
78
+ expect(span.name).toBe('tool-dispatch');
79
+ expect(span.attributes[OBSERVATION_TYPE]).toBe('chain');
75
80
  expect(JSON.parse(span.attributes[INPUT] as string)).toEqual([
76
81
  { name: 'get_service_details', args: { path: 'organizations/1' } },
77
82
  ]);
78
83
  });
79
84
 
80
- it('joins multiple pending tool names and dedupes repeats', () => {
85
+ it('preserves every pending call in a multi-tool dispatch input', () => {
81
86
  const messages = [
82
87
  {
83
88
  type: 'ai',
@@ -89,12 +94,16 @@ describe('shapeLangfuseSpan', () => {
89
94
  },
90
95
  ];
91
96
  const span = createSpan(
92
- 'tool_batch',
97
+ 'tools=openAI__gpt-5.4',
93
98
  { [INPUT]: JSON.stringify({ messages }) },
94
99
  'parent-1'
95
100
  );
96
101
  shapeLangfuseSpan(span);
97
- expect(span.name).toBe('web_search, execute_code');
102
+ expect(JSON.parse(span.attributes[INPUT] as string)).toEqual([
103
+ { name: 'web_search', args: { q: 'a' } },
104
+ { name: 'web_search', args: { q: 'b' } },
105
+ { name: 'execute_code', args: { code: '1+1' } },
106
+ ]);
98
107
  });
99
108
 
100
109
  it('reads tool calls from serialized langchain message kwargs', () => {
@@ -115,10 +124,10 @@ describe('shapeLangfuseSpan', () => {
115
124
  'parent-1'
116
125
  );
117
126
  shapeLangfuseSpan(span);
118
- expect(span.name).toBe('lookup');
127
+ expect(span.name).toBe('tool-dispatch');
119
128
  });
120
129
 
121
- it('leaves tool node spans untouched when no tool calls are found', () => {
130
+ it('keeps a stable tool-dispatch shape when no tool calls are found', () => {
122
131
  const original = JSON.stringify({
123
132
  messages: [{ type: 'human', content: 'hi' }],
124
133
  });
@@ -128,12 +137,14 @@ describe('shapeLangfuseSpan', () => {
128
137
  'parent-1'
129
138
  );
130
139
  shapeLangfuseSpan(span);
131
- expect(span.name).toBe('tools=agent_abc');
140
+ expect(span.name).toBe('tool-dispatch');
141
+ expect(span.attributes[OBSERVATION_TYPE]).toBe('chain');
132
142
  expect(span.attributes[INPUT]).toBe(original);
133
143
  });
134
144
 
135
145
  it('sets root span and trace input/output to the question and answer', () => {
136
146
  const span = createSpan('LibreChat Agent', {
147
+ [TRACE_TAGS]: JSON.stringify(['librechat', 'agent']),
137
148
  [INPUT]: JSON.stringify({
138
149
  messages: [
139
150
  { type: 'system', content: 'You are helpful.' },
@@ -156,6 +167,7 @@ describe('shapeLangfuseSpan', () => {
156
167
 
157
168
  it('extracts answer text from content part arrays', () => {
158
169
  const span = createSpan('LibreChat Agent', {
170
+ [TRACE_TAGS]: JSON.stringify(['librechat', 'agent']),
159
171
  [INPUT]: JSON.stringify([{ type: 'human', content: 'hi' }]),
160
172
  [OUTPUT]: JSON.stringify({
161
173
  messages: [
@@ -189,6 +201,65 @@ describe('shapeLangfuseSpan', () => {
189
201
  const span = createSpan('LibreChat Agent', { [INPUT]: 'plain text' });
190
202
  shapeLangfuseSpan(span);
191
203
  expect(span.attributes[INPUT]).toBe('plain text');
192
- expect(span.attributes[TRACE_INPUT]).toBeUndefined();
204
+ expect(span.attributes[TRACE_INPUT]).toBe('plain text');
205
+ });
206
+
207
+ it('renames generation spans to a provider-agnostic name', () => {
208
+ const span = createSpan(
209
+ 'ChatOpenAI',
210
+ { [OBSERVATION_TYPE]: 'generation' },
211
+ 'parent-1'
212
+ );
213
+ shapeLangfuseSpan(span);
214
+ expect(span.name).toBe('llm');
215
+ });
216
+
217
+ it('marks only agent-tagged root spans as agent observations', () => {
218
+ const span = createSpan('LibreChat Agent', {
219
+ [TRACE_TAGS]: JSON.stringify(['librechat', 'agent']),
220
+ [INPUT]: JSON.stringify({
221
+ messages: [{ type: 'human', content: 'hi' }],
222
+ }),
223
+ });
224
+ shapeLangfuseSpan(span);
225
+ expect(span.attributes[OBSERVATION_TYPE]).toBe('agent');
226
+ });
227
+
228
+ it('marks title-tagged root spans as chain observations', () => {
229
+ const span = createSpan('LibreChat Title', {
230
+ [TRACE_TAGS]: JSON.stringify(['librechat', 'title']),
231
+ [INPUT]: 'Conversation text',
232
+ [OUTPUT]: 'Conversation title',
233
+ });
234
+
235
+ shapeLangfuseSpan(span);
236
+
237
+ expect(span.attributes[OBSERVATION_TYPE]).toBe('chain');
238
+ expect(span.attributes[TRACE_INPUT]).toBe('Conversation text');
239
+ expect(span.attributes[TRACE_OUTPUT]).toBe('Conversation title');
240
+ });
241
+
242
+ it('does not classify untagged root spans as agents', () => {
243
+ const span = createSpan('Custom root', { [INPUT]: 'input' });
244
+
245
+ shapeLangfuseSpan(span);
246
+
247
+ expect(span.attributes[OBSERVATION_TYPE]).toBeUndefined();
248
+ });
249
+
250
+ it('shapes standalone generation roots without replacing their type', () => {
251
+ const span = createSpan('ChatOpenAI', {
252
+ [OBSERVATION_TYPE]: 'generation',
253
+ [TRACE_TAGS]: JSON.stringify(['librechat', 'title']),
254
+ [INPUT]: 'Generate a title',
255
+ [OUTPUT]: 'A useful title',
256
+ });
257
+
258
+ shapeLangfuseSpan(span);
259
+
260
+ expect(span.name).toBe('llm');
261
+ expect(span.attributes[OBSERVATION_TYPE]).toBe('generation');
262
+ expect(span.attributes[TRACE_INPUT]).toBe('Generate a title');
263
+ expect(span.attributes[TRACE_OUTPUT]).toBe('A useful title');
193
264
  });
194
265
  });
@@ -312,6 +312,186 @@ describe('Subagent Integration', () => {
312
312
  createWorkflowSpy.mockRestore();
313
313
  });
314
314
 
315
+ it('forwards event-driven tools through nested child graphs', async () => {
316
+ const originalCreateWorkflow = StandardGraph.prototype.createWorkflow;
317
+ const parentToolHandler = jest.fn(
318
+ (_event: string, rawData: unknown): void => {
319
+ const request = rawData as t.ToolExecuteBatchRequest;
320
+ request.resolve(
321
+ request.toolCalls.map((call) => ({
322
+ toolCallId: call.id,
323
+ status: 'success' as const,
324
+ content: `ran ${call.name}`,
325
+ }))
326
+ );
327
+ }
328
+ );
329
+ const parentUpdateHandler = jest.fn();
330
+ let specialistToolDefinitions: t.LCTool[] | undefined;
331
+ let forwardedToolResults: t.ToolExecuteResult[] | undefined;
332
+
333
+ const createWorkflowSpy = jest
334
+ .spyOn(StandardGraph.prototype, 'createWorkflow')
335
+ .mockImplementation(function (this: StandardGraph) {
336
+ const workflow = originalCreateWorkflow.call(this);
337
+ if (this.defaultAgentId === 'router') {
338
+ return {
339
+ invoke: jest.fn(async () => {
340
+ const routerContext = this.agentContexts.get('router');
341
+ const nestedTool = (
342
+ routerContext?.graphTools as t.GenericTool[] | undefined
343
+ )?.find(
344
+ (tool) => 'name' in tool && tool.name === Constants.SUBAGENT
345
+ );
346
+ if (nestedTool == null) {
347
+ throw new Error('Nested subagent tool was not created');
348
+ }
349
+ await nestedTool.invoke(
350
+ {
351
+ description: 'Use the event-driven lookup tool.',
352
+ subagent_type: 'specialist',
353
+ },
354
+ callerConfig
355
+ );
356
+ return { messages: [new AIMessage('router done')] };
357
+ }),
358
+ } as unknown as ReturnType<StandardGraph['createWorkflow']>;
359
+ }
360
+ if (this.defaultAgentId === 'specialist') {
361
+ specialistToolDefinitions =
362
+ this.agentContexts.get('specialist')?.toolDefinitions;
363
+ return {
364
+ invoke: jest.fn(async (_state, options) => {
365
+ const invokeOptions = options as
366
+ | { callbacks?: unknown[] }
367
+ | undefined;
368
+ const forwarder = (invokeOptions?.callbacks ?? [])[0] as
369
+ | {
370
+ handleCustomEvent?: (
371
+ eventName: string,
372
+ data: unknown,
373
+ runId: string
374
+ ) => Promise<void> | void;
375
+ }
376
+ | undefined;
377
+ if (forwarder?.handleCustomEvent != null) {
378
+ forwardedToolResults = await new Promise<t.ToolExecuteResult[]>(
379
+ (resolve, reject) => {
380
+ const request: t.ToolExecuteBatchRequest = {
381
+ toolCalls: [
382
+ { id: 'nested-call', name: 'mcp_lookup', args: {} },
383
+ ],
384
+ agentId: 'specialist',
385
+ resolve,
386
+ reject,
387
+ };
388
+ void forwarder.handleCustomEvent?.(
389
+ GraphEvents.ON_TOOL_EXECUTE,
390
+ request,
391
+ 'specialist-run'
392
+ );
393
+ }
394
+ );
395
+ await forwarder.handleCustomEvent(
396
+ GraphEvents.ON_RUN_STEP,
397
+ { id: 'specialist-step', type: 'tool_calls' },
398
+ 'specialist-run'
399
+ );
400
+ }
401
+ return { messages: [new AIMessage('specialist done')] };
402
+ }),
403
+ } as unknown as ReturnType<StandardGraph['createWorkflow']>;
404
+ }
405
+ return workflow;
406
+ });
407
+
408
+ const rootAgent: t.AgentInputs = {
409
+ agentId: 'root',
410
+ provider: Providers.OPENAI,
411
+ clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
412
+ instructions: 'Delegate through the router.',
413
+ maxContextTokens: 8000,
414
+ maxSubagentDepth: 2,
415
+ subagentConfigs: [
416
+ {
417
+ type: 'router',
418
+ name: 'Router',
419
+ description: 'Routes work to specialists.',
420
+ allowNested: true,
421
+ agentInputs: {
422
+ agentId: 'router',
423
+ provider: Providers.OPENAI,
424
+ clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
425
+ instructions: 'Delegate to the specialist.',
426
+ maxContextTokens: 8000,
427
+ subagentConfigs: [
428
+ {
429
+ type: 'specialist',
430
+ name: 'Specialist',
431
+ description: 'Uses an event-driven tool.',
432
+ agentInputs: {
433
+ agentId: 'specialist',
434
+ provider: Providers.OPENAI,
435
+ clientOptions: {
436
+ modelName: 'gpt-4o-mini',
437
+ apiKey: 'test-key',
438
+ },
439
+ instructions: 'Use the lookup tool.',
440
+ maxContextTokens: 8000,
441
+ toolDefinitions: [{ name: 'mcp_lookup' }],
442
+ },
443
+ },
444
+ ],
445
+ },
446
+ },
447
+ ],
448
+ };
449
+
450
+ try {
451
+ const run = await Run.create<t.IState>({
452
+ runId: `nested-event-tools-${Date.now()}`,
453
+ graphConfig: { type: 'standard', agents: [rootAgent] },
454
+ customHandlers: {
455
+ [GraphEvents.ON_TOOL_EXECUTE]: { handle: parentToolHandler },
456
+ [GraphEvents.ON_SUBAGENT_UPDATE]: {
457
+ handle: parentUpdateHandler,
458
+ },
459
+ },
460
+ returnContent: true,
461
+ skipCleanup: true,
462
+ });
463
+ const rootContext = (run.Graph as StandardGraph).agentContexts.get(
464
+ 'root'
465
+ );
466
+ const rootSubagentTool = (
467
+ rootContext?.graphTools as t.GenericTool[] | undefined
468
+ )?.find((tool) => 'name' in tool && tool.name === Constants.SUBAGENT);
469
+ expect(rootSubagentTool).toBeDefined();
470
+
471
+ await rootSubagentTool!.invoke(
472
+ { description: 'Route this task.', subagent_type: 'router' },
473
+ callerConfig
474
+ );
475
+
476
+ expect(specialistToolDefinitions).toEqual([{ name: 'mcp_lookup' }]);
477
+ expect(parentToolHandler).toHaveBeenCalledTimes(1);
478
+ expect(forwardedToolResults).toEqual([
479
+ {
480
+ toolCallId: 'nested-call',
481
+ status: 'success',
482
+ content: 'ran mcp_lookup',
483
+ },
484
+ ]);
485
+ const forwardedSubagentTypes = parentUpdateHandler.mock.calls.map(
486
+ ([, data]) => (data as t.SubagentUpdateEvent).subagentType
487
+ );
488
+ expect(forwardedSubagentTypes).toContain('router');
489
+ expect(forwardedSubagentTypes).not.toContain('specialist');
490
+ } finally {
491
+ createWorkflowSpy.mockRestore();
492
+ }
493
+ });
494
+
315
495
  it('should not create subagent tool when maxSubagentDepth is 0', async () => {
316
496
  const agentWithZeroDepth: t.AgentInputs = {
317
497
  ...createParentAgent(),
@@ -58,17 +58,18 @@ Usage:
58
58
  `.trim();
59
59
 
60
60
  /**
61
- * Bash statefulness is filesystem-tier: on a warm session the machine (files
62
- * including /tmp, installed packages, background processes) persists between
63
- * calls, but each call may start a fresh shell so shell variables and cwd
64
- * are NOT reliable, and the machine can be reset at any time. Only /mnt/data
65
- * is durable.
61
+ * Bash statefulness is filesystem-tier and scoped to `/mnt/data`. The machine
62
+ * is warm across calls, but each call runs in a fresh sandbox (new process
63
+ * tree + private /tmp), so background processes are reaped when the call ends
64
+ * and anything written outside /mnt/data is discarded. The note must not
65
+ * promise otherwise: a model told background processes survive will start a
66
+ * server in one call and assume it is listening in the next.
66
67
  */
67
68
  export const STATEFUL_BASH_NOTE =
68
- 'Session state (best-effort): commands in this conversation usually run on the same machine, so files (including /tmp), installed packages, and running background processes from earlier calls typically persist. Each call may still start a fresh shell do not rely on shell variables or the working directory carrying over and the machine may be reset at any time. Only /mnt/data is durable.';
69
+ 'Session state: commands in this conversation run on the same warm machine, so files written to /mnt/data persist between calls. Each call runs in a fresh, isolated sandbox: shell variables, the working directory, /tmp, and background processes do NOT survive after the call returns a process started in one call is terminated when that call ends. Only /mnt/data is durable (the machine itself may also be reset at any time).';
69
70
 
70
71
  export const StatefulBashExecutionToolDescription = `
71
- Runs bash commands and returns stdout/stderr output from a session-based execution environment, similar to a long-running machine.
72
+ Runs bash commands and returns stdout/stderr output. Commands in this conversation share one warm machine with a persistent /mnt/data, but each command runs in its own isolated sandbox (not a persistent shell session).
72
73
 
73
74
  ${STATEFUL_BASH_NOTE}
74
75
 
@@ -125,7 +126,7 @@ export function buildBashExecutionToolDescription(options?: {
125
126
  const STATELESS_BASH_PARAM_NOTE =
126
127
  'The environment is stateless; variables and state don\'t persist between executions.';
127
128
  const STATEFUL_BASH_PARAM_NOTE =
128
- 'Files, installed packages, and background processes usually persist between calls, but each call may start a fresh shell (do not rely on shell variables or cwd) and the machine may reset. Only /mnt/data is durable.';
129
+ 'Files written to /mnt/data persist between calls on the same warm machine. Each call runs in a fresh sandbox: shell variables, cwd, /tmp, and background processes do NOT survive the call. Only /mnt/data is durable.';
129
130
 
130
131
  export function buildBashExecutionToolSchema(opts?: {
131
132
  statefulSessions?: boolean;
@@ -150,16 +150,18 @@ Usage:
150
150
  `.trim();
151
151
 
152
152
  /**
153
- * Best-effort statefulness note. Deliberately hedged: warm reuse is an
154
- * optimization, not a guarantee (the runtime may be reset on idle timeout,
155
- * eviction, or the 8h VM lifetime), so the model must never depend on carried
156
- * state for correctness and must persist anything durable to /mnt/data.
153
+ * Statefulness here is FILESYSTEM-tier, not runtime-tier. Executions in a
154
+ * session reuse one warm machine, so `/mnt/data` carries across calls but
155
+ * every execution is a brand-new interpreter process in a fresh sandbox, so
156
+ * variables and imports never survive. The note must not imply otherwise: a
157
+ * model told its in-memory state persists writes `df = ...` in one call and
158
+ * `df.head()` in the next, then hits a NameError it was told to treat as rare.
157
159
  */
158
160
  export const STATEFUL_ENV_NOTE =
159
- 'Session state (best-effort): consecutive executions in this conversation usually share one runtime, so variables, imports, and in-memory data from earlier successful calls are typically still available. The runtime may be reset at any time, so treat carried-over state as an optimization, never a guarantee. Anything that must survive MUST be written to /mnt/data. If a NameError/ImportError signals lost state, re-run the needed setup and continue.';
161
+ 'Session state: executions in this conversation run on the same warm machine, so files persist between calls but each execution is a NEW process. Variables, imports, and in-memory data NEVER carry over: every call must re-import and rebuild the state it needs. Only /mnt/data is durable (the machine itself may also be reset at any time), so write anything that must survive there and read it back next call.';
160
162
 
161
163
  export const StatefulCodeExecutionToolDescription = `
162
- Runs code and returns stdout/stderr output from a session-based execution environment, similar to a long-running command-line session.
164
+ Runs code and returns stdout/stderr output. Executions in this conversation share one warm machine with a persistent /mnt/data, but each execution runs as a separate process (not a notebook-style kernel).
163
165
 
164
166
  ${STATEFUL_ENV_NOTE}
165
167
 
@@ -181,7 +183,7 @@ export function buildCodeExecutionToolDescription(opts?: {
181
183
  const STATELESS_CODE_PARAM_NOTE =
182
184
  'The environment is stateless; variables and imports don\'t persist between executions.';
183
185
  const STATEFUL_CODE_PARAM_NOTE =
184
- 'Executions in this conversation usually share one runtime: variables and imports from prior successful calls are typically still defined, but the runtime may reset between calls. Rebuild state on NameError/ImportError; persist anything important to /mnt/data.';
186
+ 'Executions in this conversation share one warm machine, so files written to /mnt/data persist between calls. Each execution is a new process: variables and imports do NOT carry over re-import and reload from /mnt/data every call.';
185
187
 
186
188
  export function buildCodeExecutionToolSchema(opts?: {
187
189
  statefulSessions?: boolean;
@@ -359,18 +359,48 @@ function simplifyParametersForSearch(
359
359
  return { type: parameters.type };
360
360
  }
361
361
 
362
+ /**
363
+ * Splits one alphanumeric identifier segment on case boundaries without
364
+ * emitting artificial one-character acronym fragments.
365
+ */
366
+ function splitCaseSegment(segment: string): string[] {
367
+ const splitTokens = segment
368
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
369
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
370
+ .toLowerCase()
371
+ .split(/\s+/)
372
+ .filter(Boolean);
373
+
374
+ if (splitTokens.length < 2) return splitTokens;
375
+
376
+ const mergedTokens: string[] = [];
377
+ let prefix = '';
378
+ for (const token of splitTokens) {
379
+ if (token.length === 1) {
380
+ prefix += token;
381
+ continue;
382
+ }
383
+ mergedTokens.push(`${prefix}${token}`);
384
+ prefix = '';
385
+ }
386
+
387
+ if (prefix && mergedTokens.length > 0) {
388
+ mergedTokens[mergedTokens.length - 1] += prefix;
389
+ }
390
+ return mergedTokens.length > 0 ? mergedTokens : [prefix];
391
+ }
392
+
362
393
  /**
363
394
  * Tokenizes a string into lowercase words for BM25.
364
- * Splits on underscores and non-alphanumeric characters for consistent matching.
395
+ * Splits camelCase, underscores, and non-alphanumeric characters for consistent matching.
365
396
  * @param text - The text to tokenize
366
397
  * @returns Array of lowercase tokens
367
398
  */
368
399
  function tokenize(text: string): string[] {
369
400
  return text
370
- .toLowerCase()
371
- .replace(/[^a-z0-9]/g, ' ')
372
- .split(/\s+/)
373
- .filter((token) => token.length > 0);
401
+ .split(/[^a-zA-Z0-9]+/)
402
+ .filter(Boolean)
403
+ .flatMap(splitCaseSegment);
374
404
  }
375
405
 
376
406
  /**
@@ -396,7 +426,7 @@ function createToolDocument(tool: t.ToolMetadata, fields: string[]): string {
396
426
  parts.push(paramNames);
397
427
  }
398
428
 
399
- return parts.join(' ');
429
+ return tokenize(parts.join(' ')).join(' ');
400
430
  }
401
431
 
402
432
  /**
@@ -498,35 +528,63 @@ function performLocalSearch(
498
528
 
499
529
  const maxScore = Math.max(...scores.filter((s) => s > 0), 1);
500
530
  const queryLower = query.toLowerCase().trim();
531
+ const queryIdentifier = queryTokens.join('');
532
+ const matchesIdentifiers = fields.includes('name');
501
533
 
502
- const results: t.ToolSearchResult[] = [];
534
+ const results: Array<{
535
+ result: t.ToolSearchResult;
536
+ identifierPriority: number;
537
+ }> = [];
503
538
  for (let i = 0; i < tools.length; i++) {
504
- if (scores[i] > 0) {
505
- const { field, snippet } = findMatchedField(
506
- tools[i],
507
- queryTokens,
508
- fields
509
- );
510
- let normalizedScore = Math.min(scores[i] / maxScore, 1.0);
511
-
512
- const baseName = getBaseToolName(tools[i].name).toLowerCase();
513
- if (baseName === queryLower) {
539
+ const score = scores[i];
540
+ const hasSearchScore = Number.isFinite(score) && score > 0;
541
+ let identifierPriority = 0;
542
+ let normalizedScore = hasSearchScore ? Math.min(score / maxScore, 1.0) : 0;
543
+
544
+ if (matchesIdentifiers) {
545
+ const rawBaseName = getBaseToolName(tools[i].name).toLowerCase();
546
+ const rawFullName = tools[i].name.toLowerCase();
547
+ const baseIdentifier = tokenize(rawBaseName).join('');
548
+ const fullIdentifier = tokenize(rawFullName).join('');
549
+
550
+ if (rawFullName === queryLower) {
551
+ identifierPriority = 4;
552
+ normalizedScore = 1.0;
553
+ } else if (rawBaseName === queryLower) {
554
+ identifierPriority = 3;
555
+ normalizedScore = 1.0;
556
+ } else if (
557
+ baseIdentifier === queryIdentifier ||
558
+ fullIdentifier === queryIdentifier
559
+ ) {
560
+ identifierPriority = 2;
514
561
  normalizedScore = 1.0;
515
- } else if (baseName.startsWith(queryLower)) {
562
+ } else if (baseIdentifier.startsWith(queryIdentifier)) {
563
+ identifierPriority = 1;
516
564
  normalizedScore = Math.max(normalizedScore, 0.95);
517
565
  }
566
+ }
567
+
568
+ if (!hasSearchScore && identifierPriority === 0) continue;
518
569
 
519
- results.push({
570
+ const { field, snippet } = findMatchedField(tools[i], queryTokens, fields);
571
+ results.push({
572
+ result: {
520
573
  tool_name: tools[i].name,
521
574
  match_score: normalizedScore,
522
575
  matched_field: field,
523
576
  snippet,
524
- });
525
- }
577
+ },
578
+ identifierPriority,
579
+ });
526
580
  }
527
581
 
528
- results.sort((a, b) => b.match_score - a.match_score);
529
- const topResults = results.slice(0, maxResults);
582
+ results.sort(
583
+ (a, b) =>
584
+ b.identifierPriority - a.identifierPriority ||
585
+ b.result.match_score - a.result.match_score
586
+ );
587
+ const topResults = results.slice(0, maxResults).map(({ result }) => result);
530
588
 
531
589
  return {
532
590
  tool_references: topResults,
@@ -65,13 +65,24 @@ describe('buildBashExecutionToolDescription', () => {
65
65
  ).toBe(StatefulBashExecutionToolDescription);
66
66
  });
67
67
 
68
- it('hedges: usually-persists but may-reset, and only /mnt/data is durable', () => {
68
+ /* Filesystem-tier only: each call runs in a fresh sandbox (new process
69
+ * tree + private /tmp), so background processes are reaped and non-
70
+ * /mnt/data writes are discarded. The description must not promise
71
+ * otherwise. */
72
+ it('promises /mnt/data persistence WITHOUT promising surviving processes or /tmp', () => {
69
73
  const d = StatefulBashExecutionToolDescription;
70
- expect(d).toContain('usually');
71
- expect(d).toContain('may be reset');
74
+ expect(d).toContain('same warm machine');
72
75
  expect(d).toContain('Only /mnt/data is durable');
73
- /* filesystem-tier, not shell-variable-tier */
74
- expect(d).toContain('do not rely on shell variables');
76
+ expect(d).toContain('background processes do NOT survive');
77
+ expect(d).toContain('/tmp');
78
+ });
79
+
80
+ it('never claims /tmp or background processes persist between calls', () => {
81
+ const d = StatefulBashExecutionToolDescription;
82
+ expect(d).not.toContain('files (including /tmp)');
83
+ expect(d).not.toContain(
84
+ 'background processes from earlier calls typically persist'
85
+ );
75
86
  });
76
87
 
77
88
  it('keeps the artifact-path guidance in both variants', () => {
@@ -41,22 +41,33 @@ describe('CodeExecutor stateful description', () => {
41
41
  );
42
42
  });
43
43
 
44
- it('hedges the stateful wording and keeps /mnt/data as the durable store', () => {
44
+ /* Statefulness is filesystem-tier only: the machine is warm across calls,
45
+ * but every execution is a new interpreter process, so in-memory state never
46
+ * carries over. The description must not imply a notebook-style kernel. */
47
+ it('promises filesystem persistence WITHOUT promising a shared runtime', () => {
45
48
  const d = StatefulCodeExecutionToolDescription;
46
- expect(d).toContain('usually share one runtime');
47
- expect(d).toContain('may be reset at any time');
48
- expect(d).toContain('MUST be written to /mnt/data');
49
+ expect(d).toContain('same warm machine');
50
+ expect(d).toContain('/mnt/data');
51
+ expect(d).toContain('NEW process');
52
+ expect(d).toContain('NEVER carry over');
49
53
  expect(d).toContain(CODE_ARTIFACT_PATH_GUIDANCE);
50
54
  });
51
55
 
56
+ it('never claims variables/imports survive between executions', () => {
57
+ const d = StatefulCodeExecutionToolDescription;
58
+ expect(d).not.toContain('share one runtime');
59
+ expect(d).not.toContain('typically still available');
60
+ });
61
+
52
62
  it('adjusts the code-param note per mode', () => {
53
63
  const stateless =
54
64
  buildCodeExecutionToolSchema().properties.code.description;
55
65
  const stateful = buildCodeExecutionToolSchema({ statefulSessions: true })
56
66
  .properties.code.description;
57
67
  expect(stateless).toContain('variables and imports don\'t persist');
58
- expect(stateful).toContain('typically still defined');
59
- expect(stateful).toContain('may reset between calls');
68
+ expect(stateful).toContain('do NOT carry over');
69
+ expect(stateful).toContain('/mnt/data');
70
+ expect(stateful).not.toContain('typically still defined');
60
71
  });
61
72
  });
62
73