@librechat/agents 3.2.41 → 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 (37) 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/llm/bedrock/index.cjs +9 -1
  5. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  6. package/dist/cjs/main.cjs +4 -0
  7. package/dist/cjs/messages/cache.cjs +21 -0
  8. package/dist/cjs/messages/cache.cjs.map +1 -1
  9. package/dist/cjs/tools/cloudflare/CloudflareProgrammaticToolCalling.cjs +2 -2
  10. package/dist/cjs/tools/cloudflare/CloudflareProgrammaticToolCalling.cjs.map +1 -1
  11. package/dist/cjs/tools/cloudflare/CloudflareSandboxExecutionEngine.cjs +87 -7
  12. package/dist/cjs/tools/cloudflare/CloudflareSandboxExecutionEngine.cjs.map +1 -1
  13. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  14. package/dist/esm/graphs/Graph.mjs +5 -2
  15. package/dist/esm/graphs/Graph.mjs.map +1 -1
  16. package/dist/esm/llm/bedrock/index.mjs +10 -2
  17. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  18. package/dist/esm/main.mjs +3 -3
  19. package/dist/esm/messages/cache.mjs +21 -1
  20. package/dist/esm/messages/cache.mjs.map +1 -1
  21. package/dist/esm/tools/cloudflare/CloudflareProgrammaticToolCalling.mjs +3 -3
  22. package/dist/esm/tools/cloudflare/CloudflareProgrammaticToolCalling.mjs.map +1 -1
  23. package/dist/esm/tools/cloudflare/CloudflareSandboxExecutionEngine.mjs +85 -8
  24. package/dist/esm/tools/cloudflare/CloudflareSandboxExecutionEngine.mjs.map +1 -1
  25. package/dist/types/llm/bedrock/index.d.ts +7 -0
  26. package/dist/types/messages/cache.d.ts +17 -0
  27. package/dist/types/tools/cloudflare/CloudflareSandboxExecutionEngine.d.ts +44 -0
  28. package/package.json +1 -1
  29. package/src/agents/AgentContext.ts +2 -0
  30. package/src/graphs/Graph.ts +17 -5
  31. package/src/llm/bedrock/index.ts +18 -2
  32. package/src/llm/bedrock/llm.spec.ts +97 -0
  33. package/src/messages/cache.test.ts +31 -0
  34. package/src/messages/cache.ts +25 -0
  35. package/src/tools/__tests__/CloudflareSandboxExecution.test.ts +131 -0
  36. package/src/tools/cloudflare/CloudflareProgrammaticToolCalling.ts +7 -2
  37. package/src/tools/cloudflare/CloudflareSandboxExecutionEngine.ts +165 -9
@@ -105,6 +105,13 @@ export declare class CustomChatBedrockConverse extends ChatBedrockConverse {
105
105
  * Service tier for model invocation.
106
106
  */
107
107
  serviceTier?: ServiceTierType;
108
+ /**
109
+ * The configured model id, captured at construction so it survives the
110
+ * temporary `this.model` swap to an application-inference-profile ARN during
111
+ * generation. Used to gate the Bedrock tool cache point to Claude models
112
+ * (see {@link supportsBedrockToolCache}).
113
+ */
114
+ private readonly cacheModelId;
108
115
  constructor(fields?: CustomChatBedrockConverseInput);
109
116
  static lc_name(): string;
110
117
  /**
@@ -47,6 +47,23 @@ type BedrockCachePoint = {
47
47
  * legacy default), `'1h'` adds the extended-cache `ttl`.
48
48
  */
49
49
  export declare function buildBedrockCachePoint(ttl?: PromptCacheTtl): BedrockCachePoint;
50
+ /**
51
+ * A `cachePoint` under `toolConfig.tools` is only accepted on Anthropic Claude
52
+ * models. Amazon Nova rejects it outright — `Malformed input request:
53
+ * #/toolConfig/tools/0: extraneous key [cachePoint] is not permitted` — even
54
+ * though it accepts `cachePoint` in `system` and `messages` (verified live:
55
+ * Nova returns HTTP 200 with real `cacheWriteInputTokens` for both). Per the AWS
56
+ * prompt-caching docs the support table lists `tools` for Claude only.
57
+ *
58
+ * Gate ONLY the tool checkpoint on this, so a `promptCache: true` config on Nova
59
+ * keeps its valid message/system caching instead of either 400-ing (tool point)
60
+ * or losing caching entirely. This is a capability gate (the key is rejected
61
+ * outright), distinct from the TTL value which Bedrock downgrades gracefully.
62
+ *
63
+ * @see https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html
64
+ * @see https://github.com/danny-avila/LibreChat/issues/13838
65
+ */
66
+ export declare function supportsBedrockToolCache(model: string | undefined | null): boolean;
50
67
  /**
51
68
  * Clones a message with new content. For LangChain BaseMessage instances,
52
69
  * constructs a proper class instance so that `instanceof` checks are preserved
@@ -8,6 +8,50 @@ type SpawnResult = {
8
8
  };
9
9
  export declare function getCloudflareWorkspaceRoot(config?: t.CloudflareSandboxExecutionConfig): string;
10
10
  export declare function resolveCloudflareSandbox(config: t.CloudflareSandboxExecutionConfig): Promise<t.CloudflareSandboxRuntime>;
11
+ /**
12
+ * Client-side backstop timeout for a `sandbox.exec()` await: a few seconds beyond
13
+ * the exec's own `timeout` option, so a stalled exec that never honors `timeout`
14
+ * still can't outlast this.
15
+ */
16
+ export declare function clientExecTimeoutMs(timeoutMs: number): number;
17
+ /**
18
+ * Bound a `sandbox.exec()` await with a CLIENT-SIDE timeout.
19
+ *
20
+ * The native Cloudflare Sandbox Durable Object `exec()` is effectively
21
+ * uncancellable from the host: `ExecOptions` has no `signal` (so
22
+ * `supportsExecSignal` is false for the native transport), and its `timeout`
23
+ * option is not reliably enforced when the container/RPC itself stalls — while
24
+ * the in-sandbox `timeout(1)` wrapper only bounds a command that is actually
25
+ * running. So a stalled exec (an unresponsive/cold container) otherwise hangs
26
+ * until the host's run-level abort, burning the whole run budget on one tool
27
+ * call. This race guarantees the host await settles within `timeoutMs`
28
+ * regardless of the transport.
29
+ *
30
+ * On timeout the underlying `exec` promise may keep running in the DO (a
31
+ * native-DO exec cannot be truly cancelled), so its late settlement is swallowed
32
+ * to avoid an unhandled rejection.
33
+ */
34
+ export declare function withClientTimeout<T>(exec: Promise<T>, timeoutMs: number, label: string, options?: {
35
+ /**
36
+ * Detach the backstop timer from the event loop. Use ONLY when something else
37
+ * already settles the caller (e.g. the spawn path, where spawnLocalProcess's
38
+ * own timer resolves the child). The awaited direct-exec paths must leave it
39
+ * REF'd so the timeout is guaranteed to fire even if nothing else is pending.
40
+ */
41
+ unref?: boolean;
42
+ /** Invoked when the client timeout fires — e.g. abort a signal-aware exec. */
43
+ onTimeout?: () => void;
44
+ }): Promise<T>;
45
+ /**
46
+ * Run `sandbox.exec()` bounded by a client-side timeout, and — for signal-aware
47
+ * transports (e.g. the HTTP bridge, `supportsExecSignal === true`) — abort the
48
+ * underlying exec when the timeout fires instead of merely abandoning it. The
49
+ * native DO transport ignores `signal`, so it only gets the timeout. Leaves the
50
+ * backstop timer ref'd (this is an awaited direct-exec path).
51
+ */
52
+ export declare function execWithClientTimeout(sandbox: t.CloudflareSandboxRuntime, command: string, options: t.CloudflareSandboxExecOptions, timeoutMs: number, label: string, runOptions?: {
53
+ unref?: boolean;
54
+ }): Promise<t.CloudflareSandboxExecResult>;
11
55
  export declare function createCloudflareWorkspaceFS(config: t.CloudflareSandboxExecutionConfig): WorkspaceFS;
12
56
  export declare function createCloudflareLocalExecutionConfig(config: t.CloudflareSandboxExecutionConfig): t.LocalExecutionConfig;
13
57
  export declare function validateCloudflareBashCommand(command: string, args: readonly string[], config: t.CloudflareSandboxExecutionConfig): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@librechat/agents",
3
- "version": "3.2.41",
3
+ "version": "3.2.42",
4
4
  "main": "./dist/cjs/main.cjs",
5
5
  "module": "./dist/esm/main.mjs",
6
6
  "types": "./dist/types/index.d.ts",
@@ -846,6 +846,8 @@ export class AgentContext {
846
846
  const bedrockOptions = this.clientOptions as
847
847
  | t.BedrockAnthropicClientOptions
848
848
  | undefined;
849
+ // Nova accepts system/message cachePoints (only the tool checkpoint is
850
+ // Claude-only), so this is gated on promptCache alone.
849
851
  return bedrockOptions?.promptCache === true;
850
852
  }
851
853
 
@@ -28,6 +28,7 @@ import {
28
28
  syncBudgetDerivedFields,
29
29
  addTailCacheControl,
30
30
  resolvePromptCacheTtl,
31
+ supportsBedrockToolCache,
31
32
  getMessageId,
32
33
  makeIsDeferred,
33
34
  partitionAndMarkAnthropicToolCache,
@@ -1442,11 +1443,19 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1442
1443
  | undefined
1443
1444
  )?.promptCache === true
1444
1445
  ) {
1445
- toolsForBinding =
1446
- partitionAndMarkBedrockToolCache(
1447
- rawToolsForBinding,
1448
- makeIsDeferred(agentContext.toolDefinitions)
1449
- ) ?? rawToolsForBinding;
1446
+ const bedrockModel = (
1447
+ agentContext.clientOptions as { model?: string } | undefined
1448
+ )?.model;
1449
+ // An omitted model falls back to LangChain's default Claude model (which
1450
+ // supports tool caching); only an explicit non-Claude model (e.g. Nova)
1451
+ // skips tool marking so its stray marker never leaks into toolConfig.
1452
+ if (bedrockModel == null || supportsBedrockToolCache(bedrockModel)) {
1453
+ toolsForBinding =
1454
+ partitionAndMarkBedrockToolCache(
1455
+ rawToolsForBinding,
1456
+ makeIsDeferred(agentContext.toolDefinitions)
1457
+ ) ?? rawToolsForBinding;
1458
+ }
1450
1459
  }
1451
1460
 
1452
1461
  let model =
@@ -1790,6 +1799,9 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1790
1799
  | t.ProviderOptionsMap[Providers.OPENROUTER]
1791
1800
  | undefined
1792
1801
  )?.promptCache === true;
1802
+ // Message/system cache points work on all cache-capable Bedrock models,
1803
+ // including Nova (verified live: HTTP 200 with cacheWriteInputTokens). Only
1804
+ // the tool checkpoint is Claude-only, so this is gated on promptCache alone.
1793
1805
  const bedrockPromptCacheEnabled =
1794
1806
  agentContext.provider === Providers.BEDROCK &&
1795
1807
  (
@@ -39,7 +39,11 @@ import {
39
39
  handleConverseStreamContentBlockDelta,
40
40
  handleConverseStreamMetadata,
41
41
  } from './utils';
42
- import { resolvePromptCacheTtl, type PromptCacheTtl } from '@/messages/cache';
42
+ import {
43
+ resolvePromptCacheTtl,
44
+ supportsBedrockToolCache,
45
+ type PromptCacheTtl,
46
+ } from '@/messages/cache';
43
47
  import { insertBedrockToolCachePoint } from './toolCache';
44
48
 
45
49
  /**
@@ -134,12 +138,24 @@ export class CustomChatBedrockConverse extends ChatBedrockConverse {
134
138
  */
135
139
  serviceTier?: ServiceTierType;
136
140
 
141
+ /**
142
+ * The configured model id, captured at construction so it survives the
143
+ * temporary `this.model` swap to an application-inference-profile ARN during
144
+ * generation. Used to gate the Bedrock tool cache point to Claude models
145
+ * (see {@link supportsBedrockToolCache}).
146
+ */
147
+ private readonly cacheModelId: string;
148
+
137
149
  constructor(fields?: CustomChatBedrockConverseInput) {
138
150
  super(fields);
139
151
  this.promptCache = fields?.promptCache;
140
152
  this.promptCacheTtl = fields?.promptCacheTtl;
141
153
  this.applicationInferenceProfile = fields?.applicationInferenceProfile;
142
154
  this.serviceTier = fields?.serviceTier;
155
+ // `super(fields)` initializes `this.model` to LangChain's default Claude
156
+ // model when `fields.model` is omitted, so fall back to it rather than ''
157
+ // (which would treat the default Claude model as tool-cache-unsupported).
158
+ this.cacheModelId = fields?.model ?? this.model;
143
159
  }
144
160
 
145
161
  static lc_name(): string {
@@ -164,7 +180,7 @@ export class CustomChatBedrockConverse extends ChatBedrockConverse {
164
180
  } {
165
181
  const baseParams = super.invocationParams(options);
166
182
  const toolConfig =
167
- this.promptCache === true
183
+ this.promptCache === true && supportsBedrockToolCache(this.cacheModelId)
168
184
  ? insertBedrockToolCachePoint(
169
185
  baseParams.toolConfig,
170
186
  true,
@@ -375,6 +375,7 @@ describe('CustomChatBedrockConverse', () => {
375
375
  test('adds a Bedrock cache point for directly-bound tools', () => {
376
376
  const model = new CustomChatBedrockConverse({
377
377
  ...baseConstructorArgs,
378
+ model: 'anthropic.claude-3-haiku-20240307-v1:0',
378
379
  promptCache: true,
379
380
  });
380
381
 
@@ -400,6 +401,7 @@ describe('CustomChatBedrockConverse', () => {
400
401
  test('defaults the tool cache point to the 1h extended TTL', () => {
401
402
  const model = new CustomChatBedrockConverse({
402
403
  ...baseConstructorArgs,
404
+ model: 'anthropic.claude-3-haiku-20240307-v1:0',
403
405
  promptCache: true,
404
406
  });
405
407
 
@@ -430,6 +432,7 @@ describe('CustomChatBedrockConverse', () => {
430
432
  test('honors an explicit 5m promptCacheTtl on the tool cache point', () => {
431
433
  const model = new CustomChatBedrockConverse({
432
434
  ...baseConstructorArgs,
435
+ model: 'anthropic.claude-3-haiku-20240307-v1:0',
433
436
  promptCache: true,
434
437
  promptCacheTtl: '5m',
435
438
  });
@@ -461,6 +464,7 @@ describe('CustomChatBedrockConverse', () => {
461
464
  test('adds the Bedrock cache point before deferred tools', () => {
462
465
  const model = new CustomChatBedrockConverse({
463
466
  ...baseConstructorArgs,
467
+ model: 'anthropic.claude-3-haiku-20240307-v1:0',
464
468
  promptCache: true,
465
469
  });
466
470
  const tools = partitionAndMarkBedrockToolCache(
@@ -500,6 +504,7 @@ describe('CustomChatBedrockConverse', () => {
500
504
  test('does not fall back to caching when Graph marks all tools deferred', () => {
501
505
  const model = new CustomChatBedrockConverse({
502
506
  ...baseConstructorArgs,
507
+ model: 'anthropic.claude-3-haiku-20240307-v1:0',
503
508
  promptCache: true,
504
509
  });
505
510
  const tools = partitionAndMarkBedrockToolCache(
@@ -525,6 +530,98 @@ describe('CustomChatBedrockConverse', () => {
525
530
  '__lc_bedrock_skip_tool_cache'
526
531
  );
527
532
  });
533
+
534
+ test('does not add a tool cache point for Amazon Nova models', () => {
535
+ // Regression for danny-avila/LibreChat#13838: Nova rejects a cachePoint in
536
+ // toolConfig.tools ("Malformed input request: #/toolConfig/tools/0:
537
+ // extraneous key [cachePoint] is not permitted"). Only the tool checkpoint
538
+ // is Claude-only — Nova still caches system/messages — so promptCache: true
539
+ // on Nova must omit just the tool cache point.
540
+ const model = new CustomChatBedrockConverse({
541
+ ...baseConstructorArgs,
542
+ model: 'us.amazon.nova-pro-v1:0',
543
+ promptCache: true,
544
+ });
545
+
546
+ const params = model.invocationParams({
547
+ tools: [
548
+ {
549
+ type: 'function',
550
+ function: {
551
+ name: 'direct_tool',
552
+ description: 'Direct tool',
553
+ parameters: { type: 'object', properties: {} },
554
+ },
555
+ },
556
+ ],
557
+ });
558
+
559
+ expect(getBedrockToolNames(params.toolConfig?.tools)).toEqual([
560
+ 'direct_tool',
561
+ ]);
562
+ expect(JSON.stringify(params.toolConfig?.tools ?? [])).not.toContain(
563
+ 'cachePoint'
564
+ );
565
+ });
566
+
567
+ test('still adds a tool cache point for a Claude model behind an inference profile', () => {
568
+ // The configured Claude model id is captured at construction, so the gate
569
+ // survives the application-inference-profile ARN swap during generation.
570
+ const model = new CustomChatBedrockConverse({
571
+ ...baseConstructorArgs,
572
+ model: 'anthropic.claude-sonnet-4-5-20250929-v1:0',
573
+ applicationInferenceProfile:
574
+ 'arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/test',
575
+ promptCache: true,
576
+ });
577
+
578
+ const params = model.invocationParams({
579
+ tools: [
580
+ {
581
+ type: 'function',
582
+ function: {
583
+ name: 'direct_tool',
584
+ description: 'Direct tool',
585
+ parameters: { type: 'object', properties: {} },
586
+ },
587
+ },
588
+ ],
589
+ });
590
+
591
+ expect(getBedrockToolNames(params.toolConfig?.tools)).toEqual([
592
+ 'direct_tool',
593
+ 'cachePoint',
594
+ ]);
595
+ });
596
+
597
+ test('still adds a tool cache point when model is omitted (default is Claude)', () => {
598
+ // When no model is provided, LangChain initializes this.model to a default
599
+ // Claude model, so the tool cache point must still apply.
600
+ const model = new CustomChatBedrockConverse({
601
+ ...baseConstructorArgs,
602
+ promptCache: true,
603
+ });
604
+
605
+ expect(model.model).toMatch(/claude/i);
606
+
607
+ const params = model.invocationParams({
608
+ tools: [
609
+ {
610
+ type: 'function',
611
+ function: {
612
+ name: 'direct_tool',
613
+ description: 'Direct tool',
614
+ parameters: { type: 'object', properties: {} },
615
+ },
616
+ },
617
+ ],
618
+ });
619
+
620
+ expect(getBedrockToolNames(params.toolConfig?.tools)).toEqual([
621
+ 'direct_tool',
622
+ 'cachePoint',
623
+ ]);
624
+ });
528
625
  });
529
626
 
530
627
  describe('contentBlockIndex cleanup', () => {
@@ -19,6 +19,7 @@ import {
19
19
  buildAnthropicCacheControl,
20
20
  buildBedrockCachePoint,
21
21
  resolvePromptCacheTtl,
22
+ supportsBedrockToolCache,
22
23
  DEFAULT_PROMPT_CACHE_TTL,
23
24
  } from './cache';
24
25
  import { _convertMessagesToOpenAIParams } from '@/llm/openai/utils';
@@ -895,6 +896,36 @@ describe('synthetic skill/meta messages are not cache-anchored', () => {
895
896
  });
896
897
  });
897
898
 
899
+ describe('supportsBedrockToolCache', () => {
900
+ test('returns true for Claude model ids (incl. cross-region + profile)', () => {
901
+ expect(
902
+ supportsBedrockToolCache('anthropic.claude-3-5-sonnet-20241022-v2:0')
903
+ ).toBe(true);
904
+ expect(
905
+ supportsBedrockToolCache('us.anthropic.claude-sonnet-4-5-20250929-v1:0')
906
+ ).toBe(true);
907
+ expect(supportsBedrockToolCache('anthropic.claude-opus-4-6-v1')).toBe(true);
908
+ });
909
+
910
+ test('returns false for Nova and other non-Claude families (tool cache only)', () => {
911
+ // Nova accepts system/message cachePoints but rejects the tool checkpoint.
912
+ expect(supportsBedrockToolCache('us.amazon.nova-pro-v1:0')).toBe(false);
913
+ expect(supportsBedrockToolCache('amazon.nova-lite-v1:0')).toBe(false);
914
+ expect(supportsBedrockToolCache('meta.llama3-1-70b-instruct-v1:0')).toBe(
915
+ false
916
+ );
917
+ expect(supportsBedrockToolCache('mistral.mistral-large-2407-v1:0')).toBe(
918
+ false
919
+ );
920
+ });
921
+
922
+ test('returns false for empty / missing model', () => {
923
+ expect(supportsBedrockToolCache('')).toBe(false);
924
+ expect(supportsBedrockToolCache(undefined)).toBe(false);
925
+ expect(supportsBedrockToolCache(null)).toBe(false);
926
+ });
927
+ });
928
+
898
929
  describe('stripAnthropicCacheControl', () => {
899
930
  it('removes cache_control fields from content blocks', () => {
900
931
  const messages: TestMsg[] = [
@@ -79,6 +79,31 @@ export function buildBedrockCachePoint(
79
79
  return ttl === '1h' ? { type: 'default', ttl: '1h' } : { type: 'default' };
80
80
  }
81
81
 
82
+ /**
83
+ * A `cachePoint` under `toolConfig.tools` is only accepted on Anthropic Claude
84
+ * models. Amazon Nova rejects it outright — `Malformed input request:
85
+ * #/toolConfig/tools/0: extraneous key [cachePoint] is not permitted` — even
86
+ * though it accepts `cachePoint` in `system` and `messages` (verified live:
87
+ * Nova returns HTTP 200 with real `cacheWriteInputTokens` for both). Per the AWS
88
+ * prompt-caching docs the support table lists `tools` for Claude only.
89
+ *
90
+ * Gate ONLY the tool checkpoint on this, so a `promptCache: true` config on Nova
91
+ * keeps its valid message/system caching instead of either 400-ing (tool point)
92
+ * or losing caching entirely. This is a capability gate (the key is rejected
93
+ * outright), distinct from the TTL value which Bedrock downgrades gracefully.
94
+ *
95
+ * @see https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html
96
+ * @see https://github.com/danny-avila/LibreChat/issues/13838
97
+ */
98
+ export function supportsBedrockToolCache(
99
+ model: string | undefined | null
100
+ ): boolean {
101
+ if (typeof model !== 'string') {
102
+ return false;
103
+ }
104
+ return /claude|anthropic/i.test(model);
105
+ }
106
+
82
107
  /**
83
108
  * Deep clones a message's content to prevent mutation of the original.
84
109
  */
@@ -2,6 +2,7 @@ import type * as t from '@/types';
2
2
  import {
3
3
  createCloudflareWorkspaceFS,
4
4
  createCloudflareLocalExecutionConfig,
5
+ execWithClientTimeout,
5
6
  executeCloudflareBash,
6
7
  executeCloudflareCode,
7
8
  } from '../cloudflare/CloudflareSandboxExecutionEngine';
@@ -257,6 +258,136 @@ describe('Cloudflare sandbox execution backend', () => {
257
258
  expect(result.timedOut).toBe(true);
258
259
  });
259
260
 
261
+ it('rejects with a client-side timeout when sandbox exec stalls (no native cancellation)', async () => {
262
+ // The native Cloudflare Sandbox DO exec() is uncancellable (ExecOptions has no
263
+ // signal) and its own `timeout` is not enforced when the container/RPC stalls,
264
+ // while the in-sandbox `timeout(1)` wrapper only bounds a *running* command.
265
+ // Without a client-side race a stalled exec hangs until the host's run-level
266
+ // abort, burning the whole budget on one tool call (issue #251).
267
+ jest.useFakeTimers();
268
+ try {
269
+ let mainExecCalls = 0;
270
+ const sandbox = createRuntime({
271
+ exec: (command) => {
272
+ // Cleanup (`rm -rf`) resolves immediately; the real command stalls,
273
+ // simulating an unresponsive / cold container exec that never returns.
274
+ if (command.startsWith('rm -rf')) {
275
+ return Promise.resolve({ exitCode: 0, stdout: '', stderr: '' });
276
+ }
277
+ mainExecCalls += 1;
278
+ return new Promise<t.CloudflareSandboxExecResult>(() => undefined);
279
+ },
280
+ });
281
+
282
+ const promise = executeCloudflareCode(
283
+ { lang: 'py', code: 'print("slow")' },
284
+ { sandbox, workspaceRoot: '/workspace', timeoutMs: 1000 }
285
+ );
286
+ const assertion = expect(promise).rejects.toThrow(/client-side timeout/);
287
+
288
+ // Client backstop = outerTimeoutMs(1000) + 5000 = 11000ms; advance past it.
289
+ await jest.advanceTimersByTimeAsync(11500);
290
+ await assertion;
291
+ expect(mainExecCalls).toBe(1);
292
+ } finally {
293
+ jest.useRealTimers();
294
+ }
295
+ });
296
+
297
+ it('aborts signal-aware execs when the client timeout fires', async () => {
298
+ // For signal-aware transports (e.g. the HTTP bridge), a client timeout should
299
+ // actually cancel the underlying exec, not just abandon it.
300
+ jest.useFakeTimers();
301
+ try {
302
+ let mainSignal: AbortSignal | undefined;
303
+ const sandbox = createRuntime({
304
+ supportsExecSignal: true,
305
+ exec: (command, options) => {
306
+ if (command.startsWith('rm -rf')) {
307
+ return Promise.resolve({ exitCode: 0, stdout: '', stderr: '' });
308
+ }
309
+ mainSignal = options?.signal;
310
+ return new Promise<t.CloudflareSandboxExecResult>(() => undefined);
311
+ },
312
+ });
313
+
314
+ const promise = executeCloudflareCode(
315
+ { lang: 'py', code: 'print("slow")' },
316
+ { sandbox, workspaceRoot: '/workspace', timeoutMs: 1000 }
317
+ );
318
+ const assertion = expect(promise).rejects.toThrow(/client-side timeout/);
319
+ await jest.advanceTimersByTimeAsync(11500);
320
+ await assertion;
321
+
322
+ expect(mainSignal).toBeDefined();
323
+ expect(mainSignal?.aborted).toBe(true);
324
+ } finally {
325
+ jest.useRealTimers();
326
+ }
327
+ });
328
+
329
+ it('composes a caller abort signal with the timeout instead of clobbering it', async () => {
330
+ let received: AbortSignal | undefined;
331
+ const sandbox = createRuntime({
332
+ supportsExecSignal: true,
333
+ exec: (_command, options) => {
334
+ received = options?.signal;
335
+ return new Promise<t.CloudflareSandboxExecResult>(
336
+ (_resolve, reject) => {
337
+ options?.signal?.addEventListener(
338
+ 'abort',
339
+ () => reject(new Error('aborted')),
340
+ { once: true }
341
+ );
342
+ }
343
+ );
344
+ },
345
+ });
346
+
347
+ const caller = new AbortController();
348
+ const settled = execWithClientTimeout(
349
+ sandbox,
350
+ 'echo hi',
351
+ { signal: caller.signal },
352
+ 60000,
353
+ 'test'
354
+ ).catch((e) => e as Error);
355
+
356
+ await Promise.resolve();
357
+ expect(received).toBeDefined();
358
+ // The exec gets a composed signal, not the caller's directly.
359
+ expect(received).not.toBe(caller.signal);
360
+ expect(received?.aborted).toBe(false);
361
+
362
+ // A caller cancellation must reach the exec (not wait for the client timeout).
363
+ caller.abort();
364
+ await settled;
365
+ expect(received?.aborted).toBe(true);
366
+ });
367
+
368
+ it('strips a caller signal for native runtimes that cannot consume it', async () => {
369
+ let received: t.CloudflareSandboxExecOptions | undefined;
370
+ const sandbox = createRuntime({
371
+ // no supportsExecSignal -> native DO, which cannot clone/consume a signal
372
+ exec: async (_command, options) => {
373
+ received = options;
374
+ return { exitCode: 0, stdout: 'ok', stderr: '' };
375
+ },
376
+ });
377
+ const caller = new AbortController();
378
+
379
+ await execWithClientTimeout(
380
+ sandbox,
381
+ 'echo hi',
382
+ { cwd: '/workspace', signal: caller.signal },
383
+ 60000,
384
+ 'test'
385
+ );
386
+
387
+ expect(received).toBeDefined();
388
+ expect(received).not.toHaveProperty('signal');
389
+ });
390
+
260
391
  it('passes call-specific timeouts to the Cloudflare spawn wrapper', async () => {
261
392
  let execCommand = '';
262
393
  let execTimeout: number | undefined;
@@ -19,6 +19,8 @@ import {
19
19
  } from '@/tools/BashProgrammaticToolCalling';
20
20
  import { Constants } from '@/common';
21
21
  import {
22
+ clientExecTimeoutMs,
23
+ execWithClientTimeout,
22
24
  executeCloudflareCode,
23
25
  getCloudflareWorkspaceRoot,
24
26
  resolveCloudflareSandbox,
@@ -166,13 +168,16 @@ async function executeGeneratedCloudflareBash(
166
168
  const workspaceRoot = getCloudflareWorkspaceRoot(config);
167
169
  const shell = config.shell ?? 'bash';
168
170
  const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT;
169
- const result = await sandbox.exec(
171
+ const result = await execWithClientTimeout(
172
+ sandbox,
170
173
  withInSandboxTimeout(`${shell} -lc ${quoteShell(command)}`, timeoutMs),
171
174
  {
172
175
  cwd: workspaceRoot,
173
176
  env: config.env,
174
177
  timeout: outerTimeoutMs(timeoutMs),
175
- }
178
+ },
179
+ clientExecTimeoutMs(timeoutMs),
180
+ 'cloudflare bash programmatic exec'
176
181
  );
177
182
  const maxOutputChars = config.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
178
183
  return {