@librechat/agents 3.2.39 → 3.2.42

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 (59) hide show
  1. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  2. package/dist/cjs/graphs/Graph.cjs +4 -1
  3. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  4. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +4 -3
  5. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs.map +1 -1
  6. package/dist/cjs/llm/bedrock/index.cjs +9 -1
  7. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  8. package/dist/cjs/main.cjs +4 -0
  9. package/dist/cjs/messages/cache.cjs +21 -0
  10. package/dist/cjs/messages/cache.cjs.map +1 -1
  11. package/dist/cjs/tools/ReadFile.cjs +2 -2
  12. package/dist/cjs/tools/ReadFile.cjs.map +1 -1
  13. package/dist/cjs/tools/cloudflare/CloudflareProgrammaticToolCalling.cjs +13 -13
  14. package/dist/cjs/tools/cloudflare/CloudflareProgrammaticToolCalling.cjs.map +1 -1
  15. package/dist/cjs/tools/cloudflare/CloudflareSandboxExecutionEngine.cjs +87 -7
  16. package/dist/cjs/tools/cloudflare/CloudflareSandboxExecutionEngine.cjs.map +1 -1
  17. package/dist/cjs/tools/local/LocalCodingTools.cjs +11 -11
  18. package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -1
  19. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  20. package/dist/esm/graphs/Graph.mjs +5 -2
  21. package/dist/esm/graphs/Graph.mjs.map +1 -1
  22. package/dist/esm/hooks/createWorkspacePolicyHook.mjs +4 -3
  23. package/dist/esm/hooks/createWorkspacePolicyHook.mjs.map +1 -1
  24. package/dist/esm/llm/bedrock/index.mjs +10 -2
  25. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  26. package/dist/esm/main.mjs +3 -3
  27. package/dist/esm/messages/cache.mjs +21 -1
  28. package/dist/esm/messages/cache.mjs.map +1 -1
  29. package/dist/esm/tools/ReadFile.mjs +2 -2
  30. package/dist/esm/tools/ReadFile.mjs.map +1 -1
  31. package/dist/esm/tools/cloudflare/CloudflareProgrammaticToolCalling.mjs +14 -14
  32. package/dist/esm/tools/cloudflare/CloudflareProgrammaticToolCalling.mjs.map +1 -1
  33. package/dist/esm/tools/cloudflare/CloudflareSandboxExecutionEngine.mjs +85 -8
  34. package/dist/esm/tools/cloudflare/CloudflareSandboxExecutionEngine.mjs.map +1 -1
  35. package/dist/esm/tools/local/LocalCodingTools.mjs +11 -11
  36. package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -1
  37. package/dist/types/llm/bedrock/index.d.ts +7 -0
  38. package/dist/types/messages/cache.d.ts +17 -0
  39. package/dist/types/tools/ReadFile.d.ts +4 -4
  40. package/dist/types/tools/cloudflare/CloudflareSandboxExecutionEngine.d.ts +44 -0
  41. package/package.json +1 -1
  42. package/src/agents/AgentContext.ts +2 -0
  43. package/src/graphs/Graph.ts +17 -5
  44. package/src/hooks/__tests__/createWorkspacePolicyHook.test.ts +12 -12
  45. package/src/hooks/createWorkspacePolicyHook.ts +7 -6
  46. package/src/llm/bedrock/index.ts +18 -2
  47. package/src/llm/bedrock/llm.spec.ts +97 -0
  48. package/src/messages/cache.test.ts +31 -0
  49. package/src/messages/cache.ts +25 -0
  50. package/src/tools/ReadFile.ts +2 -2
  51. package/src/tools/__tests__/CloudflareSandboxExecution.test.ts +131 -0
  52. package/src/tools/__tests__/LocalExecutionTools.test.ts +25 -25
  53. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +5 -5
  54. package/src/tools/__tests__/ReadFile.test.ts +3 -3
  55. package/src/tools/__tests__/ToolNode.session.test.ts +2 -2
  56. package/src/tools/__tests__/workspaceSeam.test.ts +2 -2
  57. package/src/tools/cloudflare/CloudflareProgrammaticToolCalling.ts +18 -13
  58. package/src/tools/cloudflare/CloudflareSandboxExecutionEngine.ts +165 -9
  59. package/src/tools/local/LocalCodingTools.ts +14 -14
@@ -131,6 +131,135 @@ function isInSandboxTimeoutExit(exitCode: number | null): boolean {
131
131
  return exitCode === 124 || exitCode === 137;
132
132
  }
133
133
 
134
+ /**
135
+ * Client-side backstop timeout for a `sandbox.exec()` await: a few seconds beyond
136
+ * the exec's own `timeout` option, so a stalled exec that never honors `timeout`
137
+ * still can't outlast this.
138
+ */
139
+ export function clientExecTimeoutMs(timeoutMs: number): number {
140
+ return outerTimeoutMs(timeoutMs) + 5000;
141
+ }
142
+
143
+ /**
144
+ * Bound a `sandbox.exec()` await with a CLIENT-SIDE timeout.
145
+ *
146
+ * The native Cloudflare Sandbox Durable Object `exec()` is effectively
147
+ * uncancellable from the host: `ExecOptions` has no `signal` (so
148
+ * `supportsExecSignal` is false for the native transport), and its `timeout`
149
+ * option is not reliably enforced when the container/RPC itself stalls — while
150
+ * the in-sandbox `timeout(1)` wrapper only bounds a command that is actually
151
+ * running. So a stalled exec (an unresponsive/cold container) otherwise hangs
152
+ * until the host's run-level abort, burning the whole run budget on one tool
153
+ * call. This race guarantees the host await settles within `timeoutMs`
154
+ * regardless of the transport.
155
+ *
156
+ * On timeout the underlying `exec` promise may keep running in the DO (a
157
+ * native-DO exec cannot be truly cancelled), so its late settlement is swallowed
158
+ * to avoid an unhandled rejection.
159
+ */
160
+ export async function withClientTimeout<T>(
161
+ exec: Promise<T>,
162
+ timeoutMs: number,
163
+ label: string,
164
+ options: {
165
+ /**
166
+ * Detach the backstop timer from the event loop. Use ONLY when something else
167
+ * already settles the caller (e.g. the spawn path, where spawnLocalProcess's
168
+ * own timer resolves the child). The awaited direct-exec paths must leave it
169
+ * REF'd so the timeout is guaranteed to fire even if nothing else is pending.
170
+ */
171
+ unref?: boolean;
172
+ /** Invoked when the client timeout fires — e.g. abort a signal-aware exec. */
173
+ onTimeout?: () => void;
174
+ } = {}
175
+ ): Promise<T> {
176
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
177
+ return exec;
178
+ }
179
+ // Swallow a late rejection from the losing promise after the race settles.
180
+ exec.catch(() => undefined);
181
+ let timer: ReturnType<typeof setTimeout> | undefined;
182
+ try {
183
+ return await Promise.race([
184
+ exec,
185
+ new Promise<never>((_resolve, reject) => {
186
+ timer = setTimeout(() => {
187
+ // Reject FIRST so this client-timeout message reliably wins the race;
188
+ // only then abort a signal-aware exec, whose resulting AbortError must
189
+ // not surface to the caller instead of the timeout.
190
+ reject(
191
+ new Error(
192
+ `${label} exceeded ${timeoutMs}ms client-side timeout (sandbox exec did not return)`
193
+ )
194
+ );
195
+ options.onTimeout?.();
196
+ }, timeoutMs);
197
+ if (options.unref === true) {
198
+ (timer as { unref?: () => void } | undefined)?.unref?.();
199
+ }
200
+ }),
201
+ ]);
202
+ } finally {
203
+ if (timer !== undefined) {
204
+ clearTimeout(timer);
205
+ }
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Run `sandbox.exec()` bounded by a client-side timeout, and — for signal-aware
211
+ * transports (e.g. the HTTP bridge, `supportsExecSignal === true`) — abort the
212
+ * underlying exec when the timeout fires instead of merely abandoning it. The
213
+ * native DO transport ignores `signal`, so it only gets the timeout. Leaves the
214
+ * backstop timer ref'd (this is an awaited direct-exec path).
215
+ */
216
+ export async function execWithClientTimeout(
217
+ sandbox: t.CloudflareSandboxRuntime,
218
+ command: string,
219
+ options: t.CloudflareSandboxExecOptions,
220
+ timeoutMs: number,
221
+ label: string,
222
+ runOptions: { unref?: boolean } = {}
223
+ ): Promise<t.CloudflareSandboxExecResult> {
224
+ const controller = new AbortController();
225
+ const execOptions: t.CloudflareSandboxExecOptions = { ...options };
226
+ const callerSignal = options.signal;
227
+ let onCallerAbort: (() => void) | undefined;
228
+ if (sandbox.supportsExecSignal === true) {
229
+ // Compose the caller's signal (e.g. run/user cancellation) with our timeout
230
+ // controller so EITHER source cancels the exec — don't clobber the caller's.
231
+ if (callerSignal != null) {
232
+ if (callerSignal.aborted) {
233
+ controller.abort();
234
+ } else {
235
+ onCallerAbort = (): void => controller.abort();
236
+ callerSignal.addEventListener('abort', onCallerAbort, { once: true });
237
+ }
238
+ }
239
+ execOptions.signal = controller.signal;
240
+ } else if ('signal' in execOptions) {
241
+ // Native DO RPC cannot consume an AbortSignal (and would fail to clone it).
242
+ // Strip any caller-provided one so the spread above can't reintroduce it.
243
+ delete execOptions.signal;
244
+ }
245
+ try {
246
+ return await withClientTimeout(
247
+ sandbox.exec(command, execOptions),
248
+ timeoutMs,
249
+ label,
250
+ {
251
+ unref: runOptions.unref,
252
+ onTimeout: () => controller.abort(),
253
+ }
254
+ );
255
+ } finally {
256
+ // Don't leave a listener attached to a long-lived/shared caller signal.
257
+ if (onCallerAbort != null && callerSignal != null) {
258
+ callerSignal.removeEventListener('abort', onCallerAbort);
259
+ }
260
+ }
261
+ }
262
+
134
263
  function truncateOutput(value: string, maxChars: number): string {
135
264
  if (maxChars <= 0 || value.length <= maxChars) {
136
265
  return value;
@@ -466,7 +595,14 @@ function createCloudflareSpawn(
466
595
  execOptions.signal = abortController.signal;
467
596
  }
468
597
  try {
469
- const result = await ctx.sandbox.exec(timedCommand, execOptions);
598
+ const result = await withClientTimeout(
599
+ ctx.sandbox.exec(timedCommand, execOptions),
600
+ clientExecTimeoutMs(timeoutMs),
601
+ 'cloudflare sandbox exec',
602
+ // spawnLocalProcess's own timer already resolves the child, so this
603
+ // backstop may safely detach; abort the (signal-aware) exec on timeout.
604
+ { unref: true, onTimeout: () => abortController.abort() }
605
+ );
470
606
  if (isClosed()) {
471
607
  return;
472
608
  }
@@ -551,13 +687,16 @@ export async function executeCloudflareBash(
551
687
  args.length > 0
552
688
  ? `${ctx.shell} -lc ${quote(command)} -- ${args.map(quote).join(' ')}`
553
689
  : `${ctx.shell} -lc ${quote(command)}`;
554
- const result = await ctx.sandbox.exec(
690
+ const result = await execWithClientTimeout(
691
+ ctx.sandbox,
555
692
  withInSandboxTimeout(shellCommand, ctx.timeoutMs),
556
693
  {
557
694
  cwd: ctx.workspaceRoot,
558
695
  env: ctx.env,
559
696
  timeout: outerTimeoutMs(ctx.timeoutMs),
560
- }
697
+ },
698
+ clientExecTimeoutMs(ctx.timeoutMs),
699
+ 'cloudflare sandbox bash exec'
561
700
  );
562
701
  return {
563
702
  stdout: truncateOutput(result.stdout, ctx.maxOutputChars),
@@ -703,15 +842,20 @@ export async function executeCloudflareCode(
703
842
  }
704
843
  );
705
844
  }
845
+ let execSucceeded = false;
706
846
  try {
707
- const result = await ctx.sandbox.exec(
847
+ const result = await execWithClientTimeout(
848
+ ctx.sandbox,
708
849
  withInSandboxTimeout(runtime.command, ctx.timeoutMs),
709
850
  {
710
851
  cwd: ctx.workspaceRoot,
711
852
  env: ctx.env,
712
853
  timeout: outerTimeoutMs(ctx.timeoutMs),
713
- }
854
+ },
855
+ clientExecTimeoutMs(ctx.timeoutMs),
856
+ 'cloudflare sandbox code-exec'
714
857
  );
858
+ execSucceeded = true;
715
859
  return {
716
860
  stdout: truncateOutput(result.stdout, ctx.maxOutputChars),
717
861
  stderr: truncateOutput(result.stderr, ctx.maxOutputChars),
@@ -719,13 +863,25 @@ export async function executeCloudflareCode(
719
863
  timedOut: isInSandboxTimeoutExit(result.exitCode),
720
864
  };
721
865
  } finally {
722
- await ctx.sandbox
723
- .exec(`rm -rf ${quote(tempDir)}`, {
866
+ // After a normal run, AWAIT cleanup so the temp dir is gone before returning.
867
+ // After a stalled/failed run, detach it (unref'd) so we don't pile a second
868
+ // client timeout onto the caller's latency; cleanup still runs best-effort.
869
+ const detach = !execSucceeded;
870
+ const cleanup = execWithClientTimeout(
871
+ ctx.sandbox,
872
+ `rm -rf ${quote(tempDir)}`,
873
+ {
724
874
  cwd: ctx.workspaceRoot,
725
875
  env: ctx.env,
726
876
  timeout: 10000,
727
- })
728
- .catch(() => undefined);
877
+ },
878
+ clientExecTimeoutMs(10000),
879
+ 'cloudflare sandbox cleanup',
880
+ { unref: detach }
881
+ ).catch(() => undefined);
882
+ if (!detach) {
883
+ await cleanup;
884
+ }
729
885
  }
730
886
  }
731
887
 
@@ -49,7 +49,7 @@ export const LocalListDirectoryToolName = Constants.LIST_DIRECTORY;
49
49
  export const LocalReadFileToolSchema: t.JsonSchemaType = {
50
50
  type: 'object',
51
51
  properties: {
52
- file_path: {
52
+ path: {
53
53
  type: 'string',
54
54
  description:
55
55
  'Path to a local file, relative to the configured cwd unless absolute paths are allowed.',
@@ -63,13 +63,13 @@ export const LocalReadFileToolSchema: t.JsonSchemaType = {
63
63
  description: 'Optional maximum number of lines to return.',
64
64
  },
65
65
  },
66
- required: ['file_path'],
66
+ required: ['path'],
67
67
  };
68
68
 
69
69
  export const LocalWriteFileToolSchema: t.JsonSchemaType = {
70
70
  type: 'object',
71
71
  properties: {
72
- file_path: {
72
+ path: {
73
73
  type: 'string',
74
74
  description:
75
75
  'Path to write, relative to the configured cwd unless absolute paths are allowed.',
@@ -79,13 +79,13 @@ export const LocalWriteFileToolSchema: t.JsonSchemaType = {
79
79
  description: 'Complete file contents to write.',
80
80
  },
81
81
  },
82
- required: ['file_path', 'content'],
82
+ required: ['path', 'content'],
83
83
  };
84
84
 
85
85
  export const LocalEditFileToolSchema: t.JsonSchemaType = {
86
86
  type: 'object',
87
87
  properties: {
88
- file_path: {
88
+ path: {
89
89
  type: 'string',
90
90
  description:
91
91
  'Path to edit, relative to the configured cwd unless absolute paths are allowed.',
@@ -112,7 +112,7 @@ export const LocalEditFileToolSchema: t.JsonSchemaType = {
112
112
  },
113
113
  },
114
114
  },
115
- required: ['file_path'],
115
+ required: ['path'],
116
116
  };
117
117
 
118
118
  export const LocalGrepSearchToolSchema: t.JsonSchemaType = {
@@ -391,18 +391,18 @@ export function createLocalReadFileTool(
391
391
  return tool(
392
392
  async (rawInput) => {
393
393
  const input = rawInput as {
394
- file_path: string;
394
+ path: string;
395
395
  offset?: number;
396
396
  limit?: number;
397
397
  };
398
398
  const path = await resolveWorkspacePathSafe(
399
- input.file_path,
399
+ input.path,
400
400
  config,
401
401
  'read'
402
402
  );
403
403
  const fileStat = await fs.stat(path);
404
404
  if (!fileStat.isFile()) {
405
- throw new Error(`Path is not a file: ${input.file_path}`);
405
+ throw new Error(`Path is not a file: ${input.path}`);
406
406
  }
407
407
  const maxBytes = Math.max(
408
408
  config.maxReadBytes ?? DEFAULT_MAX_READ_BYTES,
@@ -514,12 +514,12 @@ export function createLocalWriteFileTool(
514
514
  const fs = getWorkspaceFS(config);
515
515
  return tool(
516
516
  async (rawInput) => {
517
- const input = rawInput as { file_path: string; content: string };
517
+ const input = rawInput as { path: string; content: string };
518
518
  if (config.readOnly === true) {
519
519
  throw new Error('write_file is blocked in read-only local mode.');
520
520
  }
521
521
  const path = await resolveWorkspacePathSafe(
522
- input.file_path,
522
+ input.path,
523
523
  config,
524
524
  'write'
525
525
  );
@@ -598,7 +598,7 @@ export function createLocalEditFileTool(
598
598
  return tool(
599
599
  async (rawInput) => {
600
600
  const input = rawInput as {
601
- file_path: string;
601
+ path: string;
602
602
  old_text?: string;
603
603
  new_text?: string;
604
604
  edits?: Array<{ old_text?: string; new_text?: string }>;
@@ -612,7 +612,7 @@ export function createLocalEditFileTool(
612
612
  }
613
613
 
614
614
  const path = await resolveWorkspacePathSafe(
615
- input.file_path,
615
+ input.path,
616
616
  config,
617
617
  'write'
618
618
  );
@@ -627,7 +627,7 @@ export function createLocalEditFileTool(
627
627
  const match = locateEdit(next, edit.oldText);
628
628
  if (match == null) {
629
629
  throw new Error(
630
- `Edit ${i + 1}/${edits.length}: could not locate old_text in ${input.file_path}. ` +
630
+ `Edit ${i + 1}/${edits.length}: could not locate old_text in ${input.path}. ` +
631
631
  'Tried exact, line-trimmed, whitespace-normalized, and indentation-flexible matching. ' +
632
632
  'Re-read the file and copy the literal lines.'
633
633
  );