@librechat/agents 3.1.92 → 3.1.94

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.
@@ -107,26 +107,105 @@ describe('Cloudflare sandbox execution backend', () => {
107
107
  expect(listPaths).toEqual(['/workspace']);
108
108
  });
109
109
 
110
- it('aborts remote exec when the local timeout kills the spawn wrapper', async () => {
111
- let signal: AbortSignal | undefined;
110
+ it('does not pass AbortSignal to Cloudflare spawn exec options', async () => {
111
+ let resolveExecCalled!: () => void;
112
+ const execCalled = new Promise<void>((resolve) => {
113
+ resolveExecCalled = resolve;
114
+ });
115
+ let receivedOptions: t.CloudflareSandboxExecOptions | undefined;
116
+ const sandbox = createRuntime({
117
+ exec: (_command, options) => {
118
+ receivedOptions = options;
119
+ resolveExecCalled();
120
+ return new Promise<t.CloudflareSandboxExecResult>(() => undefined);
121
+ },
122
+ });
123
+ const config = createCloudflareLocalExecutionConfig({
124
+ sandbox,
125
+ timeoutMs: 50,
126
+ workspaceRoot: '/workspace',
127
+ });
128
+
129
+ const resultPromise = spawnLocalProcess(
130
+ 'bash',
131
+ ['-lc', 'sleep 10'],
132
+ config
133
+ );
134
+ await execCalled;
135
+ const result = await resultPromise;
136
+
137
+ expect(receivedOptions).not.toHaveProperty('signal');
138
+ expect(result.timedOut).toBe(true);
139
+ expect(result.exitCode).toBe(143);
140
+ });
141
+
142
+ it('passes AbortSignal to signal-aware runtimes and aborts it on kill', async () => {
143
+ let resolveExecCalled!: () => void;
144
+ const execCalled = new Promise<void>((resolve) => {
145
+ resolveExecCalled = resolve;
146
+ });
147
+ let receivedSignal: AbortSignal | undefined;
148
+ let abortEvents = 0;
112
149
  const sandbox = createRuntime({
113
- exec: (_command, options) =>
114
- new Promise<t.CloudflareSandboxExecResult>((_resolve, reject) => {
115
- signal = options?.signal;
116
- signal?.addEventListener('abort', () => reject(new Error('aborted')));
117
- }),
150
+ supportsExecSignal: true,
151
+ exec: (_command, options) => {
152
+ receivedSignal = options?.signal;
153
+ receivedSignal?.addEventListener('abort', () => {
154
+ abortEvents += 1;
155
+ });
156
+ resolveExecCalled();
157
+ return new Promise<t.CloudflareSandboxExecResult>(() => undefined);
158
+ },
118
159
  });
119
160
  const config = createCloudflareLocalExecutionConfig({
120
161
  sandbox,
162
+ timeoutMs: 50,
163
+ workspaceRoot: '/workspace',
164
+ });
165
+
166
+ const resultPromise = spawnLocalProcess(
167
+ 'bash',
168
+ ['-lc', 'sleep 10'],
169
+ config
170
+ );
171
+ await execCalled;
172
+ const result = await resultPromise;
173
+
174
+ expect(receivedSignal).toBeDefined();
175
+ expect(receivedSignal?.aborted).toBe(true);
176
+ expect(abortEvents).toBe(1);
177
+ expect(result.timedOut).toBe(true);
178
+ expect(result.exitCode).toBe(143);
179
+ });
180
+
181
+ it('does not start remote exec when killed before async sandbox resolution finishes', async () => {
182
+ let execCalls = 0;
183
+ let resolveSandbox!: (runtime: t.CloudflareSandboxRuntime) => void;
184
+ const sandboxPromise = new Promise<t.CloudflareSandboxRuntime>(
185
+ (resolve) => {
186
+ resolveSandbox = resolve;
187
+ }
188
+ );
189
+ const config = createCloudflareLocalExecutionConfig({
190
+ sandbox: () => sandboxPromise,
121
191
  timeoutMs: 10,
122
192
  workspaceRoot: '/workspace',
123
193
  });
124
194
 
125
195
  const result = await spawnLocalProcess('bash', ['-lc', 'sleep 10'], config);
196
+ resolveSandbox(
197
+ createRuntime({
198
+ exec: async () => {
199
+ execCalls += 1;
200
+ return { exitCode: 0, stdout: '', stderr: '' };
201
+ },
202
+ })
203
+ );
204
+ await new Promise((resolve) => setTimeout(resolve, 0));
126
205
 
127
- expect(signal?.aborted).toBe(true);
128
206
  expect(result.timedOut).toBe(true);
129
207
  expect(result.exitCode).toBe(143);
208
+ expect(execCalls).toBe(0);
130
209
  });
131
210
 
132
211
  it('memoizes sandbox factory results per config object', async () => {
@@ -86,6 +86,135 @@ describe('ProgrammaticToolCalling', () => {
86
86
  });
87
87
  });
88
88
 
89
+ it('parses JSON-string inputs before invoking structured tools', async () => {
90
+ const toolCalls: t.PTCToolCall[] = [
91
+ {
92
+ id: 'call_001',
93
+ name: 'get_weather',
94
+ input: '{"city":"San Francisco"}',
95
+ },
96
+ ];
97
+
98
+ const results = await executeTools(toolCalls, toolMap);
99
+
100
+ expect(results).toHaveLength(1);
101
+ expect(results[0].call_id).toBe('call_001');
102
+ expect(results[0].is_error).toBe(false);
103
+ expect(results[0].result).toEqual({
104
+ temperature: 65,
105
+ condition: 'Foggy',
106
+ });
107
+ });
108
+
109
+ it('preserves JSON-looking strings for string-input tools', async () => {
110
+ const invoke = jest.fn<
111
+ (_input: unknown, _config: unknown) => Promise<unknown>
112
+ >(async (input) => input);
113
+ const customTool = {
114
+ name: 'string_tool',
115
+ schema: { type: 'string' },
116
+ invoke,
117
+ } as unknown as t.GenericTool;
118
+ const customToolMap: t.ToolMap = new Map([['string_tool', customTool]]);
119
+ const toolCalls: t.PTCToolCall[] = [
120
+ {
121
+ id: 'call_001',
122
+ name: 'string_tool',
123
+ input: '{"raw":true}',
124
+ },
125
+ ];
126
+
127
+ const results = await executeTools(toolCalls, customToolMap);
128
+
129
+ expect(results[0].is_error).toBe(false);
130
+ expect(results[0].result).toBe('{"raw":true}');
131
+ expect(invoke).toHaveBeenCalledWith('{"raw":true}', {
132
+ metadata: { [Constants.PROGRAMMATIC_TOOL_CALLING]: true },
133
+ });
134
+ });
135
+
136
+ it('stringifies object inputs before invoking string-input tools', async () => {
137
+ const invoke = jest.fn<
138
+ (_input: unknown, _config: unknown) => Promise<unknown>
139
+ >(async (input) => input);
140
+ const customTool = {
141
+ name: 'string_tool',
142
+ schema: { type: 'string' },
143
+ invoke,
144
+ } as unknown as t.GenericTool;
145
+ const customToolMap: t.ToolMap = new Map([['string_tool', customTool]]);
146
+ const toolCalls: t.PTCToolCall[] = [
147
+ {
148
+ id: 'call_001',
149
+ name: 'string_tool',
150
+ input: { raw: true },
151
+ },
152
+ ];
153
+
154
+ const results = await executeTools(toolCalls, customToolMap);
155
+
156
+ expect(results[0].is_error).toBe(false);
157
+ expect(results[0].result).toBe('{"raw":true}');
158
+ expect(invoke).toHaveBeenCalledWith('{"raw":true}', {
159
+ metadata: { [Constants.PROGRAMMATIC_TOOL_CALLING]: true },
160
+ });
161
+ });
162
+
163
+ it('preserves object inputs for mixed object-or-string schemas', async () => {
164
+ const invoke = jest.fn<
165
+ (_input: unknown, _config: unknown) => Promise<unknown>
166
+ >(async (input) => input);
167
+ const customTool = {
168
+ name: 'mixed_tool',
169
+ schema: { type: ['object', 'string'] },
170
+ invoke,
171
+ } as unknown as t.GenericTool;
172
+ const customToolMap: t.ToolMap = new Map([['mixed_tool', customTool]]);
173
+ const input = { raw: true };
174
+ const toolCalls: t.PTCToolCall[] = [
175
+ {
176
+ id: 'call_001',
177
+ name: 'mixed_tool',
178
+ input,
179
+ },
180
+ ];
181
+
182
+ const results = await executeTools(toolCalls, customToolMap);
183
+
184
+ expect(results[0].is_error).toBe(false);
185
+ expect(results[0].result).toBe(input);
186
+ expect(invoke).toHaveBeenCalledWith(input, {
187
+ metadata: { [Constants.PROGRAMMATIC_TOOL_CALLING]: true },
188
+ });
189
+ });
190
+
191
+ it('preserves JSON-looking strings for mixed object-or-string schemas', async () => {
192
+ const invoke = jest.fn<
193
+ (_input: unknown, _config: unknown) => Promise<unknown>
194
+ >(async (input) => input);
195
+ const customTool = {
196
+ name: 'mixed_tool',
197
+ schema: { type: ['object', 'string'] },
198
+ invoke,
199
+ } as unknown as t.GenericTool;
200
+ const customToolMap: t.ToolMap = new Map([['mixed_tool', customTool]]);
201
+ const toolCalls: t.PTCToolCall[] = [
202
+ {
203
+ id: 'call_001',
204
+ name: 'mixed_tool',
205
+ input: '{"raw":true}',
206
+ },
207
+ ];
208
+
209
+ const results = await executeTools(toolCalls, customToolMap);
210
+
211
+ expect(results[0].is_error).toBe(false);
212
+ expect(results[0].result).toBe('{"raw":true}');
213
+ expect(invoke).toHaveBeenCalledWith('{"raw":true}', {
214
+ metadata: { [Constants.PROGRAMMATIC_TOOL_CALLING]: true },
215
+ });
216
+ });
217
+
89
218
  it('marks bash PTC inner tool invocations with bash metadata', async () => {
90
219
  const invoke = jest.fn<
91
220
  (_input: unknown, _config: unknown) => Promise<{ ok: boolean }>
@@ -469,6 +469,7 @@ export function createCloudflareBridgeRuntime(
469
469
  }
470
470
 
471
471
  return {
472
+ supportsExecSignal: true,
472
473
  getSandboxId,
473
474
  exec,
474
475
  readFile,
@@ -400,8 +400,8 @@ function createCloudflareSpawn(
400
400
  return (command, args, options) => {
401
401
  const stdout = new PassThrough();
402
402
  const stderr = new PassThrough();
403
- const abortController = new AbortController();
404
403
  const child = new EventEmitter() as ChildProcessWithoutNullStreams;
404
+ const abortController = new AbortController();
405
405
  const state = { closed: false };
406
406
  const closeOnce = (
407
407
  exitCode: number | null,
@@ -451,13 +451,19 @@ function createCloudflareSpawn(
451
451
  const timedCommand = withInSandboxTimeout(rendered, timeoutMs);
452
452
  const cwd =
453
453
  options.cwd == null ? ctx.workspaceRoot : options.cwd.toString();
454
+ if (state.closed) {
455
+ return;
456
+ }
457
+ const execOptions: t.CloudflareSandboxExecOptions = {
458
+ cwd,
459
+ env: ctx.env,
460
+ timeout: outerTimeoutMs(timeoutMs),
461
+ };
462
+ if (ctx.sandbox.supportsExecSignal === true) {
463
+ execOptions.signal = abortController.signal;
464
+ }
454
465
  try {
455
- const result = await ctx.sandbox.exec(timedCommand, {
456
- cwd,
457
- env: ctx.env,
458
- timeout: outerTimeoutMs(timeoutMs),
459
- signal: abortController.signal,
460
- });
466
+ const result = await ctx.sandbox.exec(timedCommand, execOptions);
461
467
  if (state.closed) {
462
468
  return;
463
469
  }
@@ -853,6 +853,12 @@ export type CloudflareSandboxListFilesResult =
853
853
  };
854
854
 
855
855
  export interface CloudflareSandboxRuntime {
856
+ /**
857
+ * True when this runtime can consume AbortSignal values in exec options.
858
+ * Native Cloudflare Sandbox Durable Object RPC cannot clone AbortSignal,
859
+ * but HTTP bridge runtimes can use it to abort the underlying fetch.
860
+ */
861
+ supportsExecSignal?: boolean;
856
862
  exec(
857
863
  command: string,
858
864
  options?: CloudflareSandboxExecOptions
@@ -1019,9 +1025,9 @@ export type PTCToolCall = {
1019
1025
  id: string;
1020
1026
  /** Tool name */
1021
1027
  name: string;
1022
- /** Input parameters */
1028
+ /** Input parameters. Some bridges may serialize object input as JSON text. */
1023
1029
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1024
- input: Record<string, any>;
1030
+ input: Record<string, any> | string;
1025
1031
  };
1026
1032
 
1027
1033
  /**