zeitlich 0.2.40 → 0.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 (134) hide show
  1. package/README.md +12 -1
  2. package/dist/{activities-CvUrG3YG.d.cts → activities-Coafq5zr.d.cts} +2 -2
  3. package/dist/{activities-CULxRzJ1.d.ts → activities-CrN-ghLo.d.ts} +2 -2
  4. package/dist/adapters/sandbox/daytona/index.cjs +4 -23
  5. package/dist/adapters/sandbox/daytona/index.cjs.map +1 -1
  6. package/dist/adapters/sandbox/daytona/index.d.cts +18 -86
  7. package/dist/adapters/sandbox/daytona/index.d.ts +18 -86
  8. package/dist/adapters/sandbox/daytona/index.js +4 -23
  9. package/dist/adapters/sandbox/daytona/index.js.map +1 -1
  10. package/dist/adapters/sandbox/daytona/workflow.cjs +1 -7
  11. package/dist/adapters/sandbox/daytona/workflow.cjs.map +1 -1
  12. package/dist/adapters/sandbox/daytona/workflow.d.cts +9 -2
  13. package/dist/adapters/sandbox/daytona/workflow.d.ts +9 -2
  14. package/dist/adapters/sandbox/daytona/workflow.js +1 -7
  15. package/dist/adapters/sandbox/daytona/workflow.js.map +1 -1
  16. package/dist/adapters/sandbox/e2b/index.cjs +21 -3
  17. package/dist/adapters/sandbox/e2b/index.cjs.map +1 -1
  18. package/dist/adapters/sandbox/e2b/index.d.cts +48 -7
  19. package/dist/adapters/sandbox/e2b/index.d.ts +48 -7
  20. package/dist/adapters/sandbox/e2b/index.js +22 -5
  21. package/dist/adapters/sandbox/e2b/index.js.map +1 -1
  22. package/dist/adapters/sandbox/e2b/workflow.cjs.map +1 -1
  23. package/dist/adapters/sandbox/e2b/workflow.d.cts +4 -2
  24. package/dist/adapters/sandbox/e2b/workflow.d.ts +4 -2
  25. package/dist/adapters/sandbox/e2b/workflow.js.map +1 -1
  26. package/dist/adapters/sandbox/inmemory/index.cjs +11 -0
  27. package/dist/adapters/sandbox/inmemory/index.cjs.map +1 -1
  28. package/dist/adapters/sandbox/inmemory/index.d.cts +11 -3
  29. package/dist/adapters/sandbox/inmemory/index.d.ts +11 -3
  30. package/dist/adapters/sandbox/inmemory/index.js +11 -1
  31. package/dist/adapters/sandbox/inmemory/index.js.map +1 -1
  32. package/dist/adapters/sandbox/inmemory/workflow.cjs.map +1 -1
  33. package/dist/adapters/sandbox/inmemory/workflow.d.cts +4 -2
  34. package/dist/adapters/sandbox/inmemory/workflow.d.ts +4 -2
  35. package/dist/adapters/sandbox/inmemory/workflow.js.map +1 -1
  36. package/dist/adapters/thread/anthropic/index.cjs.map +1 -1
  37. package/dist/adapters/thread/anthropic/index.d.cts +6 -6
  38. package/dist/adapters/thread/anthropic/index.d.ts +6 -6
  39. package/dist/adapters/thread/anthropic/index.js.map +1 -1
  40. package/dist/adapters/thread/anthropic/workflow.d.cts +6 -6
  41. package/dist/adapters/thread/anthropic/workflow.d.ts +6 -6
  42. package/dist/adapters/thread/google-genai/index.cjs.map +1 -1
  43. package/dist/adapters/thread/google-genai/index.d.cts +6 -6
  44. package/dist/adapters/thread/google-genai/index.d.ts +6 -6
  45. package/dist/adapters/thread/google-genai/index.js.map +1 -1
  46. package/dist/adapters/thread/google-genai/workflow.d.cts +6 -6
  47. package/dist/adapters/thread/google-genai/workflow.d.ts +6 -6
  48. package/dist/adapters/thread/langchain/index.cjs.map +1 -1
  49. package/dist/adapters/thread/langchain/index.d.cts +6 -6
  50. package/dist/adapters/thread/langchain/index.d.ts +6 -6
  51. package/dist/adapters/thread/langchain/index.js.map +1 -1
  52. package/dist/adapters/thread/langchain/workflow.d.cts +6 -6
  53. package/dist/adapters/thread/langchain/workflow.d.ts +6 -6
  54. package/dist/index.cjs +316 -119
  55. package/dist/index.cjs.map +1 -1
  56. package/dist/index.d.cts +93 -17
  57. package/dist/index.d.ts +93 -17
  58. package/dist/index.js +317 -120
  59. package/dist/index.js.map +1 -1
  60. package/dist/{proxy-5EbwzaY4.d.cts → proxy-Bf7uI-Hw.d.cts} +1 -1
  61. package/dist/{proxy-wZufFfBh.d.ts → proxy-COqA95FW.d.ts} +1 -1
  62. package/dist/{thread-manager-BqBAIsED.d.ts → thread-manager-BhkOyQ1I.d.ts} +2 -2
  63. package/dist/{thread-manager-BNiIt5r8.d.ts → thread-manager-Bi1XlbpJ.d.ts} +2 -2
  64. package/dist/{thread-manager-DF8WuCRs.d.cts → thread-manager-BsLO3Fgc.d.cts} +2 -2
  65. package/dist/{thread-manager-BoN5DOvG.d.cts → thread-manager-wRVVBFgj.d.cts} +2 -2
  66. package/dist/{types-C7OoY7h8.d.ts → types-BkX4HLzi.d.ts} +1 -1
  67. package/dist/{types-CuISs0Ub.d.cts → types-C66-BVBr.d.cts} +1 -1
  68. package/dist/types-CJ7tCdl6.d.cts +266 -0
  69. package/dist/types-CJ7tCdl6.d.ts +266 -0
  70. package/dist/{types-DeQH84C_.d.ts → types-CdALEF3z.d.cts} +342 -23
  71. package/dist/{types-Cn2r3ol3.d.cts → types-ChAy_jSP.d.ts} +342 -23
  72. package/dist/types-CjY93AWZ.d.cts +84 -0
  73. package/dist/types-gVa5XCWD.d.ts +84 -0
  74. package/dist/{workflow-DhplIN65.d.cts → workflow-BwT5EybR.d.ts} +7 -6
  75. package/dist/{workflow-C2MZZj5K.d.ts → workflow-DMmiaw6w.d.cts} +7 -6
  76. package/dist/workflow.cjs +138 -77
  77. package/dist/workflow.cjs.map +1 -1
  78. package/dist/workflow.d.cts +4 -4
  79. package/dist/workflow.d.ts +4 -4
  80. package/dist/workflow.js +139 -78
  81. package/dist/workflow.js.map +1 -1
  82. package/package.json +17 -33
  83. package/src/adapters/sandbox/daytona/index.ts +25 -48
  84. package/src/adapters/sandbox/daytona/proxy.ts +7 -8
  85. package/src/adapters/sandbox/e2b/README.md +81 -0
  86. package/src/adapters/sandbox/e2b/index.ts +53 -11
  87. package/src/adapters/sandbox/e2b/keep-alive.test.ts +115 -0
  88. package/src/adapters/sandbox/e2b/proxy.ts +3 -2
  89. package/src/adapters/sandbox/e2b/types.ts +34 -2
  90. package/src/adapters/sandbox/inmemory/index.ts +21 -1
  91. package/src/adapters/sandbox/inmemory/proxy.ts +7 -3
  92. package/src/index.ts +1 -1
  93. package/src/lib/activity.ts +5 -0
  94. package/src/lib/sandbox/capability-types.test.ts +859 -0
  95. package/src/lib/sandbox/index.ts +1 -0
  96. package/src/lib/sandbox/manager.ts +187 -31
  97. package/src/lib/sandbox/types.ts +189 -46
  98. package/src/lib/session/index.ts +1 -0
  99. package/src/lib/session/session.integration.test.ts +58 -0
  100. package/src/lib/session/session.ts +109 -50
  101. package/src/lib/session/types.ts +189 -8
  102. package/src/lib/subagent/handler.ts +66 -43
  103. package/src/lib/subagent/subagent.integration.test.ts +2 -0
  104. package/src/lib/subagent/types.ts +492 -16
  105. package/src/lib/subagent/workflow.ts +11 -1
  106. package/src/lib/tool-router/auto-append-sandbox.integration.test.ts +158 -0
  107. package/src/lib/tool-router/index.ts +1 -1
  108. package/src/lib/tool-router/with-sandbox.ts +45 -2
  109. package/src/lib/virtual-fs/filesystem.ts +41 -16
  110. package/src/lib/virtual-fs/types.ts +19 -0
  111. package/src/lib/virtual-fs/virtual-fs.test.ts +204 -1
  112. package/src/tools/read-file/handler.test.ts +83 -0
  113. package/src/workflow.ts +3 -0
  114. package/tsup.config.ts +0 -4
  115. package/dist/adapters/sandbox/bedrock/index.cjs +0 -457
  116. package/dist/adapters/sandbox/bedrock/index.cjs.map +0 -1
  117. package/dist/adapters/sandbox/bedrock/index.d.cts +0 -25
  118. package/dist/adapters/sandbox/bedrock/index.d.ts +0 -25
  119. package/dist/adapters/sandbox/bedrock/index.js +0 -454
  120. package/dist/adapters/sandbox/bedrock/index.js.map +0 -1
  121. package/dist/adapters/sandbox/bedrock/workflow.cjs +0 -36
  122. package/dist/adapters/sandbox/bedrock/workflow.cjs.map +0 -1
  123. package/dist/adapters/sandbox/bedrock/workflow.d.cts +0 -29
  124. package/dist/adapters/sandbox/bedrock/workflow.d.ts +0 -29
  125. package/dist/adapters/sandbox/bedrock/workflow.js +0 -34
  126. package/dist/adapters/sandbox/bedrock/workflow.js.map +0 -1
  127. package/dist/types-DAsQ21Rt.d.ts +0 -74
  128. package/dist/types-lm8tMNJQ.d.cts +0 -74
  129. package/dist/types-yx0LzPGn.d.cts +0 -173
  130. package/dist/types-yx0LzPGn.d.ts +0 -173
  131. package/src/adapters/sandbox/bedrock/filesystem.ts +0 -340
  132. package/src/adapters/sandbox/bedrock/index.ts +0 -274
  133. package/src/adapters/sandbox/bedrock/proxy.ts +0 -59
  134. package/src/adapters/sandbox/bedrock/types.ts +0 -24
@@ -4,6 +4,7 @@ import { withSandbox } from "./with-sandbox";
4
4
  import type { RouterContext, ToolHandlerResponse } from "./types";
5
5
  import type { ToolResultConfig } from "../types";
6
6
  import type { Sandbox } from "../sandbox/types";
7
+ import { SandboxNotFoundError } from "../sandbox/types";
7
8
 
8
9
  // ---------------------------------------------------------------------------
9
10
  // withAutoAppend
@@ -332,6 +333,163 @@ describe("withSandbox", () => {
332
333
  ).rejects.toThrow("sandbox not found");
333
334
  });
334
335
 
336
+ it("propagates SandboxNotFoundError by default (no translate option)", async () => {
337
+ const manager = {
338
+ getSandbox: async (): Promise<Sandbox> => {
339
+ throw new SandboxNotFoundError("sb-gone");
340
+ },
341
+ };
342
+
343
+ const handler = async (): Promise<ToolHandlerResponse<null>> => ({
344
+ toolResponse: "ok",
345
+ data: null,
346
+ });
347
+
348
+ const wrapped = withSandbox(manager, handler);
349
+
350
+ await expect(
351
+ wrapped(
352
+ {},
353
+ {
354
+ threadId: "t",
355
+ toolCallId: "tc",
356
+ toolName: "Bash",
357
+ sandboxId: "sb-gone",
358
+ }
359
+ )
360
+ ).rejects.toBeInstanceOf(SandboxNotFoundError);
361
+ });
362
+
363
+ it("translates SandboxNotFoundError into a structured response when opted in", async () => {
364
+ const manager = {
365
+ getSandbox: async (): Promise<Sandbox> => {
366
+ throw new SandboxNotFoundError("sb-gone");
367
+ },
368
+ };
369
+
370
+ const innerCalled = vi.fn();
371
+ const handler = async (): Promise<ToolHandlerResponse<null>> => {
372
+ innerCalled();
373
+ return { toolResponse: "ok", data: null };
374
+ };
375
+
376
+ const wrapped = withSandbox(manager, handler, {
377
+ translateSandboxNotFound: true,
378
+ });
379
+
380
+ const result = await wrapped(
381
+ {},
382
+ {
383
+ threadId: "t",
384
+ toolCallId: "tc",
385
+ toolName: "Bash",
386
+ sandboxId: "sb-gone",
387
+ }
388
+ );
389
+
390
+ expect(result.toolResponse).toContain("Bash");
391
+ expect(result.toolResponse).toContain("execution environment");
392
+ expect(result.toolResponse).toContain("no longer available");
393
+ expect(result.toolResponse).toContain("could not be completed");
394
+ expect(result.toolResponse).not.toContain("session cannot continue");
395
+ expect(result.data).toBeNull();
396
+ expect(innerCalled).not.toHaveBeenCalled();
397
+ });
398
+
399
+ it("uses sandboxNotFoundToolResponse as the tool response when set", async () => {
400
+ const manager = {
401
+ getSandbox: async (): Promise<Sandbox> => {
402
+ throw new SandboxNotFoundError("sb-gone");
403
+ },
404
+ };
405
+
406
+ const handler = async (): Promise<ToolHandlerResponse<null>> => ({
407
+ toolResponse: "ok",
408
+ data: null,
409
+ });
410
+
411
+ const wrapped = withSandbox(manager, handler, {
412
+ translateSandboxNotFound: true,
413
+ sandboxNotFoundToolResponse:
414
+ "El entorno de ejecución ya no está disponible. Reinicia el agente.",
415
+ });
416
+
417
+ const result = await wrapped(
418
+ {},
419
+ {
420
+ threadId: "t",
421
+ toolCallId: "tc",
422
+ toolName: "Bash",
423
+ sandboxId: "sb-gone",
424
+ }
425
+ );
426
+
427
+ expect(result.toolResponse).toBe(
428
+ "El entorno de ejecución ya no está disponible. Reinicia el agente."
429
+ );
430
+ expect(result.toolResponse).not.toContain("execution environment");
431
+ expect(result.toolResponse).not.toContain("Bash");
432
+ expect(result.data).toBeNull();
433
+ });
434
+
435
+ it("ignores sandboxNotFoundToolResponse when translateSandboxNotFound is not enabled", async () => {
436
+ const manager = {
437
+ getSandbox: async (): Promise<Sandbox> => {
438
+ throw new SandboxNotFoundError("sb-gone");
439
+ },
440
+ };
441
+
442
+ const handler = async (): Promise<ToolHandlerResponse<null>> => ({
443
+ toolResponse: "ok",
444
+ data: null,
445
+ });
446
+
447
+ const wrapped = withSandbox(manager, handler, {
448
+ sandboxNotFoundToolResponse: "should not be used",
449
+ });
450
+
451
+ await expect(
452
+ wrapped(
453
+ {},
454
+ {
455
+ threadId: "t",
456
+ toolCallId: "tc",
457
+ toolName: "Bash",
458
+ sandboxId: "sb-gone",
459
+ }
460
+ )
461
+ ).rejects.toBeInstanceOf(SandboxNotFoundError);
462
+ });
463
+
464
+ it("does not translate non-SandboxNotFoundError errors when translate option is set", async () => {
465
+ const manager = {
466
+ getSandbox: async (): Promise<Sandbox> => {
467
+ throw new Error("network down");
468
+ },
469
+ };
470
+
471
+ const handler = async (): Promise<ToolHandlerResponse<null>> => ({
472
+ toolResponse: "ok",
473
+ data: null,
474
+ });
475
+
476
+ const wrapped = withSandbox(manager, handler, {
477
+ translateSandboxNotFound: true,
478
+ });
479
+
480
+ await expect(
481
+ wrapped(
482
+ {},
483
+ {
484
+ threadId: "t",
485
+ toolCallId: "tc",
486
+ toolName: "Bash",
487
+ sandboxId: "sb-gone",
488
+ }
489
+ )
490
+ ).rejects.toThrow("network down");
491
+ });
492
+
335
493
  it("passes all RouterContext fields through to inner handler", async () => {
336
494
  const mockSandbox = createMockSandbox();
337
495
  const manager = { getSandbox: async () => mockSandbox };
@@ -1,7 +1,7 @@
1
1
  export { createToolRouter, defineTool, hasNoOtherToolCalls } from "./router";
2
2
  export { withAutoAppend } from "./auto-append";
3
3
  export { withSandbox } from "./with-sandbox";
4
- export type { SandboxContext } from "./with-sandbox";
4
+ export type { SandboxContext, WithSandboxOptions } from "./with-sandbox";
5
5
 
6
6
  export type {
7
7
  ToolDefinition,
@@ -1,4 +1,5 @@
1
1
  import type { Sandbox } from "../sandbox/types";
2
+ import { SandboxNotFoundError } from "../sandbox/types";
2
3
  import type { JsonValue } from "../state/types";
3
4
  import type {
4
5
  ActivityToolHandler,
@@ -6,6 +7,33 @@ import type {
6
7
  ToolHandlerResponse,
7
8
  } from "./types";
8
9
 
10
+ /**
11
+ * Options for {@link withSandbox}.
12
+ */
13
+ export interface WithSandboxOptions {
14
+ /**
15
+ * If `true`, a {@link SandboxNotFoundError} thrown by `manager.getSandbox`
16
+ * is translated into a structured tool-handler response (instead of
17
+ * propagating). This lets the agent return a graceful error to the model
18
+ * rather than crashing the workflow when the backing sandbox has been
19
+ * killed mid-run (e.g. because the E2B `timeoutMs` lifetime elapsed).
20
+ *
21
+ * Off by default to preserve the existing contract for callers that rely
22
+ * on the error bubbling out. New callers should generally enable this in
23
+ * combination with the E2B `keepAliveMs` provider option.
24
+ *
25
+ * @default false
26
+ */
27
+ translateSandboxNotFound?: boolean;
28
+ /**
29
+ * Custom tool response returned to the agent when the backing sandbox
30
+ * is not found and `translateSandboxNotFound` is `true`. Defaults to a
31
+ * generic English message. Use this to localize, match agent persona,
32
+ * or give the model more specific recovery instructions.
33
+ */
34
+ sandboxNotFoundToolResponse?: string;
35
+ }
36
+
9
37
  /**
10
38
  * Extended router context with a resolved {@link Sandbox} instance.
11
39
  *
@@ -65,13 +93,15 @@ export function withSandbox<
65
93
  handler: (
66
94
  args: TArgs,
67
95
  context: RouterContext & { sandbox: TSandbox; sandboxId: string }
68
- ) => Promise<ToolHandlerResponse<TResult, TToolResponse>>
96
+ ) => Promise<ToolHandlerResponse<TResult, TToolResponse>>,
97
+ options?: WithSandboxOptions
69
98
  ): ActivityToolHandler<
70
99
  TArgs,
71
100
  TResult | null,
72
101
  RouterContext,
73
102
  TToolResponse | string
74
103
  > {
104
+ const translateSandboxNotFound = options?.translateSandboxNotFound ?? false;
75
105
  return async (args, context) => {
76
106
  if (!context.sandboxId) {
77
107
  return {
@@ -79,7 +109,20 @@ export function withSandbox<
79
109
  data: null,
80
110
  };
81
111
  }
82
- const sandbox = await manager.getSandbox(context.sandboxId);
112
+ let sandbox: TSandbox;
113
+ try {
114
+ sandbox = await manager.getSandbox(context.sandboxId);
115
+ } catch (err) {
116
+ if (translateSandboxNotFound && err instanceof SandboxNotFoundError) {
117
+ return {
118
+ toolResponse:
119
+ options?.sandboxNotFoundToolResponse ??
120
+ `Error: the execution environment for the ${context.toolName} tool is no longer available, so this tool call could not be completed.`,
121
+ data: null,
122
+ };
123
+ }
124
+ throw err;
125
+ }
83
126
  return handler(args, { ...context, sandbox, sandboxId: context.sandboxId });
84
127
  };
85
128
  }
@@ -104,7 +104,7 @@ export class VirtualFileSystem<
104
104
  if (inline !== undefined) return inline;
105
105
  const entry = this.entries.get(norm);
106
106
  if (!entry) throw new Error(`ENOENT: no such file: ${path}`);
107
- return this.resolver.readFile(entry.id, this.ctx, entry.metadata);
107
+ return this.readEntryContent(entry);
108
108
  }
109
109
 
110
110
  async readFileBuffer(path: string): Promise<Uint8Array> {
@@ -113,9 +113,25 @@ export class VirtualFileSystem<
113
113
  if (inline !== undefined) return new TextEncoder().encode(inline);
114
114
  const entry = this.entries.get(norm);
115
115
  if (!entry) throw new Error(`ENOENT: no such file: ${path}`);
116
+ if (entry.inlineContent !== undefined) {
117
+ return new TextEncoder().encode(entry.inlineContent);
118
+ }
116
119
  return this.resolver.readFileBuffer(entry.id, this.ctx, entry.metadata);
117
120
  }
118
121
 
122
+ /**
123
+ * Resolve the string content for an entry, preferring inline content
124
+ * carried on the entry itself before consulting the resolver. Used by
125
+ * `readFile`, `appendFile`, and `cp` so all read paths agree on the
126
+ * lookup precedence.
127
+ */
128
+ private readEntryContent(entry: FileEntry<TMeta>): Promise<string> {
129
+ if (entry.inlineContent !== undefined) {
130
+ return Promise.resolve(entry.inlineContent);
131
+ }
132
+ return this.resolver.readFile(entry.id, this.ctx, entry.metadata);
133
+ }
134
+
119
135
  // --------------------------------------------------------------------------
120
136
  // Metadata operations — pure, resolved from the tree
121
137
  // --------------------------------------------------------------------------
@@ -196,6 +212,11 @@ export class VirtualFileSystem<
196
212
  const existing = this.entries.get(norm);
197
213
 
198
214
  if (existing) {
215
+ if (existing.inlineContent !== undefined) {
216
+ throw new Error(
217
+ `EROFS: cannot write to inline (read-only) entry: ${path}`
218
+ );
219
+ }
199
220
  await this.resolver.writeFile(
200
221
  existing.id,
201
222
  content,
@@ -230,11 +251,13 @@ export class VirtualFileSystem<
230
251
  return this.writeFile(path, content);
231
252
  }
232
253
 
233
- const current = await this.resolver.readFile(
234
- existing.id,
235
- this.ctx,
236
- existing.metadata
237
- );
254
+ if (existing.inlineContent !== undefined) {
255
+ throw new Error(
256
+ `EROFS: cannot append to inline (read-only) entry: ${path}`
257
+ );
258
+ }
259
+
260
+ const current = await this.readEntryContent(existing);
238
261
  const appended =
239
262
  typeof content === "string"
240
263
  ? current + content
@@ -283,6 +306,11 @@ export class VirtualFileSystem<
283
306
  const entry = this.entries.get(norm);
284
307
 
285
308
  if (entry) {
309
+ if (entry.inlineContent !== undefined) {
310
+ throw new Error(
311
+ `EROFS: cannot remove inline (read-only) entry: ${path}`
312
+ );
313
+ }
286
314
  await this.resolver.deleteFile(entry.id, this.ctx, entry.metadata);
287
315
  this.entries.delete(norm);
288
316
  this.mutations.push({ type: "remove", path: norm });
@@ -296,6 +324,11 @@ export class VirtualFileSystem<
296
324
  const prefix = norm === "/" ? "/" : norm + "/";
297
325
  for (const [p, e] of this.entries) {
298
326
  if (p.startsWith(prefix)) {
327
+ if (e.inlineContent !== undefined) {
328
+ throw new Error(
329
+ `EROFS: cannot remove inline (read-only) entry: ${p}`
330
+ );
331
+ }
299
332
  await this.resolver.deleteFile(e.id, this.ctx, e.metadata);
300
333
  this.entries.delete(p);
301
334
  this.mutations.push({ type: "remove", path: p });
@@ -323,11 +356,7 @@ export class VirtualFileSystem<
323
356
 
324
357
  const entry = this.entries.get(normSrc);
325
358
  if (entry) {
326
- const content = await this.resolver.readFile(
327
- entry.id,
328
- this.ctx,
329
- entry.metadata
330
- );
359
+ const content = await this.readEntryContent(entry);
331
360
  await this.writeFile(normDest, content);
332
361
  return;
333
362
  }
@@ -343,11 +372,7 @@ export class VirtualFileSystem<
343
372
  for (const [p, e] of this.entries) {
344
373
  if (p.startsWith(prefix)) {
345
374
  const relative = p.slice(normSrc.length);
346
- const content = await this.resolver.readFile(
347
- e.id,
348
- this.ctx,
349
- e.metadata
350
- );
375
+ const content = await this.readEntryContent(e);
351
376
  await this.writeFile(normDest + relative, content);
352
377
  }
353
378
  }
@@ -20,6 +20,25 @@ export interface FileEntry<TMeta = FileEntryMetadata> {
20
20
  /** ISO-8601 date string (JSON-safe) */
21
21
  mtime: string;
22
22
  metadata: TMeta;
23
+ /**
24
+ * Optional inline content carried directly on the entry. When present the
25
+ * {@link VirtualFileSystem} returns this string from `readFile` /
26
+ * `readFileBuffer` (and uses it as the source for `cp` / `appendFile`)
27
+ * without consulting the resolver.
28
+ *
29
+ * Use this for files that exist purely in workflow state and have no
30
+ * backing in the consumer's data layer (e.g. skill resources bundled at
31
+ * session creation time). Because the content travels with the entry,
32
+ * any tool handler that constructs a `VirtualFileSystem` from `fileTree`
33
+ * sees the same content — no separate `inlineFiles` plumbing required.
34
+ *
35
+ * Read-only: entries with `inlineContent` reject in-place mutations
36
+ * (`writeFile`, `appendFile`, `rm`, `mv` of the entry, `cp` over the
37
+ * entry as destination). The resolver has no contract for the synthetic
38
+ * `id` these entries carry, so attempting a mutation throws an `EROFS`
39
+ * error instead of silently routing a doomed call through the resolver.
40
+ */
41
+ inlineContent?: string;
23
42
  }
24
43
 
25
44
  // ============================================================================
@@ -1,4 +1,4 @@
1
- import { describe, expect, it, beforeEach } from "vitest";
1
+ import { describe, expect, it, beforeEach, vi } from "vitest";
2
2
  import type { FileEntry, FileResolver } from "./types";
3
3
  import { VirtualFileSystem } from "./filesystem";
4
4
  import { applyVirtualTreeMutations } from "./mutations";
@@ -387,6 +387,209 @@ describe("VirtualFileSystem — inlineFiles", () => {
387
387
  });
388
388
  });
389
389
 
390
+ // ============================================================================
391
+ // VirtualFileSystem — entry.inlineContent
392
+ // ============================================================================
393
+
394
+ describe("VirtualFileSystem — entry.inlineContent", () => {
395
+ const inlineEntry: FileEntry = {
396
+ id: "skill:code-review:checklist.md",
397
+ path: "/skills/code-review/checklist.md",
398
+ size: 18,
399
+ mtime: "2025-01-01T00:00:00.000Z",
400
+ metadata: {},
401
+ inlineContent: "# Review checklist",
402
+ };
403
+
404
+ function createFsWithInlineEntry(): VirtualFileSystem<TestCtx> {
405
+ const { resolver } = createMockResolver();
406
+ return new VirtualFileSystem([...sampleTree, inlineEntry], resolver, ctx);
407
+ }
408
+
409
+ it("readFile returns entry.inlineContent without hitting the resolver", async () => {
410
+ const { resolver, store } = createMockResolver();
411
+ const readFileSpy = vi.spyOn(resolver, "readFile");
412
+ const fs = new VirtualFileSystem(
413
+ [...sampleTree, inlineEntry],
414
+ resolver,
415
+ ctx
416
+ );
417
+
418
+ const content = await fs.readFile("/skills/code-review/checklist.md");
419
+ expect(content).toBe("# Review checklist");
420
+ expect(readFileSpy).not.toHaveBeenCalled();
421
+ expect(store.size).toBeGreaterThan(0);
422
+ });
423
+
424
+ it("readFileBuffer returns entry.inlineContent encoded as Uint8Array", async () => {
425
+ const fs = createFsWithInlineEntry();
426
+ const buf = await fs.readFileBuffer("/skills/code-review/checklist.md");
427
+ expect(buf).toBeInstanceOf(Uint8Array);
428
+ expect(new TextDecoder().decode(buf)).toBe("# Review checklist");
429
+ });
430
+
431
+ it("entry.inlineContent files participate in directory inference", async () => {
432
+ const fs = createFsWithInlineEntry();
433
+ expect(await fs.exists("/skills")).toBe(true);
434
+ expect(await fs.exists("/skills/code-review")).toBe(true);
435
+ expect(await fs.readdir("/skills/code-review")).toContain("checklist.md");
436
+ });
437
+
438
+ it("inlineFiles map still wins over entry.inlineContent for the same path", async () => {
439
+ const { resolver } = createMockResolver();
440
+ const fs = new VirtualFileSystem(
441
+ [...sampleTree, inlineEntry],
442
+ resolver,
443
+ ctx,
444
+ "/",
445
+ { "/skills/code-review/checklist.md": "OVERRIDE" }
446
+ );
447
+ expect(await fs.readFile("/skills/code-review/checklist.md")).toBe(
448
+ "OVERRIDE"
449
+ );
450
+ });
451
+
452
+ it("readFile resolves entry.inlineContent for non-normalized paths (no leading slash)", async () => {
453
+ const fs = createFsWithInlineEntry();
454
+ const content = await fs.readFile("skills/code-review/checklist.md");
455
+ expect(content).toBe("# Review checklist");
456
+ });
457
+
458
+ it("empty-string entry.inlineContent is served without falling through to the resolver", async () => {
459
+ const { resolver } = createMockResolver();
460
+ const readFileSpy = vi.spyOn(resolver, "readFile");
461
+ const fs = new VirtualFileSystem(
462
+ [
463
+ ...sampleTree,
464
+ {
465
+ id: "skill:empty",
466
+ path: "/skills/empty.md",
467
+ size: 0,
468
+ mtime: "2025-01-01T00:00:00.000Z",
469
+ metadata: {},
470
+ inlineContent: "",
471
+ } satisfies FileEntry,
472
+ ],
473
+ resolver,
474
+ ctx
475
+ );
476
+ expect(await fs.readFile("/skills/empty.md")).toBe("");
477
+ expect(readFileSpy).not.toHaveBeenCalled();
478
+ });
479
+
480
+ it("stat reports entry.inlineContent files as files", async () => {
481
+ const fs = createFsWithInlineEntry();
482
+ const stat = await fs.stat("/skills/code-review/checklist.md");
483
+ expect(stat.isFile).toBe(true);
484
+ expect(stat.isDirectory).toBe(false);
485
+ });
486
+
487
+ it("non-inline entries in the same tree still go through the resolver", async () => {
488
+ const { resolver } = createMockResolver();
489
+ const readFileSpy = vi.spyOn(resolver, "readFile");
490
+ const fs = new VirtualFileSystem(
491
+ [...sampleTree, inlineEntry],
492
+ resolver,
493
+ ctx
494
+ );
495
+ const content = await fs.readFile("/src/index.ts");
496
+ expect(content).toBe('console.log("hello");');
497
+ expect(readFileSpy).toHaveBeenCalledTimes(1);
498
+ });
499
+ });
500
+
501
+ // ============================================================================
502
+ // VirtualFileSystem — entry.inlineContent read-only contract
503
+ // ============================================================================
504
+
505
+ describe("VirtualFileSystem — entry.inlineContent read-only", () => {
506
+ const inlineEntry: FileEntry = {
507
+ id: "skill:demo:notes.md",
508
+ path: "/skills/demo/notes.md",
509
+ size: 8,
510
+ mtime: "2025-01-01T00:00:00.000Z",
511
+ metadata: {},
512
+ inlineContent: "original",
513
+ };
514
+
515
+ function makeFs(): {
516
+ fs: VirtualFileSystem<TestCtx>;
517
+ resolver: FileResolver<TestCtx>;
518
+ spies: {
519
+ writeFile: ReturnType<typeof vi.spyOn>;
520
+ deleteFile: ReturnType<typeof vi.spyOn>;
521
+ };
522
+ } {
523
+ const { resolver } = createMockResolver();
524
+ const writeFile = vi.spyOn(resolver, "writeFile");
525
+ const deleteFile = vi.spyOn(resolver, "deleteFile");
526
+ const fs = new VirtualFileSystem(
527
+ [...sampleTree, inlineEntry],
528
+ resolver,
529
+ ctx
530
+ );
531
+ return { fs, resolver, spies: { writeFile, deleteFile } };
532
+ }
533
+
534
+ it("writeFile on inline entry throws EROFS and never calls resolver.writeFile", async () => {
535
+ const { fs, spies } = makeFs();
536
+ await expect(fs.writeFile("/skills/demo/notes.md", "new")).rejects.toThrow(
537
+ /EROFS/
538
+ );
539
+ expect(spies.writeFile).not.toHaveBeenCalled();
540
+ });
541
+
542
+ it("appendFile on inline entry throws EROFS and never calls resolver.writeFile", async () => {
543
+ const { fs, spies } = makeFs();
544
+ await expect(
545
+ fs.appendFile("/skills/demo/notes.md", "more")
546
+ ).rejects.toThrow(/EROFS/);
547
+ expect(spies.writeFile).not.toHaveBeenCalled();
548
+ });
549
+
550
+ it("rm on inline entry throws EROFS and never calls resolver.deleteFile", async () => {
551
+ const { fs, spies } = makeFs();
552
+ await expect(fs.rm("/skills/demo/notes.md")).rejects.toThrow(/EROFS/);
553
+ expect(spies.deleteFile).not.toHaveBeenCalled();
554
+ });
555
+
556
+ it("rm recursive on a directory containing an inline entry throws EROFS", async () => {
557
+ const { fs, spies } = makeFs();
558
+ await expect(fs.rm("/skills", { recursive: true })).rejects.toThrow(
559
+ /EROFS/
560
+ );
561
+ expect(spies.deleteFile).not.toHaveBeenCalled();
562
+ });
563
+
564
+ it("mv of an inline entry throws EROFS (rm step rejects after copy)", async () => {
565
+ const { fs } = makeFs();
566
+ await expect(fs.mv("/skills/demo/notes.md", "/copy.md")).rejects.toThrow(
567
+ /EROFS/
568
+ );
569
+ });
570
+
571
+ it("cp from inline source to a fresh destination uses inlineContent and creates via resolver", async () => {
572
+ const { fs } = makeFs();
573
+ await fs.cp("/skills/demo/notes.md", "/copied.md");
574
+ expect(await fs.readFile("/copied.md")).toBe("original");
575
+ expect(await fs.exists("/copied.md")).toBe(true);
576
+ });
577
+
578
+ it("appendFile to a missing path under an inline-only directory delegates to writeFile", async () => {
579
+ const { fs } = makeFs();
580
+ await fs.appendFile("/skills/demo/new.md", "hello");
581
+ expect(await fs.readFile("/skills/demo/new.md")).toBe("hello");
582
+ });
583
+
584
+ it("cp over an inline destination throws EROFS", async () => {
585
+ const { fs, spies } = makeFs();
586
+ await expect(
587
+ fs.cp("/src/index.ts", "/skills/demo/notes.md")
588
+ ).rejects.toThrow(/EROFS/);
589
+ expect(spies.writeFile).not.toHaveBeenCalled();
590
+ });
591
+ });
592
+
390
593
  // ============================================================================
391
594
  // createVirtualFsActivities
392
595
  // ============================================================================
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { readFileHandler } from "./handler";
3
+ import { VirtualFileSystem } from "../../lib/virtual-fs/filesystem";
4
+ import type { FileEntry, FileResolver } from "../../lib/virtual-fs/types";
5
+
6
+ interface TestCtx {
7
+ projectId: string;
8
+ }
9
+
10
+ function createNoopResolver(): FileResolver<TestCtx> {
11
+ return {
12
+ resolveEntries: async () => [],
13
+ readFile: vi.fn(async () => {
14
+ throw new Error(
15
+ "resolver.readFile should not be called for inline entries"
16
+ );
17
+ }),
18
+ readFileBuffer: vi.fn(async () => {
19
+ throw new Error(
20
+ "resolver.readFileBuffer should not be called for inline entries"
21
+ );
22
+ }),
23
+ writeFile: vi.fn(async () => {}),
24
+ createFile: vi.fn(async () => {
25
+ throw new Error("not implemented");
26
+ }),
27
+ deleteFile: vi.fn(async () => {}),
28
+ };
29
+ }
30
+
31
+ const ctx: TestCtx = { projectId: "p" };
32
+
33
+ const skillEntry: FileEntry = {
34
+ id: "skill:demo:notes.md",
35
+ path: "/skills/demo/notes.md",
36
+ size: 9,
37
+ mtime: "2025-01-01T00:00:00.000Z",
38
+ metadata: {},
39
+ inlineContent: "# notes\n",
40
+ };
41
+
42
+ describe("readFileHandler — entry.inlineContent (skill resources)", () => {
43
+ it("returns the inline content even when the resolver has no backing", async () => {
44
+ const resolver = createNoopResolver();
45
+ const fs = new VirtualFileSystem([skillEntry], resolver, ctx);
46
+
47
+ const response = await readFileHandler(
48
+ { path: "/skills/demo/notes.md" },
49
+ {
50
+ threadId: "t",
51
+ toolCallId: "tc",
52
+ toolName: "FileRead",
53
+ // The handler signature requires SandboxContext, which expects a
54
+ // SandboxFileSystem — VirtualFileSystem implements that interface.
55
+ sandbox: { fs } as never,
56
+ sandboxId: "ignored",
57
+ }
58
+ );
59
+
60
+ expect(typeof response.toolResponse).toBe("string");
61
+ expect(response.toolResponse).toContain("# notes");
62
+ expect(response.data).not.toBeNull();
63
+ });
64
+
65
+ it("works with non-normalized agent-supplied paths (no leading slash)", async () => {
66
+ const resolver = createNoopResolver();
67
+ const fs = new VirtualFileSystem([skillEntry], resolver, ctx);
68
+
69
+ const response = await readFileHandler(
70
+ { path: "skills/demo/notes.md" },
71
+ {
72
+ threadId: "t",
73
+ toolCallId: "tc",
74
+ toolName: "FileRead",
75
+ sandbox: { fs } as never,
76
+ sandboxId: "ignored",
77
+ }
78
+ );
79
+
80
+ expect(response.toolResponse).toContain("# notes");
81
+ expect(response.data).not.toBeNull();
82
+ });
83
+ });