zeitlich 0.2.37 → 0.2.39

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 (172) hide show
  1. package/README.md +18 -0
  2. package/dist/{activities-Bb-nAjwQ.d.ts → activities-Bmu7XnaG.d.ts} +4 -4
  3. package/dist/{activities-vkI4_3CC.d.cts → activities-ByBFLvm2.d.cts} +4 -4
  4. package/dist/adapter-id-BB-mmrts.d.cts +17 -0
  5. package/dist/adapter-id-BB-mmrts.d.ts +17 -0
  6. package/dist/adapter-id-CMwVrVqv.d.cts +17 -0
  7. package/dist/adapter-id-CMwVrVqv.d.ts +17 -0
  8. package/dist/adapter-id-CbY2zeSt.d.cts +17 -0
  9. package/dist/adapter-id-CbY2zeSt.d.ts +17 -0
  10. package/dist/adapters/sandbox/bedrock/index.cjs +3 -3
  11. package/dist/adapters/sandbox/bedrock/index.cjs.map +1 -1
  12. package/dist/adapters/sandbox/bedrock/index.d.cts +6 -6
  13. package/dist/adapters/sandbox/bedrock/index.d.ts +6 -6
  14. package/dist/adapters/sandbox/bedrock/index.js +3 -3
  15. package/dist/adapters/sandbox/bedrock/index.js.map +1 -1
  16. package/dist/adapters/sandbox/bedrock/workflow.d.cts +2 -2
  17. package/dist/adapters/sandbox/bedrock/workflow.d.ts +2 -2
  18. package/dist/adapters/sandbox/daytona/index.cjs +3 -3
  19. package/dist/adapters/sandbox/daytona/index.cjs.map +1 -1
  20. package/dist/adapters/sandbox/daytona/index.d.cts +4 -4
  21. package/dist/adapters/sandbox/daytona/index.d.ts +4 -4
  22. package/dist/adapters/sandbox/daytona/index.js +3 -3
  23. package/dist/adapters/sandbox/daytona/index.js.map +1 -1
  24. package/dist/adapters/sandbox/daytona/workflow.d.cts +1 -1
  25. package/dist/adapters/sandbox/daytona/workflow.d.ts +1 -1
  26. package/dist/adapters/sandbox/e2b/index.cjs +26 -14
  27. package/dist/adapters/sandbox/e2b/index.cjs.map +1 -1
  28. package/dist/adapters/sandbox/e2b/index.d.cts +24 -4
  29. package/dist/adapters/sandbox/e2b/index.d.ts +24 -4
  30. package/dist/adapters/sandbox/e2b/index.js +26 -14
  31. package/dist/adapters/sandbox/e2b/index.js.map +1 -1
  32. package/dist/adapters/sandbox/e2b/workflow.d.cts +1 -1
  33. package/dist/adapters/sandbox/e2b/workflow.d.ts +1 -1
  34. package/dist/adapters/sandbox/inmemory/index.cjs +3 -3
  35. package/dist/adapters/sandbox/inmemory/index.cjs.map +1 -1
  36. package/dist/adapters/sandbox/inmemory/index.d.cts +4 -4
  37. package/dist/adapters/sandbox/inmemory/index.d.ts +4 -4
  38. package/dist/adapters/sandbox/inmemory/index.js +3 -3
  39. package/dist/adapters/sandbox/inmemory/index.js.map +1 -1
  40. package/dist/adapters/sandbox/inmemory/workflow.d.cts +1 -1
  41. package/dist/adapters/sandbox/inmemory/workflow.d.ts +1 -1
  42. package/dist/adapters/thread/anthropic/index.cjs +150 -13
  43. package/dist/adapters/thread/anthropic/index.cjs.map +1 -1
  44. package/dist/adapters/thread/anthropic/index.d.cts +9 -8
  45. package/dist/adapters/thread/anthropic/index.d.ts +9 -8
  46. package/dist/adapters/thread/anthropic/index.js +150 -14
  47. package/dist/adapters/thread/anthropic/index.js.map +1 -1
  48. package/dist/adapters/thread/anthropic/workflow.cjs +9 -3
  49. package/dist/adapters/thread/anthropic/workflow.cjs.map +1 -1
  50. package/dist/adapters/thread/anthropic/workflow.d.cts +6 -5
  51. package/dist/adapters/thread/anthropic/workflow.d.ts +6 -5
  52. package/dist/adapters/thread/anthropic/workflow.js +9 -4
  53. package/dist/adapters/thread/anthropic/workflow.js.map +1 -1
  54. package/dist/adapters/thread/google-genai/index.cjs +154 -13
  55. package/dist/adapters/thread/google-genai/index.cjs.map +1 -1
  56. package/dist/adapters/thread/google-genai/index.d.cts +6 -5
  57. package/dist/adapters/thread/google-genai/index.d.ts +6 -5
  58. package/dist/adapters/thread/google-genai/index.js +154 -14
  59. package/dist/adapters/thread/google-genai/index.js.map +1 -1
  60. package/dist/adapters/thread/google-genai/workflow.cjs +9 -3
  61. package/dist/adapters/thread/google-genai/workflow.cjs.map +1 -1
  62. package/dist/adapters/thread/google-genai/workflow.d.cts +6 -5
  63. package/dist/adapters/thread/google-genai/workflow.d.ts +6 -5
  64. package/dist/adapters/thread/google-genai/workflow.js +9 -4
  65. package/dist/adapters/thread/google-genai/workflow.js.map +1 -1
  66. package/dist/adapters/thread/index.cjs +16 -0
  67. package/dist/adapters/thread/index.cjs.map +1 -0
  68. package/dist/adapters/thread/index.d.cts +34 -0
  69. package/dist/adapters/thread/index.d.ts +34 -0
  70. package/dist/adapters/thread/index.js +12 -0
  71. package/dist/adapters/thread/index.js.map +1 -0
  72. package/dist/adapters/thread/langchain/index.cjs +149 -14
  73. package/dist/adapters/thread/langchain/index.cjs.map +1 -1
  74. package/dist/adapters/thread/langchain/index.d.cts +9 -8
  75. package/dist/adapters/thread/langchain/index.d.ts +9 -8
  76. package/dist/adapters/thread/langchain/index.js +149 -15
  77. package/dist/adapters/thread/langchain/index.js.map +1 -1
  78. package/dist/adapters/thread/langchain/workflow.cjs +9 -3
  79. package/dist/adapters/thread/langchain/workflow.cjs.map +1 -1
  80. package/dist/adapters/thread/langchain/workflow.d.cts +6 -5
  81. package/dist/adapters/thread/langchain/workflow.d.ts +6 -5
  82. package/dist/adapters/thread/langchain/workflow.js +9 -4
  83. package/dist/adapters/thread/langchain/workflow.js.map +1 -1
  84. package/dist/index.cjs +367 -59
  85. package/dist/index.cjs.map +1 -1
  86. package/dist/index.d.cts +11 -11
  87. package/dist/index.d.ts +11 -11
  88. package/dist/index.js +365 -61
  89. package/dist/index.js.map +1 -1
  90. package/dist/{proxy-DEtowJyd.d.cts → proxy-BAKzNGRq.d.cts} +1 -1
  91. package/dist/{proxy-0smGKvx8.d.ts → proxy-DO_MXbY4.d.ts} +1 -1
  92. package/dist/{thread-manager-C-C4pI2z.d.ts → thread-manager-CcRXasqs.d.ts} +2 -2
  93. package/dist/{thread-manager-D4vgzYrh.d.cts → thread-manager-ClwSaUnj.d.cts} +2 -2
  94. package/dist/{thread-manager-3fszQih4.d.ts → thread-manager-D-7lp1JK.d.ts} +2 -2
  95. package/dist/{thread-manager-CzYln2OC.d.cts → thread-manager-Y8Ucf0Tf.d.cts} +2 -2
  96. package/dist/{types-CPKDl-y_.d.ts → types-Bcbiq8iv.d.cts} +195 -22
  97. package/dist/{types-CNuWnvy9.d.ts → types-DAsQ21Rt.d.ts} +1 -1
  98. package/dist/{types-B37hKoWA.d.ts → types-DpHTX-iO.d.ts} +58 -1
  99. package/dist/{types-BO7Yju20.d.cts → types-Dt8-HBBT.d.ts} +195 -22
  100. package/dist/{types-D08CXPh8.d.cts → types-hFFi-Zd9.d.cts} +58 -1
  101. package/dist/{types-DWEUmYAJ.d.cts → types-lm8tMNJQ.d.cts} +1 -1
  102. package/dist/{types-tQL9njTu.d.cts → types-yx0LzPGn.d.cts} +21 -7
  103. package/dist/{types-tQL9njTu.d.ts → types-yx0LzPGn.d.ts} +21 -7
  104. package/dist/{workflow-CjXHbZZc.d.ts → workflow-Bmf9EtDW.d.ts} +83 -3
  105. package/dist/{workflow-Do_lzJpT.d.cts → workflow-Bx9utBwb.d.cts} +83 -3
  106. package/dist/workflow.cjs +266 -39
  107. package/dist/workflow.cjs.map +1 -1
  108. package/dist/workflow.d.cts +3 -3
  109. package/dist/workflow.d.ts +3 -3
  110. package/dist/workflow.js +264 -41
  111. package/dist/workflow.js.map +1 -1
  112. package/package.json +12 -2
  113. package/src/adapters/sandbox/bedrock/index.ts +12 -3
  114. package/src/adapters/sandbox/daytona/index.ts +12 -3
  115. package/src/adapters/sandbox/e2b/index.ts +36 -14
  116. package/src/adapters/sandbox/e2b/types.ts +16 -0
  117. package/src/adapters/sandbox/inmemory/index.ts +12 -3
  118. package/src/adapters/thread/adapter-id.test.ts +42 -0
  119. package/src/adapters/thread/anthropic/activities.ts +40 -5
  120. package/src/adapters/thread/anthropic/adapter-id.ts +16 -0
  121. package/src/adapters/thread/anthropic/fork-transform.test.ts +291 -0
  122. package/src/adapters/thread/anthropic/index.ts +3 -0
  123. package/src/adapters/thread/anthropic/model-invoker.ts +7 -1
  124. package/src/adapters/thread/anthropic/proxy.ts +3 -2
  125. package/src/adapters/thread/anthropic/thread-manager.ts +27 -1
  126. package/src/adapters/thread/google-genai/activities.ts +44 -5
  127. package/src/adapters/thread/google-genai/adapter-id.ts +16 -0
  128. package/src/adapters/thread/google-genai/fork-transform.test.ts +149 -0
  129. package/src/adapters/thread/google-genai/index.ts +3 -0
  130. package/src/adapters/thread/google-genai/model-invoker.ts +8 -2
  131. package/src/adapters/thread/google-genai/proxy.ts +3 -2
  132. package/src/adapters/thread/google-genai/thread-manager.ts +27 -1
  133. package/src/adapters/thread/index.ts +39 -0
  134. package/src/adapters/thread/langchain/activities.ts +40 -5
  135. package/src/adapters/thread/langchain/adapter-id.ts +16 -0
  136. package/src/adapters/thread/langchain/fork-transform.test.ts +142 -0
  137. package/src/adapters/thread/langchain/index.ts +3 -0
  138. package/src/adapters/thread/langchain/model-invoker.ts +7 -1
  139. package/src/adapters/thread/langchain/proxy.ts +3 -2
  140. package/src/adapters/thread/langchain/thread-manager.ts +27 -1
  141. package/src/lib/lifecycle.ts +14 -5
  142. package/src/lib/model/types.ts +7 -0
  143. package/src/lib/sandbox/manager.ts +26 -18
  144. package/src/lib/sandbox/types.ts +27 -7
  145. package/src/lib/session/session-edge-cases.integration.test.ts +336 -4
  146. package/src/lib/session/session.integration.test.ts +192 -2
  147. package/src/lib/session/session.ts +102 -8
  148. package/src/lib/session/types.ts +66 -3
  149. package/src/lib/state/index.ts +1 -0
  150. package/src/lib/state/manager.integration.test.ts +109 -0
  151. package/src/lib/state/manager.ts +38 -8
  152. package/src/lib/state/types.ts +25 -0
  153. package/src/lib/subagent/handler.ts +124 -11
  154. package/src/lib/subagent/index.ts +5 -1
  155. package/src/lib/subagent/subagent.integration.test.ts +628 -104
  156. package/src/lib/subagent/types.ts +63 -14
  157. package/src/lib/subagent/workflow.ts +29 -2
  158. package/src/lib/thread/index.ts +5 -0
  159. package/src/lib/thread/keys.test.ts +101 -0
  160. package/src/lib/thread/keys.ts +94 -0
  161. package/src/lib/thread/manager.test.ts +139 -0
  162. package/src/lib/thread/manager.ts +105 -9
  163. package/src/lib/thread/proxy.ts +3 -0
  164. package/src/lib/thread/types.ts +64 -1
  165. package/src/lib/tool-router/index.ts +2 -0
  166. package/src/lib/tool-router/router-edge-cases.integration.test.ts +92 -0
  167. package/src/lib/tool-router/router.integration.test.ts +12 -0
  168. package/src/lib/tool-router/router.ts +89 -16
  169. package/src/lib/tool-router/types.ts +42 -1
  170. package/src/lib/types.ts +12 -0
  171. package/src/workflow.ts +14 -1
  172. package/tsup.config.ts +1 -0
@@ -7,11 +7,16 @@ import {
7
7
  } from "@temporalio/workflow";
8
8
  import type { SessionExitReason } from "../types";
9
9
  import type { SessionConfig, ZeitlichSession } from "./types";
10
- import type { SandboxOps, SandboxSnapshot } from "../sandbox/types";
10
+ import type {
11
+ SandboxCreateOptions,
12
+ SandboxOps,
13
+ SandboxSnapshot,
14
+ } from "../sandbox/types";
11
15
  import type {
12
16
  AgentState,
13
17
  AgentStateManager,
14
18
  JsonSerializable,
19
+ PersistedThreadState,
15
20
  } from "../state/types";
16
21
  import { createToolRouter } from "../tool-router/router";
17
22
  import type { ParsedToolCallUnion, ToolMap } from "../tool-router/types";
@@ -107,6 +112,7 @@ export async function createSession<
107
112
  sandbox: sandboxInit,
108
113
  sandboxShutdown = "destroy",
109
114
  onSandboxReady,
115
+ onSessionExit,
110
116
  virtualFs: virtualFsConfig,
111
117
  virtualFsOps,
112
118
  }: SessionConfig<T, M, TContent>): Promise<ZeitlichSession<M, boolean>> {
@@ -142,6 +148,8 @@ export async function createSession<
142
148
  appendSystemMessage,
143
149
  appendAgentMessage,
144
150
  forkThread,
151
+ loadThreadState,
152
+ saveThreadState,
145
153
  } = threadOps;
146
154
 
147
155
  const plugins: ToolMap[string][] = [];
@@ -253,8 +261,14 @@ export async function createSession<
253
261
  nonRetryable: true,
254
262
  });
255
263
  }
264
+ const forkInit = sandboxInit as {
265
+ mode: "fork";
266
+ sandboxId: string;
267
+ options?: SandboxCreateOptions;
268
+ };
256
269
  sandboxId = await sandboxOps.forkSandbox(
257
- (sandboxInit as { mode: "fork"; sandboxId: string }).sandboxId
270
+ forkInit.sandboxId,
271
+ forkInit.options
258
272
  );
259
273
  sandboxOwned = true;
260
274
  } else if (sandboxMode === "from-snapshot") {
@@ -264,10 +278,15 @@ export async function createSession<
264
278
  nonRetryable: true,
265
279
  });
266
280
  }
267
- const snap = (
268
- sandboxInit as { mode: "from-snapshot"; snapshot: SandboxSnapshot }
269
- ).snapshot;
270
- sandboxId = await sandboxOps.restoreSandbox(snap);
281
+ const restoreInit = sandboxInit as {
282
+ mode: "from-snapshot";
283
+ snapshot: SandboxSnapshot;
284
+ options?: SandboxCreateOptions;
285
+ };
286
+ sandboxId = await sandboxOps.restoreSandbox(
287
+ restoreInit.snapshot,
288
+ restoreInit.options
289
+ );
271
290
  sandboxOwned = true;
272
291
  } else if (sandboxOps) {
273
292
  const skillFiles = skills ? collectSkillFiles(skills) : undefined;
@@ -298,7 +317,10 @@ export async function createSession<
298
317
  }
299
318
 
300
319
  if (sandboxId && sandboxOwned && onSandboxReady) {
301
- onSandboxReady(sandboxId);
320
+ onSandboxReady({
321
+ sandboxId,
322
+ ...(baseSnapshot && { baseSnapshot }),
323
+ });
302
324
  }
303
325
 
304
326
  // --- Virtual filesystem init (independent of sandbox) ----------------
@@ -350,10 +372,21 @@ export async function createSession<
350
372
  const systemPrompt = stateManager.getSystemPrompt();
351
373
 
352
374
  // --- Thread lifecycle: new, continue, or fork ----------------------
375
+ const rehydrateFromSlice = (slice: PersistedThreadState): void => {
376
+ stateManager.mergeUpdate({
377
+ tasks: new Map(slice.tasks),
378
+ ...slice.custom,
379
+ } as Partial<AgentState<TState>>);
380
+ };
381
+
353
382
  if (threadMode === "fork" && sourceThreadId) {
354
383
  await forkThread(sourceThreadId, threadId, threadKey);
384
+ const forkedSlice = await loadThreadState(threadId, threadKey);
385
+ if (forkedSlice) rehydrateFromSlice(forkedSlice);
355
386
  } else if (threadMode === "continue") {
356
387
  // "continue" — thread already exists, just append the new message
388
+ const continuedSlice = await loadThreadState(threadId, threadKey);
389
+ if (continuedSlice) rehydrateFromSlice(continuedSlice);
357
390
  } else {
358
391
  if (appendSystemPrompt) {
359
392
  if (
@@ -381,6 +414,13 @@ export async function createSession<
381
414
  let finalMessage: M | null = null;
382
415
 
383
416
  try {
417
+ // Per-turn assistant message id. Pre-generated in the workflow
418
+ // so the runAgent activity can truncate the thread from this id
419
+ // on entry (deterministic rewind + time-travel via Temporal
420
+ // workflow reset). On a rewind retry we keep the same id so the
421
+ // prior attempt's assistant + tool results are wiped by the next
422
+ // runAgent call.
423
+ let assistantId: string | undefined;
384
424
  while (
385
425
  stateManager.isRunning() &&
386
426
  !stateManager.isTerminal() &&
@@ -393,14 +433,17 @@ export async function createSession<
393
433
 
394
434
  stateManager.setTools(toolRouter.getToolDefinitions());
395
435
 
436
+ assistantId ??= uuid4();
437
+
396
438
  const { message, rawToolCalls, usage } = await runAgent({
397
439
  threadId,
398
440
  threadKey,
399
441
  agentName,
400
442
  metadata,
443
+ assistantMessageId: assistantId,
401
444
  });
402
445
 
403
- await appendAgentMessage(threadId, uuid4(), message, threadKey);
446
+ await appendAgentMessage(threadId, assistantId, message, threadKey);
404
447
 
405
448
  if (usage) {
406
449
  stateManager.updateUsage(usage);
@@ -452,6 +495,28 @@ export async function createSession<
452
495
  }
453
496
  }
454
497
 
498
+ const rewind = toolCallResults.rewind;
499
+ if (rewind) {
500
+ log.info("rewinding turn", {
501
+ agentName,
502
+ threadId,
503
+ turn: currentTurn,
504
+ toolCallId: rewind.toolCallId,
505
+ toolName: rewind.toolName,
506
+ });
507
+ // Keep the same assistantId for the retry. The next
508
+ // runAgent call will call truncateFromId(assistantId) on
509
+ // entry, wiping the bad assistant message + any already
510
+ // appended tool results before re-invoking the LLM. The
511
+ // turn counter is intentionally NOT rolled back — each
512
+ // rewind still consumes one of the `maxTurns` budget so a
513
+ // misbehaving tool cannot spin the session forever.
514
+ continue;
515
+ }
516
+
517
+ // Turn committed: fresh id for the next turn.
518
+ assistantId = undefined;
519
+
455
520
  if (stateManager.getStatus() === "WAITING_FOR_INPUT") {
456
521
  const conditionMet = await condition(
457
522
  () => stateManager.getStatus() === "RUNNING",
@@ -485,6 +550,28 @@ export async function createSession<
485
550
  });
486
551
  throw ApplicationFailure.fromError(error);
487
552
  } finally {
553
+ // Persist the task map + custom state slice alongside the thread so
554
+ // a future `continue` / `fork` run can rehydrate it. Runs on every
555
+ // exit path (completed, failed, cancelled, max_turns,
556
+ // waiting_for_input timeout). Best-effort: failures here must not
557
+ // mask the original exit reason / error.
558
+ try {
559
+ await saveThreadState(
560
+ threadId,
561
+ stateManager.getPersistedSlice(),
562
+ threadKey
563
+ );
564
+ } catch (persistError) {
565
+ log.warn("failed to persist thread state", {
566
+ agentName,
567
+ threadId,
568
+ error:
569
+ persistError instanceof Error
570
+ ? persistError.message
571
+ : String(persistError),
572
+ });
573
+ }
574
+
488
575
  await callSessionEnd(exitReason, stateManager.getTurns());
489
576
 
490
577
  if (sandboxOwned && sandboxId && sandboxOps) {
@@ -526,6 +613,13 @@ export async function createSession<
526
613
  ...(exitSnapshot && { hasExitSnapshot: true }),
527
614
  });
528
615
 
616
+ if (onSessionExit) {
617
+ onSessionExit({
618
+ ...(sandboxId && { sandboxId }),
619
+ ...(exitSnapshot && { snapshot: exitSnapshot }),
620
+ });
621
+ }
622
+
529
623
  return {
530
624
  threadId,
531
625
  finalMessage,
@@ -11,7 +11,11 @@ import type { Skill } from "../skills/types";
11
11
  import type { SandboxOps, SandboxSnapshot } from "../sandbox/types";
12
12
  import type { VirtualFsOps } from "../virtual-fs/types";
13
13
  import type { RunAgentActivity } from "../model/types";
14
- import type { AgentStateManager, JsonSerializable } from "../state/types";
14
+ import type {
15
+ AgentStateManager,
16
+ JsonSerializable,
17
+ PersistedThreadState,
18
+ } from "../state/types";
15
19
  import type { ActivityInterfaceFor } from "@temporalio/workflow";
16
20
  import type {
17
21
  ThreadInit,
@@ -53,12 +57,54 @@ export interface ThreadOps<TContent = string> {
53
57
  content: unknown,
54
58
  threadKey?: string
55
59
  ): Promise<void>;
56
- /** Copy all messages from sourceThreadId into a new thread at targetThreadId */
60
+ /**
61
+ * Copy all messages AND the persisted state slice (tasks + custom
62
+ * state) from `sourceThreadId` into a new thread at `targetThreadId`.
63
+ * Adapters that have `onForkPrepareThread` and/or `onForkTransform`
64
+ * hooks configured apply them once to the new thread's messages
65
+ * before returning.
66
+ */
57
67
  forkThread(
58
68
  sourceThreadId: string,
59
69
  targetThreadId: string,
60
70
  threadKey?: string
61
71
  ): Promise<void>;
72
+ /**
73
+ * Truncate the thread starting at `messageId`: that message and every
74
+ * message after it are removed. If `messageId` is not present the call
75
+ * is a no-op.
76
+ *
77
+ * The `runAgent` activity invokes this on entry with the pre-generated
78
+ * `assistantMessageId`. On the happy path the id is not yet in the
79
+ * thread and the call is a no-op. On a rewind retry (same assistant
80
+ * id reused) or a Temporal workflow reset-to-this-activity the id is
81
+ * present, so the bad assistant + any tool results it produced are
82
+ * wiped and the call is then replayable.
83
+ */
84
+ truncateThread(
85
+ threadId: string,
86
+ messageId: string,
87
+ threadKey?: string
88
+ ): Promise<void>;
89
+ /**
90
+ * Load the persisted state slice (tasks + custom state) associated with
91
+ * the thread, or `null` if none has been saved yet. Called on session
92
+ * start for `continue`/`fork` threads to rehydrate {@link AgentStateManager}.
93
+ */
94
+ loadThreadState(
95
+ threadId: string,
96
+ threadKey?: string
97
+ ): Promise<PersistedThreadState | null>;
98
+ /**
99
+ * Overwrite the persisted state slice for the thread. Called once from
100
+ * the session's `finally` block on every exit path so that "finish,
101
+ * store, continue later" works regardless of exit reason.
102
+ */
103
+ saveThreadState(
104
+ threadId: string,
105
+ state: PersistedThreadState,
106
+ threadKey?: string
107
+ ): Promise<void>;
62
108
  }
63
109
 
64
110
  /**
@@ -183,8 +229,25 @@ export interface SessionConfig<
183
229
  /**
184
230
  * Called as soon as the sandbox is created (or resumed/forked), before the
185
231
  * agent loop starts. Useful for signalling sandbox readiness to a parent.
232
+ *
233
+ * `baseSnapshot` is only populated when the sandbox was freshly created
234
+ * this run and `sandboxShutdown === "snapshot"` — i.e. when the session
235
+ * captured a seed snapshot intended for reuse.
236
+ */
237
+ onSandboxReady?: (args: {
238
+ sandboxId: string;
239
+ baseSnapshot?: SandboxSnapshot;
240
+ }) => void;
241
+ /**
242
+ * Called right before `runSession` returns, with the session's sandbox
243
+ * outputs. Useful for callers (e.g. `defineSubagentWorkflow`) that want to
244
+ * forward these fields to their own return value without requiring user
245
+ * code to manually thread them through.
186
246
  */
187
- onSandboxReady?: (sandboxId: string) => void;
247
+ onSessionExit?: (result: {
248
+ sandboxId?: string;
249
+ snapshot?: SandboxSnapshot;
250
+ }) => void;
188
251
 
189
252
  // ---------------------------------------------------------------------------
190
253
  // Virtual filesystem
@@ -6,4 +6,5 @@ export type {
6
6
  JsonSerializable,
7
7
  AgentState,
8
8
  AgentStateManager,
9
+ PersistedThreadState,
9
10
  } from "./types";
@@ -350,4 +350,113 @@ describe("createAgentStateManager integration", () => {
350
350
  sm.incrementTurns();
351
351
  expect(sm.getTotalUsage().turns).toBe(2);
352
352
  });
353
+
354
+ // --- Persisted slice round-trip ---
355
+
356
+ it("getPersistedSlice snapshots tasks + custom state, omits runtime bookkeeping", () => {
357
+ const sm = createAgentStateManager<{ label: string; count: number }>({
358
+ initialState: {
359
+ systemPrompt: "test",
360
+ label: "original",
361
+ count: 1,
362
+ },
363
+ });
364
+
365
+ const task: WorkflowTask = {
366
+ id: "task-1",
367
+ subject: "subject",
368
+ description: "description",
369
+ activeForm: "doing",
370
+ status: "pending",
371
+ metadata: {},
372
+ blockedBy: [],
373
+ blocks: [],
374
+ };
375
+ sm.setTask(task);
376
+ sm.incrementTurns();
377
+ sm.updateUsage({ inputTokens: 42 });
378
+
379
+ const slice = sm.getPersistedSlice();
380
+ expect(slice.tasks).toEqual([["task-1", task]]);
381
+ expect(slice.custom).toEqual({ label: "original", count: 1 });
382
+ expect("status" in slice.custom).toBe(false);
383
+ expect("version" in slice.custom).toBe(false);
384
+ expect("turns" in slice.custom).toBe(false);
385
+ expect("tools" in slice.custom).toBe(false);
386
+ });
387
+
388
+ it("mergeUpdate replaces the task map when given a tasks field", () => {
389
+ const sm = createAgentStateManager<{ label: string; extra?: string }>({
390
+ initialState: {
391
+ systemPrompt: "test",
392
+ label: "original",
393
+ },
394
+ });
395
+
396
+ const existing: WorkflowTask = {
397
+ id: "task-pre",
398
+ subject: "pre",
399
+ description: "pre",
400
+ activeForm: "pre",
401
+ status: "pending",
402
+ metadata: {},
403
+ blockedBy: [],
404
+ blocks: [],
405
+ };
406
+ sm.setTask(existing);
407
+ const versionBefore = sm.getVersion();
408
+
409
+ const incoming: WorkflowTask = {
410
+ id: "task-new",
411
+ subject: "new",
412
+ description: "new",
413
+ activeForm: "new",
414
+ status: "in_progress",
415
+ metadata: { foo: "bar" },
416
+ blockedBy: [],
417
+ blocks: [],
418
+ };
419
+
420
+ sm.mergeUpdate({
421
+ tasks: new Map([["task-new", incoming]]),
422
+ label: "restored",
423
+ extra: "extra-value",
424
+ });
425
+
426
+ expect(sm.getTasks()).toEqual([incoming]);
427
+ expect(sm.getTask("task-pre")).toBeUndefined();
428
+ expect(sm.get("label")).toBe("restored");
429
+ expect(sm.get("extra")).toBe("extra-value");
430
+ expect(sm.getVersion()).toBe(versionBefore + 1);
431
+ });
432
+
433
+ it("getPersistedSlice + mergeUpdate round-trips tasks + custom state", () => {
434
+ const original = createAgentStateManager<{ answer: number }>({
435
+ initialState: { systemPrompt: "test", answer: 42 },
436
+ });
437
+ const task: WorkflowTask = {
438
+ id: "t",
439
+ subject: "s",
440
+ description: "d",
441
+ activeForm: "doing",
442
+ status: "completed",
443
+ metadata: { a: "b" },
444
+ blockedBy: ["x"],
445
+ blocks: ["y"],
446
+ };
447
+ original.setTask(task);
448
+
449
+ const slice = original.getPersistedSlice();
450
+
451
+ const restored = createAgentStateManager<{ answer: number }>({
452
+ initialState: { systemPrompt: "test", answer: 0 },
453
+ });
454
+ restored.mergeUpdate({
455
+ tasks: new Map(slice.tasks),
456
+ ...slice.custom,
457
+ });
458
+
459
+ expect(restored.getTasks()).toEqual([task]);
460
+ expect(restored.get("answer")).toBe(42);
461
+ });
353
462
  });
@@ -11,7 +11,13 @@ import {
11
11
  isTerminalStatus,
12
12
  } from "../types";
13
13
  import type { ToolDefinition } from "../tool-router/types";
14
- import type { AgentState, AgentStateManager, JsonSerializable } from "./types";
14
+ import type {
15
+ AgentState,
16
+ AgentStateManager,
17
+ JsonSerializable,
18
+ JsonValue,
19
+ PersistedThreadState,
20
+ } from "./types";
15
21
  import { z } from "zod";
16
22
 
17
23
  /**
@@ -63,11 +69,19 @@ export function createAgentStateManager<
63
69
  const tasks = new Map<string, WorkflowTask>(initialState?.tasks);
64
70
 
65
71
  const {
66
- status: _,
67
- version: __,
68
- turns: ___,
69
- tasks: ____,
70
- tools: _____,
72
+ status: _status,
73
+ version: _version,
74
+ turns: _turns,
75
+ tasks: _tasks,
76
+ tools: _tools,
77
+ systemPrompt: _systemPrompt,
78
+ fileTree: _fileTree,
79
+ inlineFiles: _inlineFiles,
80
+ virtualFsCtx: _virtualFsCtx,
81
+ totalInputTokens: _totalInputTokens,
82
+ totalOutputTokens: _totalOutputTokens,
83
+ cachedWriteTokens: _cachedWriteTokens,
84
+ cachedReadTokens: _cachedReadTokens,
71
85
  ...custom
72
86
  } = initialState ?? {};
73
87
  const customState = custom as TCustom;
@@ -166,8 +180,17 @@ export function createAgentStateManager<
166
180
  version++;
167
181
  },
168
182
 
169
- mergeUpdate(update: Partial<TCustom>): void {
170
- Object.assign(customState as object, update);
183
+ mergeUpdate(update: Partial<AgentState<TCustom>>): void {
184
+ const { tasks: nextTasks, ...rest } = update as Partial<
185
+ AgentState<TCustom>
186
+ > & { tasks?: Map<string, WorkflowTask> };
187
+ if (nextTasks) {
188
+ tasks.clear();
189
+ for (const [id, task] of nextTasks) {
190
+ tasks.set(id, task);
191
+ }
192
+ }
193
+ Object.assign(customState as object, rest);
171
194
  version++;
172
195
  },
173
196
 
@@ -214,6 +237,13 @@ export function createAgentStateManager<
214
237
  return deleted;
215
238
  },
216
239
 
240
+ getPersistedSlice(): PersistedThreadState {
241
+ return {
242
+ tasks: Array.from(tasks.entries()),
243
+ custom: { ...(customState as unknown as Record<string, JsonValue>) },
244
+ };
245
+ },
246
+
217
247
  updateUsage(usage: {
218
248
  inputTokens?: number;
219
249
  outputTokens?: number;
@@ -48,6 +48,23 @@ export type JsonSerializable<T> = {
48
48
  export type AgentState<TCustom extends JsonSerializable<TCustom>> =
49
49
  BaseAgentState & TCustom;
50
50
 
51
+ /**
52
+ * The slice of agent state that is persisted alongside the thread in the
53
+ * thread store (e.g. Redis) so that a workflow can terminate, store its
54
+ * state, and be continued or forked later with that state rehydrated.
55
+ *
56
+ * Only fields that make sense to carry across workflow runs belong here.
57
+ * Runtime bookkeeping like status, version, turns, tools, fileTree, token
58
+ * counters, and the system prompt is intentionally NOT persisted — each run
59
+ * rebuilds those from scratch.
60
+ */
61
+ export interface PersistedThreadState {
62
+ /** Task map serialized as entries so it round-trips through JSON. */
63
+ tasks: [string, WorkflowTask][];
64
+ /** All custom state fields declared by the caller. */
65
+ custom: Record<string, JsonValue>;
66
+ }
67
+
51
68
  /**
52
69
  * Agent state manager interface
53
70
  * Note: Temporal handlers must be set up in the workflow file due to
@@ -122,6 +139,14 @@ export interface AgentStateManager<TCustom extends JsonSerializable<TCustom>> {
122
139
  /** Set the tools (converts Zod schemas to JSON Schema for serialization) */
123
140
  setTools(newTools: ToolDefinition[]): void;
124
141
 
142
+ /**
143
+ * Snapshot the fields that should survive across workflow runs
144
+ * (tasks + all custom state). Safe to pass directly to
145
+ * {@link ThreadOps.saveThreadState}. Rehydrate on the next run with
146
+ * `mergeUpdate({ tasks: new Map(slice.tasks), ...slice.custom })`.
147
+ */
148
+ getPersistedSlice(): PersistedThreadState;
149
+
125
150
  /** Update the usage */
126
151
  updateUsage(usage: TokenUsage): void;
127
152