@librechat/agents 3.2.42 → 3.2.43
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 +4 -3
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +1 -1
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +1 -1
- package/dist/cjs/llm/bedrock/index.cjs +1 -1
- package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
- package/dist/cjs/main.cjs +2 -0
- package/dist/cjs/messages/cache.cjs +26 -4
- package/dist/cjs/messages/cache.cjs.map +1 -1
- package/dist/cjs/session/JsonlSessionStore.cjs +1 -1
- package/dist/cjs/tools/cloudflare/CloudflareSandboxExecutionEngine.cjs +32 -16
- package/dist/cjs/tools/cloudflare/CloudflareSandboxExecutionEngine.cjs.map +1 -1
- package/dist/cjs/tools/local/CompileCheckTool.cjs +11 -3
- package/dist/cjs/tools/local/CompileCheckTool.cjs.map +1 -1
- package/dist/cjs/tools/local/FileCheckpointer.cjs +8 -3
- package/dist/cjs/tools/local/FileCheckpointer.cjs.map +1 -1
- package/dist/cjs/tools/local/LocalCodingTools.cjs +26 -7
- package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -1
- package/dist/cjs/tools/local/LocalExecutionEngine.cjs +2 -2
- package/dist/cjs/tools/local/syntaxCheck.cjs +6 -2
- package/dist/cjs/tools/local/syntaxCheck.cjs.map +1 -1
- package/dist/cjs/tools/local/workspaceFS.cjs +20 -0
- package/dist/cjs/tools/local/workspaceFS.cjs.map +1 -1
- package/dist/esm/agents/AgentContext.mjs +5 -4
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +2 -2
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/hooks/createWorkspacePolicyHook.mjs +1 -1
- package/dist/esm/llm/bedrock/index.mjs +2 -2
- package/dist/esm/llm/bedrock/index.mjs.map +1 -1
- package/dist/esm/main.mjs +3 -3
- package/dist/esm/messages/cache.mjs +26 -5
- package/dist/esm/messages/cache.mjs.map +1 -1
- package/dist/esm/session/JsonlSessionStore.mjs +1 -1
- package/dist/esm/tools/cloudflare/CloudflareSandboxExecutionEngine.mjs +32 -17
- package/dist/esm/tools/cloudflare/CloudflareSandboxExecutionEngine.mjs.map +1 -1
- package/dist/esm/tools/local/CompileCheckTool.mjs +11 -3
- package/dist/esm/tools/local/CompileCheckTool.mjs.map +1 -1
- package/dist/esm/tools/local/FileCheckpointer.mjs +9 -4
- package/dist/esm/tools/local/FileCheckpointer.mjs.map +1 -1
- package/dist/esm/tools/local/LocalCodingTools.mjs +26 -7
- package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -1
- package/dist/esm/tools/local/LocalExecutionEngine.mjs +2 -2
- package/dist/esm/tools/local/syntaxCheck.mjs +6 -2
- package/dist/esm/tools/local/syntaxCheck.mjs.map +1 -1
- package/dist/esm/tools/local/workspaceFS.mjs +19 -1
- package/dist/esm/tools/local/workspaceFS.mjs.map +1 -1
- package/dist/types/agents/AgentContext.d.ts +3 -2
- package/dist/types/messages/cache.d.ts +12 -2
- package/dist/types/tools/cloudflare/CloudflareSandboxExecutionEngine.d.ts +9 -0
- package/dist/types/tools/local/workspaceFS.d.ts +13 -0
- package/package.json +1 -1
- package/src/agents/AgentContext.ts +8 -3
- package/src/graphs/Graph.ts +7 -1
- package/src/llm/bedrock/index.ts +2 -2
- package/src/messages/cache.test.ts +32 -0
- package/src/messages/cache.ts +31 -5
- package/src/specs/vllm-reasoning-toolcalls.test.ts +340 -0
- package/src/tools/__tests__/CloudflareSandboxExecution.test.ts +192 -0
- package/src/tools/__tests__/LocalExecutionTools.test.ts +54 -0
- package/src/tools/cloudflare/CloudflareSandboxExecutionEngine.ts +106 -30
- package/src/tools/local/CompileCheckTool.ts +19 -3
- package/src/tools/local/FileCheckpointer.ts +20 -4
- package/src/tools/local/LocalCodingTools.ts +61 -8
- package/src/tools/local/__tests__/FileCheckpointer.test.ts +42 -0
- package/src/tools/local/syntaxCheck.ts +14 -2
- package/src/tools/local/workspaceFS.ts +27 -0
|
@@ -37,6 +37,8 @@ import {
|
|
|
37
37
|
_resetSyntaxCheckProbeCacheForTests,
|
|
38
38
|
} from '../local/syntaxCheck';
|
|
39
39
|
import { resolveLocalToolsForBinding } from '../local/resolveLocalExecutionTools';
|
|
40
|
+
import { WorkspaceClientTimeoutError } from '../local/workspaceFS';
|
|
41
|
+
import type { WorkspaceFS } from '../local/workspaceFS';
|
|
40
42
|
import { LocalFileCheckpointerImpl } from '../local/FileCheckpointer';
|
|
41
43
|
import { createCompileCheckTool } from '../local/CompileCheckTool';
|
|
42
44
|
import { runBashAstChecks } from '../local/bashAst';
|
|
@@ -2317,6 +2319,58 @@ describe('comprehensive review (round 14) — Codex P1 #37 + P2 #38/#40/#41', ()
|
|
|
2317
2319
|
).rejects.toThrow(/syntax check failed.*reverted/i);
|
|
2318
2320
|
expect(await fsp.readFile(file, 'utf8')).toBe(original);
|
|
2319
2321
|
});
|
|
2322
|
+
|
|
2323
|
+
it('write_file: still reverts when the strict validation READ times out', async () => {
|
|
2324
|
+
// A stalled validation read must not leave the just-written bytes on disk —
|
|
2325
|
+
// strict mode's revert contract still applies when validation is
|
|
2326
|
+
// inconclusive due to a timeout (here a brand-new file -> unlink).
|
|
2327
|
+
const cwd = await createTempDir();
|
|
2328
|
+
const file = join(cwd, 'config.json');
|
|
2329
|
+
const unlinked: string[] = [];
|
|
2330
|
+
let written = false;
|
|
2331
|
+
const fakeFs = {
|
|
2332
|
+
readFile: async () => {
|
|
2333
|
+
if (!written) {
|
|
2334
|
+
// write_file pre-read: the file does not exist yet.
|
|
2335
|
+
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
|
2336
|
+
}
|
|
2337
|
+
// jsonCheck's post-write validation read stalls.
|
|
2338
|
+
throw new WorkspaceClientTimeoutError(
|
|
2339
|
+
'cloudflare sandbox readFile exceeded 6000ms client-side timeout'
|
|
2340
|
+
);
|
|
2341
|
+
},
|
|
2342
|
+
writeFile: async () => {
|
|
2343
|
+
written = true;
|
|
2344
|
+
},
|
|
2345
|
+
mkdir: async () => undefined,
|
|
2346
|
+
unlink: async (p: string) => {
|
|
2347
|
+
unlinked.push(p);
|
|
2348
|
+
},
|
|
2349
|
+
stat: async () => ({ isFile: () => true, size: 1 }),
|
|
2350
|
+
readdir: async () => [],
|
|
2351
|
+
realpath: async (p: string) => p,
|
|
2352
|
+
} as unknown as WorkspaceFS;
|
|
2353
|
+
|
|
2354
|
+
const bundle = createLocalCodingToolBundle({
|
|
2355
|
+
cwd,
|
|
2356
|
+
postEditSyntaxCheck: 'strict',
|
|
2357
|
+
exec: { fs: fakeFs },
|
|
2358
|
+
});
|
|
2359
|
+
const writeTool = bundle.tools.find(
|
|
2360
|
+
(tt) => tt.name === Constants.WRITE_FILE
|
|
2361
|
+
);
|
|
2362
|
+
await expect(
|
|
2363
|
+
writeTool!.invoke({
|
|
2364
|
+
id: 'wf-strict-read-timeout',
|
|
2365
|
+
name: Constants.WRITE_FILE,
|
|
2366
|
+
args: { path: file, content: '{"ok": true}\n' },
|
|
2367
|
+
type: 'tool_call',
|
|
2368
|
+
})
|
|
2369
|
+
).rejects.toThrow(/client-side timeout/);
|
|
2370
|
+
// strict mode attempted the revert (brand-new file -> unlink), rather than
|
|
2371
|
+
// leaving the unvalidated write on disk.
|
|
2372
|
+
expect(unlinked.length).toBeGreaterThan(0);
|
|
2373
|
+
});
|
|
2320
2374
|
});
|
|
2321
2375
|
|
|
2322
2376
|
describe('fallbackGrep skip sentinels do not count as matches (Codex P2 [43])', () => {
|
|
@@ -5,6 +5,10 @@ import type { WriteFileOptions, MakeDirectoryOptions, Stats } from 'fs';
|
|
|
5
5
|
import type { ChildProcessWithoutNullStreams } from 'child_process';
|
|
6
6
|
import type { FileHandle } from 'fs/promises';
|
|
7
7
|
import type { WorkspaceFS, ReaddirEntry } from '@/tools/local/workspaceFS';
|
|
8
|
+
import {
|
|
9
|
+
WorkspaceClientTimeoutError,
|
|
10
|
+
isWorkspaceClientTimeoutError,
|
|
11
|
+
} from '@/tools/local/workspaceFS';
|
|
8
12
|
import type * as t from '@/types';
|
|
9
13
|
import {
|
|
10
14
|
LOCAL_SPAWN_TIMEOUT_MS,
|
|
@@ -140,6 +144,18 @@ export function clientExecTimeoutMs(timeoutMs: number): number {
|
|
|
140
144
|
return outerTimeoutMs(timeoutMs) + 5000;
|
|
141
145
|
}
|
|
142
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Client-side backstop timeout for a native-DO sandbox FILE-IO RPC
|
|
149
|
+
* (`readFile`/`writeFile`/`listFiles`/`mkdir`/`deleteFile`). Unlike `exec()`
|
|
150
|
+
* there is no in-sandbox `timeout(1)` layer for these to honor, so this is just a
|
|
151
|
+
* few seconds of headroom over the configured tool timeout — enough that a normal
|
|
152
|
+
* (even large, byte-capped) read completes, while a stalled/cold container can't
|
|
153
|
+
* outlast it. See `withClientTimeout` for why the native DO RPC needs this.
|
|
154
|
+
*/
|
|
155
|
+
export function clientFsTimeoutMs(timeoutMs: number): number {
|
|
156
|
+
return timeoutMs + 5000;
|
|
157
|
+
}
|
|
158
|
+
|
|
143
159
|
/**
|
|
144
160
|
* Bound a `sandbox.exec()` await with a CLIENT-SIDE timeout.
|
|
145
161
|
*
|
|
@@ -188,8 +204,8 @@ export async function withClientTimeout<T>(
|
|
|
188
204
|
// only then abort a signal-aware exec, whose resulting AbortError must
|
|
189
205
|
// not surface to the caller instead of the timeout.
|
|
190
206
|
reject(
|
|
191
|
-
new
|
|
192
|
-
`${label} exceeded ${timeoutMs}ms client-side timeout (sandbox
|
|
207
|
+
new WorkspaceClientTimeoutError(
|
|
208
|
+
`${label} exceeded ${timeoutMs}ms client-side timeout (sandbox RPC did not return)`
|
|
193
209
|
)
|
|
194
210
|
);
|
|
195
211
|
options.onTimeout?.();
|
|
@@ -412,12 +428,17 @@ function createDirent(info: t.CloudflareSandboxFileInfo): ReaddirEntry {
|
|
|
412
428
|
|
|
413
429
|
async function findChildInfo(
|
|
414
430
|
sandbox: t.CloudflareSandboxRuntime,
|
|
415
|
-
filePath: string
|
|
431
|
+
filePath: string,
|
|
432
|
+
timeoutMs: number
|
|
416
433
|
): Promise<t.CloudflareSandboxFileInfo | undefined> {
|
|
417
434
|
const parent = path.dirname(filePath);
|
|
418
435
|
const basename = path.basename(filePath);
|
|
419
436
|
const entries = normalizeFileList(
|
|
420
|
-
await
|
|
437
|
+
await withClientTimeout(
|
|
438
|
+
sandbox.listFiles(parent, { includeHidden: true }),
|
|
439
|
+
timeoutMs,
|
|
440
|
+
'cloudflare sandbox listFiles'
|
|
441
|
+
)
|
|
421
442
|
);
|
|
422
443
|
return entries.find((entry) => {
|
|
423
444
|
const absolute = entryAbsolutePath(entry, parent);
|
|
@@ -429,13 +450,29 @@ export function createCloudflareWorkspaceFS(
|
|
|
429
450
|
config: t.CloudflareSandboxExecutionConfig
|
|
430
451
|
): WorkspaceFS {
|
|
431
452
|
const workspaceRoot = getCloudflareWorkspaceRoot(config);
|
|
453
|
+
// Native-DO file-IO RPCs have the SAME stall hazard as exec() (PR #252): no
|
|
454
|
+
// `signal`, no reliably-enforced timeout, so a cold/unresponsive container
|
|
455
|
+
// hangs the host await until the run-level abort — burning the whole budget on
|
|
456
|
+
// one read (observed: a `read_file` that stalled ~552s before the wall-clock
|
|
457
|
+
// budget killed it). Bound every native FS RPC with the same client-side
|
|
458
|
+
// backstop the exec sites use.
|
|
459
|
+
const fsTimeoutMs = clientFsTimeoutMs(config.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
460
|
+
const bound = <T>(op: Promise<T>, label: string): Promise<T> =>
|
|
461
|
+
withClientTimeout(op, fsTimeoutMs, `cloudflare sandbox ${label}`);
|
|
432
462
|
|
|
433
463
|
const fs: WorkspaceFS = {
|
|
434
464
|
readFile: (async (filePath: string, encoding?: 'utf8') => {
|
|
435
465
|
const sandbox = await resolveCloudflareSandbox(config);
|
|
436
466
|
const resolved = toSandboxPath(filePath, workspaceRoot);
|
|
437
|
-
|
|
438
|
-
|
|
467
|
+
// Wrap the stream drain (normalizeReadFileContent) inside the backstop too:
|
|
468
|
+
// a sandbox.readFile that resolves to a { content: ReadableStream } can
|
|
469
|
+
// still stall mid-drain after the RPC promise settled.
|
|
470
|
+
const buffer = await bound(
|
|
471
|
+
(async (): Promise<Buffer> =>
|
|
472
|
+
normalizeReadFileContent(
|
|
473
|
+
await sandbox.readFile(resolved, encoding ? { encoding } : undefined)
|
|
474
|
+
))(),
|
|
475
|
+
'readFile'
|
|
439
476
|
);
|
|
440
477
|
return encoding != null ? buffer.toString(encoding) : buffer;
|
|
441
478
|
}) as WorkspaceFS['readFile'],
|
|
@@ -447,29 +484,46 @@ export function createCloudflareWorkspaceFS(
|
|
|
447
484
|
const sandbox = await resolveCloudflareSandbox(config);
|
|
448
485
|
const resolved = toSandboxPath(filePath, workspaceRoot);
|
|
449
486
|
const normalized = normalizeWriteFileContent(content);
|
|
450
|
-
await
|
|
487
|
+
await bound(
|
|
488
|
+
sandbox.writeFile(resolved, normalized.content, normalized.options),
|
|
489
|
+
'writeFile'
|
|
490
|
+
);
|
|
451
491
|
},
|
|
452
492
|
stat: async (filePath: string) => {
|
|
453
493
|
const sandbox = await resolveCloudflareSandbox(config);
|
|
454
494
|
const resolved = toSandboxPath(filePath, workspaceRoot);
|
|
455
495
|
if (resolved === workspaceRoot) {
|
|
456
496
|
const entries = normalizeFileList(
|
|
457
|
-
await
|
|
497
|
+
await bound(
|
|
498
|
+
sandbox.listFiles(resolved, { includeHidden: true }),
|
|
499
|
+
'listFiles'
|
|
500
|
+
)
|
|
458
501
|
);
|
|
459
502
|
return createStats({ size: entries.length, type: 'directory' });
|
|
460
503
|
}
|
|
461
|
-
const info = await findChildInfo(sandbox, resolved);
|
|
504
|
+
const info = await findChildInfo(sandbox, resolved, fsTimeoutMs);
|
|
462
505
|
if (info != null) {
|
|
463
506
|
return createStats({ size: info.size, type: info.type });
|
|
464
507
|
}
|
|
465
508
|
try {
|
|
466
509
|
const entries = normalizeFileList(
|
|
467
|
-
await
|
|
510
|
+
await bound(
|
|
511
|
+
sandbox.listFiles(resolved, { includeHidden: true }),
|
|
512
|
+
'listFiles'
|
|
513
|
+
)
|
|
468
514
|
);
|
|
469
515
|
return createStats({ size: entries.length, type: 'directory' });
|
|
470
|
-
} catch {
|
|
471
|
-
|
|
472
|
-
|
|
516
|
+
} catch (error) {
|
|
517
|
+
// A directory-probe timeout is a stalled container, not "not a directory".
|
|
518
|
+
// Don't fall through to the readFile branch — that would wait through a
|
|
519
|
+
// SECOND full backstop (~2x the timeout) before surfacing.
|
|
520
|
+
if (isWorkspaceClientTimeoutError(error)) {
|
|
521
|
+
throw error;
|
|
522
|
+
}
|
|
523
|
+
const buffer = await bound(
|
|
524
|
+
(async (): Promise<Buffer> =>
|
|
525
|
+
normalizeReadFileContent(await sandbox.readFile(resolved)))(),
|
|
526
|
+
'readFile'
|
|
473
527
|
);
|
|
474
528
|
return createStats({ size: buffer.length, type: 'file' });
|
|
475
529
|
}
|
|
@@ -478,7 +532,10 @@ export function createCloudflareWorkspaceFS(
|
|
|
478
532
|
const sandbox = await resolveCloudflareSandbox(config);
|
|
479
533
|
const resolved = toSandboxPath(filePath, workspaceRoot);
|
|
480
534
|
const entries = normalizeFileList(
|
|
481
|
-
await
|
|
535
|
+
await bound(
|
|
536
|
+
sandbox.listFiles(resolved, { includeHidden: true }),
|
|
537
|
+
'listFiles'
|
|
538
|
+
)
|
|
482
539
|
);
|
|
483
540
|
if (options?.withFileTypes === true) {
|
|
484
541
|
return entries.map(createDirent);
|
|
@@ -487,21 +544,29 @@ export function createCloudflareWorkspaceFS(
|
|
|
487
544
|
}) as WorkspaceFS['readdir'],
|
|
488
545
|
mkdir: async (filePath: string, options?: MakeDirectoryOptions) => {
|
|
489
546
|
const sandbox = await resolveCloudflareSandbox(config);
|
|
490
|
-
await
|
|
491
|
-
|
|
492
|
-
|
|
547
|
+
await bound(
|
|
548
|
+
sandbox.mkdir(toSandboxPath(filePath, workspaceRoot), {
|
|
549
|
+
recursive: options?.recursive,
|
|
550
|
+
}),
|
|
551
|
+
'mkdir'
|
|
552
|
+
);
|
|
493
553
|
},
|
|
494
554
|
realpath: async (filePath: string) =>
|
|
495
555
|
toSandboxPath(filePath, workspaceRoot),
|
|
496
556
|
unlink: async (filePath: string) => {
|
|
497
557
|
const sandbox = await resolveCloudflareSandbox(config);
|
|
498
|
-
await
|
|
558
|
+
await bound(
|
|
559
|
+
sandbox.deleteFile(toSandboxPath(filePath, workspaceRoot)),
|
|
560
|
+
'deleteFile'
|
|
561
|
+
);
|
|
499
562
|
},
|
|
500
563
|
open: async (filePath: string, _flags: 'r') => {
|
|
501
564
|
const sandbox = await resolveCloudflareSandbox(config);
|
|
502
565
|
const resolved = toSandboxPath(filePath, workspaceRoot);
|
|
503
|
-
const buffer = await
|
|
504
|
-
|
|
566
|
+
const buffer = await bound(
|
|
567
|
+
(async (): Promise<Buffer> =>
|
|
568
|
+
normalizeReadFileContent(await sandbox.readFile(resolved)))(),
|
|
569
|
+
'readFile'
|
|
505
570
|
);
|
|
506
571
|
return {
|
|
507
572
|
read: async (
|
|
@@ -832,18 +897,29 @@ export async function executeCloudflareCode(
|
|
|
832
897
|
input.args,
|
|
833
898
|
ctx.shell
|
|
834
899
|
);
|
|
835
|
-
await ctx.sandbox.mkdir(tempDir, { recursive: true });
|
|
836
|
-
if (runtime.source != null) {
|
|
837
|
-
await ctx.sandbox.writeFile(
|
|
838
|
-
path.join(tempDir, runtime.fileName),
|
|
839
|
-
runtime.source,
|
|
840
|
-
{
|
|
841
|
-
encoding: 'utf8',
|
|
842
|
-
}
|
|
843
|
-
);
|
|
844
|
-
}
|
|
845
900
|
let execSucceeded = false;
|
|
846
901
|
try {
|
|
902
|
+
// Bound the temp-dir setup RPCs (they run BEFORE the bounded exec): a
|
|
903
|
+
// native-DO stall here would hang the host on a single mkdir/writeFile and
|
|
904
|
+
// burn the run budget. Keep them INSIDE the try so the finally cleanup still
|
|
905
|
+
// removes .lc-exec/<uuid> if setup throws — the uncancellable write can land
|
|
906
|
+
// late on a cold container, so an orphaned dir would otherwise accumulate.
|
|
907
|
+
await withClientTimeout(
|
|
908
|
+
ctx.sandbox.mkdir(tempDir, { recursive: true }),
|
|
909
|
+
clientFsTimeoutMs(ctx.timeoutMs),
|
|
910
|
+
'cloudflare sandbox mkdir'
|
|
911
|
+
);
|
|
912
|
+
if (runtime.source != null) {
|
|
913
|
+
await withClientTimeout(
|
|
914
|
+
ctx.sandbox.writeFile(
|
|
915
|
+
path.join(tempDir, runtime.fileName),
|
|
916
|
+
runtime.source,
|
|
917
|
+
{ encoding: 'utf8' }
|
|
918
|
+
),
|
|
919
|
+
clientFsTimeoutMs(ctx.timeoutMs),
|
|
920
|
+
'cloudflare sandbox writeFile'
|
|
921
|
+
);
|
|
922
|
+
}
|
|
847
923
|
const result = await execWithClientTimeout(
|
|
848
924
|
ctx.sandbox,
|
|
849
925
|
withInSandboxTimeout(runtime.command, ctx.timeoutMs),
|
|
@@ -27,6 +27,7 @@ import { resolve } from 'path';
|
|
|
27
27
|
import { tool } from '@langchain/core/tools';
|
|
28
28
|
import type { DynamicStructuredTool } from '@langchain/core/tools';
|
|
29
29
|
import type { WorkspaceFS } from './workspaceFS';
|
|
30
|
+
import { isWorkspaceClientTimeoutError } from './workspaceFS';
|
|
30
31
|
import type * as t from '@/types';
|
|
31
32
|
import {
|
|
32
33
|
getLocalCwd,
|
|
@@ -74,7 +75,12 @@ async function pathExists(fs: WorkspaceFS, p: string): Promise<boolean> {
|
|
|
74
75
|
try {
|
|
75
76
|
await fs.stat(p);
|
|
76
77
|
return true;
|
|
77
|
-
} catch {
|
|
78
|
+
} catch (error) {
|
|
79
|
+
// A stalled-RPC timeout is not "absent" — don't let it pick a weaker/wrong
|
|
80
|
+
// toolchain after waiting through the backstop; surface it.
|
|
81
|
+
if (isWorkspaceClientTimeoutError(error)) {
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
78
84
|
return false;
|
|
79
85
|
}
|
|
80
86
|
}
|
|
@@ -94,7 +100,12 @@ async function detect(cwd: string, fs: WorkspaceFS): Promise<Detection> {
|
|
|
94
100
|
if (await pathExists(fs, resolve(cwd, 'package.json'))) {
|
|
95
101
|
const pkgRaw = await fs
|
|
96
102
|
.readFile(resolve(cwd, 'package.json'), 'utf8')
|
|
97
|
-
.catch(() =>
|
|
103
|
+
.catch((error) => {
|
|
104
|
+
if (isWorkspaceClientTimeoutError(error)) {
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
return '';
|
|
108
|
+
});
|
|
98
109
|
if (pkgRaw.includes('"typescript"')) {
|
|
99
110
|
return {
|
|
100
111
|
kind: 'typescript',
|
|
@@ -124,7 +135,12 @@ async function detect(cwd: string, fs: WorkspaceFS): Promise<Detection> {
|
|
|
124
135
|
) {
|
|
125
136
|
const pyToml = await fs
|
|
126
137
|
.readFile(resolve(cwd, 'pyproject.toml'), 'utf8')
|
|
127
|
-
.catch(() =>
|
|
138
|
+
.catch((error) => {
|
|
139
|
+
if (isWorkspaceClientTimeoutError(error)) {
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
return '';
|
|
143
|
+
});
|
|
128
144
|
if (pyToml.includes('mypy')) {
|
|
129
145
|
return {
|
|
130
146
|
kind: 'python-mypy',
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { dirname } from 'path';
|
|
2
2
|
import type { WorkspaceFS } from './workspaceFS';
|
|
3
3
|
import type * as t from '@/types';
|
|
4
|
-
import { nodeWorkspaceFS } from './workspaceFS';
|
|
4
|
+
import { isWorkspaceClientTimeoutError, nodeWorkspaceFS } from './workspaceFS';
|
|
5
5
|
|
|
6
6
|
type Snapshot = { kind: 'absent' } | { kind: 'present'; content: Buffer };
|
|
7
7
|
|
|
@@ -41,7 +41,12 @@ export class LocalFileCheckpointerImpl implements t.LocalFileCheckpointer {
|
|
|
41
41
|
let info;
|
|
42
42
|
try {
|
|
43
43
|
info = await this.fs.stat(absolutePath);
|
|
44
|
-
} catch {
|
|
44
|
+
} catch (error) {
|
|
45
|
+
// A stalled-RPC timeout is NOT "file absent" — snapshotting it as absent
|
|
46
|
+
// would delete an existing file on revert. Surface it instead.
|
|
47
|
+
if (isWorkspaceClientTimeoutError(error)) {
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
45
50
|
this.snapshots.set(absolutePath, { kind: 'absent' });
|
|
46
51
|
return;
|
|
47
52
|
}
|
|
@@ -60,7 +65,14 @@ export class LocalFileCheckpointerImpl implements t.LocalFileCheckpointer {
|
|
|
60
65
|
let restored = 0;
|
|
61
66
|
for (const [path, snapshot] of this.snapshots.entries()) {
|
|
62
67
|
if (snapshot.kind === 'absent') {
|
|
63
|
-
await this.fs.unlink(path).catch(() =>
|
|
68
|
+
await this.fs.unlink(path).catch((error) => {
|
|
69
|
+
// A timed-out delete did NOT happen — surface it rather than counting
|
|
70
|
+
// the path as restored (which would falsely claim the workspace is
|
|
71
|
+
// back to its pre-write state).
|
|
72
|
+
if (isWorkspaceClientTimeoutError(error)) {
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
});
|
|
64
76
|
restored++;
|
|
65
77
|
continue;
|
|
66
78
|
}
|
|
@@ -68,7 +80,11 @@ export class LocalFileCheckpointerImpl implements t.LocalFileCheckpointer {
|
|
|
68
80
|
await this.fs.mkdir(dirname(path), { recursive: true });
|
|
69
81
|
await this.fs.writeFile(path, snapshot.content);
|
|
70
82
|
restored++;
|
|
71
|
-
} catch {
|
|
83
|
+
} catch (error) {
|
|
84
|
+
// A timed-out restore left the bad write in place — surface it.
|
|
85
|
+
if (isWorkspaceClientTimeoutError(error)) {
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
72
88
|
// Best-effort: ignore individual restore failures so the rest
|
|
73
89
|
// of the rewind continues.
|
|
74
90
|
}
|
|
@@ -3,6 +3,7 @@ import { createTwoFilesPatch } from 'diff';
|
|
|
3
3
|
import { tool } from '@langchain/core/tools';
|
|
4
4
|
import type { DynamicStructuredTool } from '@langchain/core/tools';
|
|
5
5
|
import type * as t from '@/types';
|
|
6
|
+
import { isWorkspaceClientTimeoutError } from './workspaceFS';
|
|
6
7
|
import {
|
|
7
8
|
createLocalBashProgrammaticToolCallingTool,
|
|
8
9
|
createLocalProgrammaticToolCallingTool,
|
|
@@ -294,8 +295,14 @@ async function revertStrictWrite(
|
|
|
294
295
|
} else {
|
|
295
296
|
await fs.unlink(path);
|
|
296
297
|
}
|
|
297
|
-
} catch {
|
|
298
|
-
|
|
298
|
+
} catch (error) {
|
|
299
|
+
// A timed-out revert leaves the rejected write on disk — the caller would
|
|
300
|
+
// otherwise claim "reverted to pre-write state" while the bad bytes remain.
|
|
301
|
+
// Surface it; for other failures stay best-effort (caller sees the original
|
|
302
|
+
// syntax error).
|
|
303
|
+
if (isWorkspaceClientTimeoutError(error)) {
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
299
306
|
}
|
|
300
307
|
}
|
|
301
308
|
|
|
@@ -538,7 +545,12 @@ export function createLocalWriteFileTool(
|
|
|
538
545
|
before = decoded.text;
|
|
539
546
|
encoding = decoded;
|
|
540
547
|
existed = true;
|
|
541
|
-
} catch {
|
|
548
|
+
} catch (error) {
|
|
549
|
+
// A stalled-RPC timeout is NOT "file absent" — treating it as such would
|
|
550
|
+
// overwrite an existing file with fresh content. Surface it instead.
|
|
551
|
+
if (isWorkspaceClientTimeoutError(error)) {
|
|
552
|
+
throw error;
|
|
553
|
+
}
|
|
542
554
|
existed = false;
|
|
543
555
|
}
|
|
544
556
|
|
|
@@ -546,7 +558,21 @@ export function createLocalWriteFileTool(
|
|
|
546
558
|
const finalText = encodeFile(input.content, encoding);
|
|
547
559
|
await fs.writeFile(path, finalText, 'utf8');
|
|
548
560
|
|
|
549
|
-
|
|
561
|
+
let syntax;
|
|
562
|
+
try {
|
|
563
|
+
syntax = await maybeRunSyntaxCheck(path, config);
|
|
564
|
+
} catch (error) {
|
|
565
|
+
// A validation-read timeout must still honor strict mode's revert
|
|
566
|
+
// contract: restore the pre-write state before surfacing (best-effort —
|
|
567
|
+
// the revert may itself time out on the same stalled container).
|
|
568
|
+
if (
|
|
569
|
+
isWorkspaceClientTimeoutError(error) &&
|
|
570
|
+
config.postEditSyntaxCheck === 'strict'
|
|
571
|
+
) {
|
|
572
|
+
await revertStrictWrite(fs, path, existed, before, encoding);
|
|
573
|
+
}
|
|
574
|
+
throw error;
|
|
575
|
+
}
|
|
550
576
|
|
|
551
577
|
const diff = existed
|
|
552
578
|
? summariseDiff(path, before, input.content)
|
|
@@ -642,7 +668,20 @@ export function createLocalEditFileTool(
|
|
|
642
668
|
const finalText = encodeFile(next, encoding);
|
|
643
669
|
await fs.writeFile(path, finalText, 'utf8');
|
|
644
670
|
|
|
645
|
-
|
|
671
|
+
let syntax;
|
|
672
|
+
try {
|
|
673
|
+
syntax = await maybeRunSyntaxCheck(path, config);
|
|
674
|
+
} catch (error) {
|
|
675
|
+
// As in write_file: a validation-read timeout still triggers strict
|
|
676
|
+
// mode's revert (edit_file always operates on an existing file).
|
|
677
|
+
if (
|
|
678
|
+
isWorkspaceClientTimeoutError(error) &&
|
|
679
|
+
config.postEditSyntaxCheck === 'strict'
|
|
680
|
+
) {
|
|
681
|
+
await revertStrictWrite(fs, path, true, original, encoding);
|
|
682
|
+
}
|
|
683
|
+
throw error;
|
|
684
|
+
}
|
|
646
685
|
|
|
647
686
|
const diff = summariseDiff(path, original, next);
|
|
648
687
|
const fuzzy = strategiesUsed.some((s) => s !== 'exact');
|
|
@@ -826,7 +865,12 @@ async function* walkFiles(
|
|
|
826
865
|
let entries;
|
|
827
866
|
try {
|
|
828
867
|
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
829
|
-
} catch {
|
|
868
|
+
} catch (error) {
|
|
869
|
+
// A stalled-RPC timeout must surface, not be silently skipped as an
|
|
870
|
+
// unreadable directory — that would corrupt search results ("no matches").
|
|
871
|
+
if (isWorkspaceClientTimeoutError(error)) {
|
|
872
|
+
throw error;
|
|
873
|
+
}
|
|
830
874
|
continue;
|
|
831
875
|
}
|
|
832
876
|
for (const entry of entries) {
|
|
@@ -1009,7 +1053,12 @@ async function fallbackGrep(
|
|
|
1009
1053
|
let stat;
|
|
1010
1054
|
try {
|
|
1011
1055
|
stat = await fs.stat(file);
|
|
1012
|
-
} catch {
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
// A stalled-RPC timeout must surface, not be skipped — skipping a file can
|
|
1058
|
+
// report "no matches" even when the (unreadable-due-to-stall) file matched.
|
|
1059
|
+
if (isWorkspaceClientTimeoutError(error)) {
|
|
1060
|
+
throw error;
|
|
1061
|
+
}
|
|
1013
1062
|
continue;
|
|
1014
1063
|
}
|
|
1015
1064
|
if (stat.size > FALLBACK_GREP_MAX_FILE_BYTES) {
|
|
@@ -1021,7 +1070,11 @@ async function fallbackGrep(
|
|
|
1021
1070
|
let content;
|
|
1022
1071
|
try {
|
|
1023
1072
|
content = await fs.readFile(file, 'utf8');
|
|
1024
|
-
} catch {
|
|
1073
|
+
} catch (error) {
|
|
1074
|
+
// As above: a client-timeout means a stalled read, not an unreadable file.
|
|
1075
|
+
if (isWorkspaceClientTimeoutError(error)) {
|
|
1076
|
+
throw error;
|
|
1077
|
+
}
|
|
1025
1078
|
continue;
|
|
1026
1079
|
}
|
|
1027
1080
|
if (content.includes('\0')) {
|
|
@@ -3,6 +3,10 @@ import { join } from 'path';
|
|
|
3
3
|
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'fs/promises';
|
|
4
4
|
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
|
5
5
|
import { LocalFileCheckpointerImpl } from '../FileCheckpointer';
|
|
6
|
+
import {
|
|
7
|
+
WorkspaceClientTimeoutError,
|
|
8
|
+
type WorkspaceFS,
|
|
9
|
+
} from '../workspaceFS';
|
|
6
10
|
|
|
7
11
|
/**
|
|
8
12
|
* Pins the LocalFileCheckpointer's per-Run snapshot/rewind contract.
|
|
@@ -63,6 +67,44 @@ describe('LocalFileCheckpointerImpl', () => {
|
|
|
63
67
|
await expect(stat(file)).rejects.toThrow();
|
|
64
68
|
});
|
|
65
69
|
|
|
70
|
+
it('rethrows a client-timeout from stat instead of snapshotting as absent', async () => {
|
|
71
|
+
// A stalled stat() must NOT be recorded as "absent" — that would delete an
|
|
72
|
+
// existing file on rewind. The timeout has to surface.
|
|
73
|
+
const fs = {
|
|
74
|
+
stat: async () => {
|
|
75
|
+
throw new WorkspaceClientTimeoutError(
|
|
76
|
+
'cloudflare sandbox listFiles exceeded 6000ms client-side timeout'
|
|
77
|
+
);
|
|
78
|
+
},
|
|
79
|
+
} as unknown as WorkspaceFS;
|
|
80
|
+
const cp = new LocalFileCheckpointerImpl(32 * 1024 * 1024, fs);
|
|
81
|
+
|
|
82
|
+
await expect(cp.captureBeforeWrite('/workspace/a.txt')).rejects.toThrow(
|
|
83
|
+
/client-side timeout/
|
|
84
|
+
);
|
|
85
|
+
// Nothing was snapshotted, so rewind is a no-op (no spurious deletion).
|
|
86
|
+
expect(await cp.rewind()).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('rewind rethrows a client-timeout from a restore write instead of claiming success', async () => {
|
|
90
|
+
// A stalled restore write leaves the bad bytes on disk — rewind must surface
|
|
91
|
+
// the timeout, not silently count the path as restored.
|
|
92
|
+
const fs = {
|
|
93
|
+
stat: async () => ({ isFile: () => true, size: 5 }),
|
|
94
|
+
readFile: async () => Buffer.from('orig\n'),
|
|
95
|
+
mkdir: async () => undefined,
|
|
96
|
+
writeFile: async () => {
|
|
97
|
+
throw new WorkspaceClientTimeoutError(
|
|
98
|
+
'cloudflare sandbox writeFile exceeded 6000ms client-side timeout'
|
|
99
|
+
);
|
|
100
|
+
},
|
|
101
|
+
} as unknown as WorkspaceFS;
|
|
102
|
+
const cp = new LocalFileCheckpointerImpl(32 * 1024 * 1024, fs);
|
|
103
|
+
|
|
104
|
+
await cp.captureBeforeWrite('/workspace/a.txt'); // snapshots 'present'
|
|
105
|
+
await expect(cp.rewind()).rejects.toThrow(/client-side timeout/);
|
|
106
|
+
});
|
|
107
|
+
|
|
66
108
|
it('rewinds across multiple files in a single pass', async () => {
|
|
67
109
|
const a = join(dir, 'multi-a.txt');
|
|
68
110
|
const b = join(dir, 'multi-b.txt');
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { extname } from 'path';
|
|
20
20
|
import type * as t from '@/types';
|
|
21
|
+
import { isWorkspaceClientTimeoutError } from './workspaceFS';
|
|
21
22
|
import {
|
|
22
23
|
getSpawn,
|
|
23
24
|
getWorkspaceFS,
|
|
@@ -168,7 +169,13 @@ const jsonCheck: SyntaxChecker = async (path, config) => {
|
|
|
168
169
|
// file → catch returns undefined → ok: true) or read a different
|
|
169
170
|
// file with the same absolute path. Codex P1 #24.
|
|
170
171
|
const fs = getWorkspaceFS(config);
|
|
171
|
-
const raw = await fs.readFile(path, 'utf8').catch(() =>
|
|
172
|
+
const raw = await fs.readFile(path, 'utf8').catch((error) => {
|
|
173
|
+
// A stalled read must not pass the syntax gate as ok:true — surface it.
|
|
174
|
+
if (isWorkspaceClientTimeoutError(error)) {
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
return undefined;
|
|
178
|
+
});
|
|
172
179
|
if (raw == null) return { ok: true };
|
|
173
180
|
try {
|
|
174
181
|
JSON.parse(raw);
|
|
@@ -237,7 +244,12 @@ export async function runPostEditSyntaxCheck(
|
|
|
237
244
|
};
|
|
238
245
|
}
|
|
239
246
|
return result;
|
|
240
|
-
} catch {
|
|
247
|
+
} catch (error) {
|
|
248
|
+
// Don't swallow a stalled-RPC timeout as "no checker outcome" (which would
|
|
249
|
+
// let the write through without a real syntax gate) — surface it.
|
|
250
|
+
if (isWorkspaceClientTimeoutError(error)) {
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
241
253
|
return null;
|
|
242
254
|
}
|
|
243
255
|
}
|
|
@@ -39,6 +39,33 @@ export type ReaddirEntry = {
|
|
|
39
39
|
isSymbolicLink(): boolean;
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Thrown when a {@link WorkspaceFS} operation is abandoned by a client-side
|
|
44
|
+
* backstop timeout (a stalled remote/sandbox RPC that never returned). This is
|
|
45
|
+
* DISTINCT from "file not found": callers that catch generic FS failures as
|
|
46
|
+
* absence — e.g. a pre-read before an overwrite, or a `stat` before snapshotting
|
|
47
|
+
* — MUST rethrow this so a stalled read isn't mistaken for a missing file (which
|
|
48
|
+
* would otherwise overwrite an existing file or snapshot it as absent).
|
|
49
|
+
*/
|
|
50
|
+
export class WorkspaceClientTimeoutError extends Error {
|
|
51
|
+
readonly code = 'WORKSPACE_CLIENT_TIMEOUT';
|
|
52
|
+
constructor(message: string) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = 'WorkspaceClientTimeoutError';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isWorkspaceClientTimeoutError(
|
|
59
|
+
error: unknown
|
|
60
|
+
): error is WorkspaceClientTimeoutError {
|
|
61
|
+
return (
|
|
62
|
+
error instanceof WorkspaceClientTimeoutError ||
|
|
63
|
+
(typeof error === 'object' &&
|
|
64
|
+
error !== null &&
|
|
65
|
+
(error as { code?: unknown }).code === 'WORKSPACE_CLIENT_TIMEOUT')
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
42
69
|
export interface WorkspaceFS {
|
|
43
70
|
readFile(path: string, encoding: 'utf8'): Promise<string>;
|
|
44
71
|
readFile(path: string): Promise<Buffer>;
|